Add 'packages/client/' from commit '180edbc46a556f6d572c3b4ade4b396a31a1bc42'
git-subtree-dir: packages/client git-subtree-mainline:ae0afd3408
git-subtree-split:180edbc46a
This commit is contained in:
commit
998aa62303
27 changed files with 1597 additions and 0 deletions
4
packages/client/.babelrc
Normal file
4
packages/client/.babelrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"presets": ["es2015"],
|
||||||
|
"plugins": ["add-module-exports"]
|
||||||
|
}
|
81
packages/client/.gitignore
vendored
Normal file
81
packages/client/.gitignore
vendored
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
# See https://www.dartlang.org/tools/private-files.html
|
||||||
|
|
||||||
|
# Files and directories created by pub
|
||||||
|
.buildlog
|
||||||
|
.packages
|
||||||
|
.project
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
**/packages/
|
||||||
|
|
||||||
|
# Files created by dart2js
|
||||||
|
# (Most Dart developers will use pub build to compile Dart, use/modify these
|
||||||
|
# rules if you intend to use dart2js directly
|
||||||
|
# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
|
||||||
|
# differentiate from explicit Javascript files)
|
||||||
|
*.dart.js
|
||||||
|
*.part.js
|
||||||
|
*.js.deps
|
||||||
|
*.js.map
|
||||||
|
*.info.json
|
||||||
|
|
||||||
|
# Directory created by dartdoc
|
||||||
|
doc/api/
|
||||||
|
|
||||||
|
# Don't commit pubspec lock file
|
||||||
|
# (Library packages only! Remove pattern if developing an application package)
|
||||||
|
pubspec.lock
|
||||||
|
.idea
|
||||||
|
|
||||||
|
lib/angel_client.js
|
||||||
|
*.sum
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules
|
||||||
|
jspm_packages
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
.dart_tool
|
10
packages/client/.npmignore
Normal file
10
packages/client/.npmignore
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.babelrc
|
||||||
|
.istanbul.yml
|
||||||
|
.travis.yml
|
||||||
|
.editorconfig
|
||||||
|
.idea/
|
||||||
|
src/
|
||||||
|
test/
|
||||||
|
!lib/
|
||||||
|
.github/
|
||||||
|
coverage
|
4
packages/client/.travis.yml
Normal file
4
packages/client/.travis.yml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
language: dart
|
||||||
|
dart:
|
||||||
|
- dev
|
||||||
|
- stable
|
43
packages/client/CHANGELOG.md
Normal file
43
packages/client/CHANGELOG.md
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# 2.0.2
|
||||||
|
* `_join` previously discarded quer parameters, etc.
|
||||||
|
* Allow any `Map<String, dynamic>` as body, not just `Map<String, String>`.
|
||||||
|
|
||||||
|
# 2.0.1
|
||||||
|
* Change `BaseAngelClient` constructor to accept `dynamic` instead of `String` for `baseUrl.
|
||||||
|
|
||||||
|
# 2.0.0
|
||||||
|
* Deprecate `basePath` in favor of `baseUrl`.
|
||||||
|
* `Angel` now extends `http.Client`.
|
||||||
|
* Deprecate `auth_types`.
|
||||||
|
|
||||||
|
# 2.0.0-alpha.2
|
||||||
|
* Make Service `index` always return `List<Data>`.
|
||||||
|
* Add `Service.map`.
|
||||||
|
|
||||||
|
# 2.0.0-alpha.1
|
||||||
|
* Refactor `params` to `Map<String, dynamic>`.
|
||||||
|
|
||||||
|
# 2.0.0-alpha
|
||||||
|
* Depend on Dart 2.
|
||||||
|
* Depend on Angel 2.
|
||||||
|
* Remove `dart2_constant`.
|
||||||
|
|
||||||
|
# 1.2.0+2
|
||||||
|
* Code cleanup + housekeeping, update to `dart2_constant`, and
|
||||||
|
ensured build works with `2.0.0-dev.64.1`.
|
||||||
|
|
||||||
|
# 1.2.0+1
|
||||||
|
* Removed a type annotation in `authenticateViaPopup` to prevent breaking with DDC.
|
||||||
|
|
||||||
|
# 1.2.0
|
||||||
|
* `ServiceList` now uses `Equality` from `package:collection` to compare items.
|
||||||
|
* `Service`s will now add service errors to corresponding streams if there is a listener.
|
||||||
|
|
||||||
|
# 1.1.0+3
|
||||||
|
* `ServiceList` no longer ignores empty `index` events.
|
||||||
|
|
||||||
|
# 1.1.0+2
|
||||||
|
* Updated `ServiceList` to also fire on `index`.
|
||||||
|
|
||||||
|
# 1.1.0+1
|
||||||
|
* Added `ServiceList`.
|
21
packages/client/LICENSE
Normal file
21
packages/client/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2016 angel-dart
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
106
packages/client/README.md
Normal file
106
packages/client/README.md
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
# angel_client
|
||||||
|
|
||||||
|
[![Pub](https://img.shields.io/pub/v/angel_client.svg)](https://pub.dartlang.org/packages/angel_client)
|
||||||
|
[![build status](https://travis-ci.org/angel-dart/client.svg)](https://travis-ci.org/angel-dart/client)
|
||||||
|
|
||||||
|
Client library for the Angel framework.
|
||||||
|
This library provides virtually the same API as an Angel server.
|
||||||
|
The client can run in the browser, in Flutter, or on the command-line.
|
||||||
|
In addition, the client supports `angel_auth` authentication.
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Choose one or the other, depending on platform
|
||||||
|
import 'package:angel_client/io.dart';
|
||||||
|
import 'package:angel_client/browser.dart';
|
||||||
|
import 'package:angel_client/flutter.dart';
|
||||||
|
|
||||||
|
main() async {
|
||||||
|
Angel app = new Rest("http://localhost:3000");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can call `service` to receive an instance of `Service`, which acts as a client to a
|
||||||
|
service on the server at the given path (say that five times fast!).
|
||||||
|
|
||||||
|
```dart
|
||||||
|
foo() async {
|
||||||
|
Service Todos = app.service("todos");
|
||||||
|
List<Map> todos = await Todos.index();
|
||||||
|
|
||||||
|
print(todos.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI client also supports reflection via `json_god`. There is no need to work with Maps;
|
||||||
|
you can use the same class on the client and the server.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class Todo extends Model {
|
||||||
|
String text;
|
||||||
|
|
||||||
|
Todo({String this.text});
|
||||||
|
}
|
||||||
|
|
||||||
|
bar() async {
|
||||||
|
// By the next release, this will just be:
|
||||||
|
// app.service<Todo>("todos")
|
||||||
|
Service Todos = app.service("todos", type: Todo);
|
||||||
|
List<Todo> todos = await Todos.index();
|
||||||
|
|
||||||
|
print(todos.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Just like on the server, services support `index`, `read`, `create`, `modify`, `update` and
|
||||||
|
`remove`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
Local auth:
|
||||||
|
```dart
|
||||||
|
var auth = await app.authenticate(type: 'local', credentials: {username: ..., password: ...});
|
||||||
|
print(auth.token);
|
||||||
|
print(auth.data); // User object
|
||||||
|
```
|
||||||
|
|
||||||
|
Revive an existing jwt:
|
||||||
|
```dart
|
||||||
|
Future<AngelAuthResult> reviveJwt(String jwt) {
|
||||||
|
return app.authenticate(credentials: {'token': jwt});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Via Popup:
|
||||||
|
```dart
|
||||||
|
app.authenticateViaPopup('/auth/google').listen((jwt) {
|
||||||
|
// Do something with the JWT
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Resume a session from localStorage (browser only):
|
||||||
|
```dart
|
||||||
|
// Automatically checks for JSON-encoded 'token' in localStorage,
|
||||||
|
// and tries to revive it
|
||||||
|
await app.authenticate();
|
||||||
|
```
|
||||||
|
|
||||||
|
Logout:
|
||||||
|
```dart
|
||||||
|
await app.logout();
|
||||||
|
```
|
||||||
|
|
||||||
|
# Live Updates
|
||||||
|
Oftentimes, you will want to update a collection based on updates from a service.
|
||||||
|
Use `ServiceList` for this case:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
build(BuildContext context) async {
|
||||||
|
var list = new ServiceList(app.service('api/todos'));
|
||||||
|
|
||||||
|
return new StreamBuilder(
|
||||||
|
stream: list.onChange,
|
||||||
|
builder: _yourBuildFunction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
6
packages/client/analysis_options.yaml
Normal file
6
packages/client/analysis_options.yaml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
include: package:pedantic/analysis_options.yaml
|
||||||
|
analyzer:
|
||||||
|
strong-mode:
|
||||||
|
implicit-casts: false
|
||||||
|
# errors:
|
||||||
|
# unawaited_futures: ignore
|
14
packages/client/build.yaml
Normal file
14
packages/client/build.yaml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
builders:
|
||||||
|
build_web_compilers|entrypoint:
|
||||||
|
generate_for:
|
||||||
|
- web/**.dart
|
||||||
|
options:
|
||||||
|
dart2js_args:
|
||||||
|
- --dump-info
|
||||||
|
- --fast-startup
|
||||||
|
- --minify
|
||||||
|
- --trust-type-annotations
|
||||||
|
- --trust-primitives
|
||||||
|
- --no-source-maps
|
1
packages/client/dart_test.yaml
Normal file
1
packages/client/dart_test.yaml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# platforms: [vm, content-shell]
|
21
packages/client/example/main.dart
Normal file
21
packages/client/example/main.dart
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:angel_client/angel_client.dart';
|
||||||
|
|
||||||
|
Future doSomething(Angel app) async {
|
||||||
|
var userService = app
|
||||||
|
.service<String, Map<String, dynamic>>('api/users')
|
||||||
|
.map(User.fromMap, User.toMap);
|
||||||
|
|
||||||
|
var users = await userService.index();
|
||||||
|
print('Name: ${users.first.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
User({this.name});
|
||||||
|
|
||||||
|
static User fromMap(Map data) => User(name: data['name'] as String);
|
||||||
|
|
||||||
|
static Map<String, String> toMap(User user) => {'name': user.name};
|
||||||
|
}
|
347
packages/client/lib/angel_client.dart
Normal file
347
packages/client/lib/angel_client.dart
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
/// Client library for the Angel framework.
|
||||||
|
library angel_client;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
export 'package:angel_http_exception/angel_http_exception.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
/// A function that configures an [Angel] client in some way.
|
||||||
|
typedef FutureOr<void> AngelConfigurer(Angel app);
|
||||||
|
|
||||||
|
/// A function that deserializes data received from the server.
|
||||||
|
///
|
||||||
|
/// This is only really necessary in the browser, where `json_god`
|
||||||
|
/// doesn't work.
|
||||||
|
typedef T AngelDeserializer<T>(x);
|
||||||
|
|
||||||
|
/// Represents an Angel server that we are querying.
|
||||||
|
abstract class Angel extends http.BaseClient {
|
||||||
|
/// A mutable member. When this is set, it holds a JSON Web Token
|
||||||
|
/// that is automatically attached to every request sent.
|
||||||
|
///
|
||||||
|
/// This is designed with `package:angel_auth` in mind.
|
||||||
|
String authToken;
|
||||||
|
|
||||||
|
/// The root URL at which the target server.
|
||||||
|
final Uri baseUrl;
|
||||||
|
|
||||||
|
Angel(baseUrl)
|
||||||
|
: this.baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString());
|
||||||
|
|
||||||
|
/// Prefer to use [baseUrl] instead.
|
||||||
|
@deprecated
|
||||||
|
String get basePath => baseUrl.toString();
|
||||||
|
|
||||||
|
/// Fired whenever a WebSocket is successfully authenticated.
|
||||||
|
Stream<AngelAuthResult> get onAuthenticated;
|
||||||
|
|
||||||
|
/// Authenticates against the server.
|
||||||
|
///
|
||||||
|
/// This is designed with `package:angel_auth` in mind.
|
||||||
|
///
|
||||||
|
/// The [type] is appended to the [authEndpoint], ex. `local` becomes `/auth/local`.
|
||||||
|
///
|
||||||
|
/// The given [credentials] are sent to server as-is; the request body is sent as JSON.
|
||||||
|
Future<AngelAuthResult> authenticate(
|
||||||
|
{@required String type,
|
||||||
|
credentials,
|
||||||
|
String authEndpoint = '/auth',
|
||||||
|
@deprecated String reviveEndpoint = '/auth/token'});
|
||||||
|
|
||||||
|
/// Shorthand for authenticating via a JWT string.
|
||||||
|
Future<AngelAuthResult> reviveJwt(String token,
|
||||||
|
{String authEndpoint = '/auth'}) {
|
||||||
|
return authenticate(
|
||||||
|
type: 'token',
|
||||||
|
credentials: {'token': token},
|
||||||
|
authEndpoint: authEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the [url] in a new window, and returns a [Stream] that will fire a JWT on successful authentication.
|
||||||
|
Stream<String> authenticateViaPopup(String url, {String eventName = 'token'});
|
||||||
|
|
||||||
|
/// Disposes of any outstanding resources.
|
||||||
|
Future<void> close();
|
||||||
|
|
||||||
|
/// Applies an [AngelConfigurer] to this instance.
|
||||||
|
Future<void> configure(AngelConfigurer configurer) async {
|
||||||
|
await configurer(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs the current user out of the application.
|
||||||
|
FutureOr<void> logout();
|
||||||
|
|
||||||
|
/// Creates a [Service] instance that queries a given path on the server.
|
||||||
|
///
|
||||||
|
/// This expects that there is an Angel `Service` mounted on the server.
|
||||||
|
///
|
||||||
|
/// In other words, all endpoints will return [Data], except for the root of
|
||||||
|
/// [path], which returns a [List<Data>].
|
||||||
|
///
|
||||||
|
/// You can pass a custom [deserializer], which is typically necessary in cases where
|
||||||
|
/// `dart:mirrors` does not exist.
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{@deprecated Type type, AngelDeserializer<Data> deserializer});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> delete(url, {Map<String, String> headers});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> get(url, {Map<String, String> headers});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> head(url, {Map<String, String> headers});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> patch(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> post(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> put(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the result of authentication with an Angel server.
|
||||||
|
class AngelAuthResult {
|
||||||
|
String _token;
|
||||||
|
final Map<String, dynamic> data = {};
|
||||||
|
|
||||||
|
/// The JSON Web token that was sent with this response.
|
||||||
|
String get token => _token;
|
||||||
|
|
||||||
|
AngelAuthResult({String token, Map<String, dynamic> data = const {}}) {
|
||||||
|
_token = token;
|
||||||
|
this.data.addAll(data ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to deserialize a response from a [Map].
|
||||||
|
factory AngelAuthResult.fromMap(Map data) {
|
||||||
|
final result = new AngelAuthResult();
|
||||||
|
|
||||||
|
if (data is Map && data.containsKey('token') && data['token'] is String)
|
||||||
|
result._token = data['token'].toString();
|
||||||
|
|
||||||
|
if (data is Map)
|
||||||
|
result.data.addAll((data['data'] as Map<String, dynamic>) ?? {});
|
||||||
|
|
||||||
|
if (result.token == null) {
|
||||||
|
throw new FormatException(
|
||||||
|
'The required "token" field was not present in the given data.');
|
||||||
|
} else if (data['data'] is! Map) {
|
||||||
|
throw new FormatException(
|
||||||
|
'The required "data" field in the given data was not a map; instead, it was ${data['data']}.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to deserialize a response from a [String].
|
||||||
|
factory AngelAuthResult.fromJson(String s) =>
|
||||||
|
new AngelAuthResult.fromMap(json.decode(s) as Map);
|
||||||
|
|
||||||
|
/// Converts this instance into a JSON-friendly representation.
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'token': token, 'data': data};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries a service on an Angel server, with the same API.
|
||||||
|
abstract class Service<Id, Data> {
|
||||||
|
/// Fired on `indexed` events.
|
||||||
|
Stream<List<Data>> get onIndexed;
|
||||||
|
|
||||||
|
/// Fired on `read` events.
|
||||||
|
Stream<Data> get onRead;
|
||||||
|
|
||||||
|
/// Fired on `created` events.
|
||||||
|
Stream<Data> get onCreated;
|
||||||
|
|
||||||
|
/// Fired on `modified` events.
|
||||||
|
Stream<Data> get onModified;
|
||||||
|
|
||||||
|
/// Fired on `updated` events.
|
||||||
|
Stream<Data> get onUpdated;
|
||||||
|
|
||||||
|
/// Fired on `removed` events.
|
||||||
|
Stream<Data> get onRemoved;
|
||||||
|
|
||||||
|
/// The Angel instance powering this service.
|
||||||
|
Angel get app;
|
||||||
|
|
||||||
|
Future close();
|
||||||
|
|
||||||
|
/// Retrieves all resources.
|
||||||
|
Future<List<Data>> index([Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Retrieves the desired resource.
|
||||||
|
Future<Data> read(Id id, [Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Creates a resource.
|
||||||
|
Future<Data> create(Data data, [Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Modifies a resource.
|
||||||
|
Future<Data> modify(Id id, Data data, [Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Overwrites a resource.
|
||||||
|
Future<Data> update(Id id, Data data, [Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Removes the given resource.
|
||||||
|
Future<Data> remove(Id id, [Map<String, dynamic> params]);
|
||||||
|
|
||||||
|
/// Creates a [Service] that wraps over this one, and maps input and output using two converter functions.
|
||||||
|
///
|
||||||
|
/// Handy utility for handling data in a type-safe manner.
|
||||||
|
Service<Id, U> map<U>(U Function(Data) encoder, Data Function(U) decoder) {
|
||||||
|
return new _MappedService(this, encoder, decoder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MappedService<Id, Data, U> extends Service<Id, U> {
|
||||||
|
final Service<Id, Data> inner;
|
||||||
|
final U Function(Data) encoder;
|
||||||
|
final Data Function(U) decoder;
|
||||||
|
|
||||||
|
_MappedService(this.inner, this.encoder, this.decoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Angel get app => inner.app;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() => new Future.value();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<U> create(U data, [Map<String, dynamic> params]) {
|
||||||
|
return inner.create(decoder(data)).then(encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<U>> index([Map<String, dynamic> params]) {
|
||||||
|
return inner.index(params).then((l) => l.map(encoder).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<U> modify(Id id, U data, [Map<String, dynamic> params]) {
|
||||||
|
return inner.modify(id, decoder(data), params).then(encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<U> get onCreated => inner.onCreated.map(encoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<U>> get onIndexed =>
|
||||||
|
inner.onIndexed.map((l) => l.map(encoder).toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<U> get onModified => inner.onModified.map(encoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<U> get onRead => inner.onRead.map(encoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<U> get onRemoved => inner.onRemoved.map(encoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<U> get onUpdated => inner.onUpdated.map(encoder);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<U> read(Id id, [Map<String, dynamic> params]) {
|
||||||
|
return inner.read(id, params).then(encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<U> remove(Id id, [Map<String, dynamic> params]) {
|
||||||
|
return inner.remove(id, params).then(encoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<U> update(Id id, U data, [Map<String, dynamic> params]) {
|
||||||
|
return inner.update(id, decoder(data), params).then(encoder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [List] that automatically updates itself whenever the referenced [service] fires an event.
|
||||||
|
class ServiceList<Id, Data> extends DelegatingList<Data> {
|
||||||
|
/// A field name used to compare [Map] by ID.
|
||||||
|
final String idField;
|
||||||
|
|
||||||
|
/// A function used to compare the ID's two items for equality.
|
||||||
|
///
|
||||||
|
/// Defaults to comparing the [idField] of `Map` instances.
|
||||||
|
Equality<Data> get equality => _equality;
|
||||||
|
|
||||||
|
Equality<Data> _equality;
|
||||||
|
|
||||||
|
final Service<Id, Data> service;
|
||||||
|
|
||||||
|
final StreamController<ServiceList<Id, Data>> _onChange =
|
||||||
|
new StreamController();
|
||||||
|
|
||||||
|
final List<StreamSubscription> _subs = [];
|
||||||
|
|
||||||
|
ServiceList(this.service, {this.idField = 'id', Equality<Data> equality})
|
||||||
|
: super([]) {
|
||||||
|
_equality = equality;
|
||||||
|
_equality ??= new EqualityBy<Data, Id>((map) {
|
||||||
|
if (map is Map)
|
||||||
|
return map[idField ?? 'id'] as Id;
|
||||||
|
else
|
||||||
|
throw new UnsupportedError(
|
||||||
|
'ServiceList only knows how to find the id from a Map object. Provide a custom `Equality` in your call to the constructor.');
|
||||||
|
});
|
||||||
|
// Index
|
||||||
|
_subs.add(service.onIndexed.where(_notNull).listen((data) {
|
||||||
|
this
|
||||||
|
..clear()
|
||||||
|
..addAll(data);
|
||||||
|
_onChange.add(this);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Created
|
||||||
|
_subs.add(service.onCreated.where(_notNull).listen((item) {
|
||||||
|
add(item);
|
||||||
|
_onChange.add(this);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Modified/Updated
|
||||||
|
handleModified(Data item) {
|
||||||
|
var indices = <int>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
if (_equality.equals(item, this[i])) indices.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indices.isNotEmpty) {
|
||||||
|
for (var i in indices) this[i] = item;
|
||||||
|
|
||||||
|
_onChange.add(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_subs.addAll([
|
||||||
|
service.onModified.where(_notNull).listen(handleModified),
|
||||||
|
service.onUpdated.where(_notNull).listen(handleModified),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Removed
|
||||||
|
_subs.add(service.onRemoved.where(_notNull).listen((item) {
|
||||||
|
removeWhere((x) => _equality.equals(item, x));
|
||||||
|
_onChange.add(this);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _notNull(x) => x != null;
|
||||||
|
|
||||||
|
/// Fires whenever the underlying [service] fires a change event.
|
||||||
|
Stream<ServiceList<Id, Data>> get onChange => _onChange.stream;
|
||||||
|
|
||||||
|
Future close() async {
|
||||||
|
await _onChange.close();
|
||||||
|
}
|
||||||
|
}
|
4
packages/client/lib/auth_types.dart
Normal file
4
packages/client/lib/auth_types.dart
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
@deprecated
|
||||||
|
library auth_types;
|
||||||
|
|
||||||
|
const String local = 'local', token = 'token';
|
439
packages/client/lib/base_angel_client.dart
Normal file
439
packages/client/lib/base_angel_client.dart
Normal file
|
@ -0,0 +1,439 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert' show Encoding;
|
||||||
|
import 'package:angel_http_exception/angel_http_exception.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/src/base_client.dart' as http;
|
||||||
|
import 'package:http/src/base_request.dart' as http;
|
||||||
|
import 'package:http/src/request.dart' as http;
|
||||||
|
import 'package:http/src/response.dart' as http;
|
||||||
|
import 'package:http/src/streamed_response.dart' as http;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'angel_client.dart';
|
||||||
|
|
||||||
|
const Map<String, String> _readHeaders = const {'Accept': 'application/json'};
|
||||||
|
const Map<String, String> _writeHeaders = const {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, String> _buildQuery(Map<String, dynamic> params) {
|
||||||
|
return params?.map((k, v) => new MapEntry(k, v.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _invalid(http.Response response) =>
|
||||||
|
response.statusCode == null ||
|
||||||
|
response.statusCode < 200 ||
|
||||||
|
response.statusCode >= 300;
|
||||||
|
|
||||||
|
AngelHttpException failure(http.Response response,
|
||||||
|
{error, String message, StackTrace stack}) {
|
||||||
|
try {
|
||||||
|
var v = json.decode(response.body);
|
||||||
|
|
||||||
|
if (v is Map && (v['is_error'] == true) || v['isError'] == true) {
|
||||||
|
return new AngelHttpException.fromMap(v as Map);
|
||||||
|
} else {
|
||||||
|
return new AngelHttpException(error,
|
||||||
|
message: message ??
|
||||||
|
'Unhandled exception while connecting to Angel backend.',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
stackTrace: stack);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
return new AngelHttpException(error ?? e,
|
||||||
|
message: message ??
|
||||||
|
'Angel backend did not return JSON - an error likely occurred.',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
stackTrace: stack ?? st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BaseAngelClient extends Angel {
|
||||||
|
final StreamController<AngelAuthResult> _onAuthenticated =
|
||||||
|
new StreamController<AngelAuthResult>();
|
||||||
|
final List<Service> _services = [];
|
||||||
|
final http.BaseClient client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<AngelAuthResult> get onAuthenticated => _onAuthenticated.stream;
|
||||||
|
|
||||||
|
BaseAngelClient(this.client, baseUrl) : super(baseUrl);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AngelAuthResult> authenticate(
|
||||||
|
{String type,
|
||||||
|
credentials,
|
||||||
|
String authEndpoint = '/auth',
|
||||||
|
@deprecated String reviveEndpoint = '/auth/token'}) async {
|
||||||
|
type ??= 'token';
|
||||||
|
|
||||||
|
var segments = baseUrl.pathSegments
|
||||||
|
.followedBy(p.split(authEndpoint))
|
||||||
|
.followedBy([type]);
|
||||||
|
var url = baseUrl.replace(path: p.joinAll(segments));
|
||||||
|
http.Response response;
|
||||||
|
|
||||||
|
if (credentials != null) {
|
||||||
|
response = await post(url,
|
||||||
|
body: json.encode(credentials), headers: _writeHeaders);
|
||||||
|
} else {
|
||||||
|
response = await post(url, headers: _writeHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_invalid(response)) {
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var v = json.decode(response.body);
|
||||||
|
|
||||||
|
if (v is! Map ||
|
||||||
|
!(v as Map).containsKey('data') ||
|
||||||
|
!(v as Map).containsKey('token')) {
|
||||||
|
throw new AngelHttpException.notAuthenticated(
|
||||||
|
message: "Auth endpoint '$url' did not return a proper response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = new AngelAuthResult.fromMap(v as Map);
|
||||||
|
_onAuthenticated.add(r);
|
||||||
|
return r;
|
||||||
|
} on AngelHttpException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e, st) {
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
client.close();
|
||||||
|
await _onAuthenticated.close();
|
||||||
|
await Future.wait(_services.map((s) => s.close())).then((_) {
|
||||||
|
_services.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
authToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||||
|
if (authToken?.isNotEmpty == true)
|
||||||
|
request.headers['authorization'] ??= 'Bearer $authToken';
|
||||||
|
return client.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a non-streaming [Request] and returns a non-streaming [Response].
|
||||||
|
Future<http.Response> sendUnstreamed(
|
||||||
|
String method, url, Map<String, String> headers,
|
||||||
|
[body, Encoding encoding]) async {
|
||||||
|
var request =
|
||||||
|
new http.Request(method, url is Uri ? url : Uri.parse(url.toString()));
|
||||||
|
|
||||||
|
if (headers != null) request.headers.addAll(headers);
|
||||||
|
|
||||||
|
if (encoding != null) request.encoding = encoding;
|
||||||
|
if (body != null) {
|
||||||
|
if (body is String) {
|
||||||
|
request.body = body;
|
||||||
|
} else if (body is List<int>) {
|
||||||
|
request.bodyBytes = new List<int>.from(body);
|
||||||
|
} else if (body is Map<String, dynamic>) {
|
||||||
|
request.bodyFields =
|
||||||
|
body.map((k, v) => MapEntry(k, v is String ? v : v.toString()));
|
||||||
|
} else {
|
||||||
|
throw new ArgumentError.value(body, 'body',
|
||||||
|
'must be a String, List<int>, or Map<String, String>.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.Response.fromStream(await send(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type type, AngelDeserializer<Data> deserializer}) {
|
||||||
|
var url = baseUrl.replace(path: p.join(baseUrl.path, path));
|
||||||
|
var s = new BaseAngelService<Id, Data>(client, this, url,
|
||||||
|
deserializer: deserializer);
|
||||||
|
_services.add(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _join(url) {
|
||||||
|
var u = url is Uri ? url : Uri.parse(url.toString());
|
||||||
|
if (u.hasScheme || u.hasAuthority) return u;
|
||||||
|
return u.replace(path: p.join(baseUrl.path, u.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> delete(url, {Map<String, String> headers}) async {
|
||||||
|
return sendUnstreamed('DELETE', _join(url), headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> get(url, {Map<String, String> headers}) async {
|
||||||
|
return sendUnstreamed('GET', _join(url), headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> head(url, {Map<String, String> headers}) async {
|
||||||
|
return sendUnstreamed('HEAD', _join(url), headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> patch(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding}) async {
|
||||||
|
return sendUnstreamed('PATCH', _join(url), headers, body, encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> post(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding}) async {
|
||||||
|
return sendUnstreamed('POST', _join(url), headers, body, encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> put(url,
|
||||||
|
{body, Map<String, String> headers, Encoding encoding}) async {
|
||||||
|
return sendUnstreamed('PUT', _join(url), headers, body, encoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseAngelService<Id, Data> extends Service<Id, Data> {
|
||||||
|
@override
|
||||||
|
final BaseAngelClient app;
|
||||||
|
final Uri baseUrl;
|
||||||
|
final http.BaseClient client;
|
||||||
|
final AngelDeserializer<Data> deserializer;
|
||||||
|
|
||||||
|
final StreamController<List<Data>> _onIndexed = new StreamController();
|
||||||
|
final StreamController<Data> _onRead = new StreamController(),
|
||||||
|
_onCreated = new StreamController(),
|
||||||
|
_onModified = new StreamController(),
|
||||||
|
_onUpdated = new StreamController(),
|
||||||
|
_onRemoved = new StreamController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<Data>> get onIndexed => _onIndexed.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Data> get onRead => _onRead.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Data> get onCreated => _onCreated.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Data> get onModified => _onModified.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Data> get onUpdated => _onUpdated.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Data> get onRemoved => _onRemoved.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() async {
|
||||||
|
await _onIndexed.close();
|
||||||
|
await _onRead.close();
|
||||||
|
await _onCreated.close();
|
||||||
|
await _onModified.close();
|
||||||
|
await _onUpdated.close();
|
||||||
|
await _onRemoved.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseAngelService(this.client, this.app, baseUrl, {this.deserializer})
|
||||||
|
: this.baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString());
|
||||||
|
|
||||||
|
/// Use [baseUrl] instead.
|
||||||
|
@deprecated
|
||||||
|
String get basePath => baseUrl.toString();
|
||||||
|
|
||||||
|
Data deserialize(x) {
|
||||||
|
return deserializer != null ? deserializer(x) : x as Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
makeBody(x) {
|
||||||
|
return json.encode(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||||
|
if (app.authToken != null && app.authToken.isNotEmpty) {
|
||||||
|
request.headers['Authorization'] = 'Bearer ${app.authToken}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Data>> index([Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(queryParameters: _buildQuery(params));
|
||||||
|
var response = await app.sendUnstreamed('GET', url, _readHeaders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onIndexed.hasListener)
|
||||||
|
_onIndexed.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var v = json.decode(response.body) as List;
|
||||||
|
var r = v.map(deserialize).toList();
|
||||||
|
_onIndexed.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onIndexed.hasListener)
|
||||||
|
_onIndexed.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data> read(id, [Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(
|
||||||
|
path: p.join(baseUrl.path, id.toString()),
|
||||||
|
queryParameters: _buildQuery(params));
|
||||||
|
|
||||||
|
var response = await app.sendUnstreamed('GET', url, _readHeaders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onRead.hasListener)
|
||||||
|
_onRead.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = deserialize(json.decode(response.body));
|
||||||
|
_onRead.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onRead.hasListener)
|
||||||
|
_onRead.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data> create(data, [Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(queryParameters: _buildQuery(params));
|
||||||
|
var response =
|
||||||
|
await app.sendUnstreamed('POST', url, _writeHeaders, makeBody(data));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onCreated.hasListener)
|
||||||
|
_onCreated.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = deserialize(json.decode(response.body));
|
||||||
|
_onCreated.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onCreated.hasListener)
|
||||||
|
_onCreated.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data> modify(id, data, [Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(
|
||||||
|
path: p.join(baseUrl.path, id.toString()),
|
||||||
|
queryParameters: _buildQuery(params));
|
||||||
|
|
||||||
|
var response =
|
||||||
|
await app.sendUnstreamed('PATCH', url, _writeHeaders, makeBody(data));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onModified.hasListener)
|
||||||
|
_onModified.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = deserialize(json.decode(response.body));
|
||||||
|
_onModified.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onModified.hasListener)
|
||||||
|
_onModified.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data> update(id, data, [Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(
|
||||||
|
path: p.join(baseUrl.path, id.toString()),
|
||||||
|
queryParameters: _buildQuery(params));
|
||||||
|
|
||||||
|
var response =
|
||||||
|
await app.sendUnstreamed('POST', url, _writeHeaders, makeBody(data));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onUpdated.hasListener)
|
||||||
|
_onUpdated.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = deserialize(json.decode(response.body));
|
||||||
|
_onUpdated.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onUpdated.hasListener)
|
||||||
|
_onUpdated.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data> remove(id, [Map<String, dynamic> params]) async {
|
||||||
|
var url = baseUrl.replace(
|
||||||
|
path: p.join(baseUrl.path, id.toString()),
|
||||||
|
queryParameters: _buildQuery(params));
|
||||||
|
|
||||||
|
var response = await app.sendUnstreamed('DELETE', url, _readHeaders);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_invalid(response)) {
|
||||||
|
if (_onRemoved.hasListener)
|
||||||
|
_onRemoved.addError(failure(response));
|
||||||
|
else
|
||||||
|
throw failure(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = deserialize(json.decode(response.body));
|
||||||
|
_onRemoved.add(r);
|
||||||
|
return r;
|
||||||
|
} catch (e, st) {
|
||||||
|
if (_onRemoved.hasListener)
|
||||||
|
_onRemoved.addError(e, st);
|
||||||
|
else
|
||||||
|
throw failure(response, error: e, stack: st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
80
packages/client/lib/browser.dart
Normal file
80
packages/client/lib/browser.dart
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
/// Browser client library for the Angel framework.
|
||||||
|
library angel_client.browser;
|
||||||
|
|
||||||
|
import 'dart:async'
|
||||||
|
show Future, Stream, StreamController, StreamSubscription, Timer;
|
||||||
|
import 'dart:html' show CustomEvent, Event, window;
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/browser_client.dart' as http;
|
||||||
|
import 'angel_client.dart';
|
||||||
|
// import 'auth_types.dart' as auth_types;
|
||||||
|
import 'base_angel_client.dart';
|
||||||
|
export 'angel_client.dart';
|
||||||
|
|
||||||
|
/// Queries an Angel server via REST.
|
||||||
|
class Rest extends BaseAngelClient {
|
||||||
|
Rest(String basePath) : super(new http.BrowserClient(), basePath);
|
||||||
|
|
||||||
|
Future<AngelAuthResult> authenticate(
|
||||||
|
{String type,
|
||||||
|
credentials,
|
||||||
|
String authEndpoint = '/auth',
|
||||||
|
@deprecated String reviveEndpoint = '/auth/token'}) async {
|
||||||
|
if (type == null || type == 'token') {
|
||||||
|
if (!window.localStorage.containsKey('token')) {
|
||||||
|
throw new Exception(
|
||||||
|
'Cannot revive token from localStorage - there is none.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = json.decode(window.localStorage['token']);
|
||||||
|
credentials ??= {'token': token};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await super.authenticate(
|
||||||
|
type: type, credentials: credentials, authEndpoint: authEndpoint);
|
||||||
|
window.localStorage['token'] = json.encode(authToken = result.token);
|
||||||
|
window.localStorage['user'] = json.encode(result.data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token', String errorMessage}) {
|
||||||
|
var ctrl = new StreamController<String>();
|
||||||
|
var wnd = window.open(url, 'angel_client_auth_popup');
|
||||||
|
|
||||||
|
Timer t;
|
||||||
|
StreamSubscription sub;
|
||||||
|
t = new Timer.periodic(new Duration(milliseconds: 500), (timer) {
|
||||||
|
if (!ctrl.isClosed) {
|
||||||
|
if (wnd.closed) {
|
||||||
|
ctrl.addError(new AngelHttpException.notAuthenticated(
|
||||||
|
message:
|
||||||
|
errorMessage ?? 'Authentication via popup window failed.'));
|
||||||
|
ctrl.close();
|
||||||
|
timer.cancel();
|
||||||
|
sub?.cancel();
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
timer.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
sub = window.on[eventName ?? 'token'].listen((Event ev) {
|
||||||
|
var e = ev as CustomEvent;
|
||||||
|
if (!ctrl.isClosed) {
|
||||||
|
ctrl.add(e.detail.toString());
|
||||||
|
t.cancel();
|
||||||
|
ctrl.close();
|
||||||
|
sub.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctrl.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future logout() {
|
||||||
|
window.localStorage.remove('token');
|
||||||
|
return super.logout();
|
||||||
|
}
|
||||||
|
}
|
19
packages/client/lib/flutter.dart
Normal file
19
packages/client/lib/flutter.dart
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
/// Flutter-compatible client library for the Angel framework.
|
||||||
|
library angel_client.flutter;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'base_angel_client.dart';
|
||||||
|
export 'angel_client.dart';
|
||||||
|
|
||||||
|
/// Queries an Angel server via REST.
|
||||||
|
class Rest extends BaseAngelClient {
|
||||||
|
Rest(String basePath) : super(new http.Client() as http.BaseClient, basePath);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token'}) {
|
||||||
|
throw new UnimplementedError(
|
||||||
|
'Opening popup windows is not supported in the `flutter` client.');
|
||||||
|
}
|
||||||
|
}
|
68
packages/client/lib/io.dart
Normal file
68
packages/client/lib/io.dart
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
/// Command-line client library for the Angel framework.
|
||||||
|
library angel_client.cli;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:json_god/json_god.dart' as god;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'angel_client.dart';
|
||||||
|
import 'base_angel_client.dart';
|
||||||
|
export 'angel_client.dart';
|
||||||
|
|
||||||
|
/// Queries an Angel server via REST.
|
||||||
|
class Rest extends BaseAngelClient {
|
||||||
|
final List<Service> _services = [];
|
||||||
|
|
||||||
|
Rest(String path) : super(new http.Client() as http.BaseClient, path);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type type, AngelDeserializer deserializer}) {
|
||||||
|
var url = baseUrl.replace(path: p.join(baseUrl.path, path));
|
||||||
|
var s = new RestService<Id, Data>(client, this, url, type);
|
||||||
|
_services.add(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token'}) {
|
||||||
|
throw new UnimplementedError(
|
||||||
|
'Opening popup windows is not supported in the `dart:io` client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future close() async {
|
||||||
|
await super.close();
|
||||||
|
await Future.wait(_services.map((s) => s.close())).then((_) {
|
||||||
|
_services.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries an Angel service via REST.
|
||||||
|
class RestService<Id, Data> extends BaseAngelService<Id, Data> {
|
||||||
|
final Type type;
|
||||||
|
|
||||||
|
RestService(http.BaseClient client, BaseAngelClient app, url, this.type)
|
||||||
|
: super(client, app, url);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Data deserialize(x) {
|
||||||
|
if (type != null) {
|
||||||
|
return x.runtimeType == type
|
||||||
|
? x as Data
|
||||||
|
: god.deserializeDatum(x, outputType: type) as Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return x as Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
makeBody(x) {
|
||||||
|
if (type != null) {
|
||||||
|
return super.makeBody(god.serializeObject(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.makeBody(x);
|
||||||
|
}
|
||||||
|
}
|
35
packages/client/package.json
Normal file
35
packages/client/package.json
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "angel_client",
|
||||||
|
"version": "1.0.0-dev",
|
||||||
|
"description": "Client library for the Angel framework.",
|
||||||
|
"main": "build/angel_client.js",
|
||||||
|
"jsnext:main": "lib/angel_client.js",
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"compile": "npm run dartdevc && babel -o build/angel_client.js lib/angel_client.js",
|
||||||
|
"dartdevc": "dartdevc --modules node -o lib/angel_client.js lib/angel_client.dart",
|
||||||
|
"prepublish": "npm run compile",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/angel-dart/angel_client.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"angel",
|
||||||
|
"angel_client"
|
||||||
|
],
|
||||||
|
"author": "Tobe O <thosakwe@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/angel-dart/angel_client/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/angel-dart/angel_client#readme",
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-cli": "^6.18.0",
|
||||||
|
"babel-plugin-add-module-exports": "^0.2.1",
|
||||||
|
"babel-preset-es2015": "^6.18.0"
|
||||||
|
}
|
||||||
|
}
|
23
packages/client/pubspec.yaml
Normal file
23
packages/client/pubspec.yaml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
name: angel_client
|
||||||
|
version: 2.0.2
|
||||||
|
description: Support for querying Angel servers in the browser, Flutter, and command-line.
|
||||||
|
author: Tobe O <thosakwe@gmail.com>
|
||||||
|
homepage: https://github.com/angel-dart/angel_client
|
||||||
|
environment:
|
||||||
|
sdk: ">=2.0.0-dev <3.0.0"
|
||||||
|
dependencies:
|
||||||
|
angel_http_exception: ^1.0.0
|
||||||
|
collection: ^1.0.0
|
||||||
|
http: ^0.12.0
|
||||||
|
json_god: ">=2.0.0-beta <3.0.0"
|
||||||
|
meta: ^1.0.0
|
||||||
|
path: ^1.0.0
|
||||||
|
dev_dependencies:
|
||||||
|
angel_framework: ^2.0.0-alpha
|
||||||
|
angel_model: ^1.0.0
|
||||||
|
async: ^2.0.0
|
||||||
|
build_runner: ^1.0.0
|
||||||
|
build_web_compilers: ^1.0.0
|
||||||
|
mock_request: ^1.0.0
|
||||||
|
pedantic: ^1.0.0
|
||||||
|
test: ^1.0.0
|
BIN
packages/client/test/.DS_Store
vendored
Normal file
BIN
packages/client/test/.DS_Store
vendored
Normal file
Binary file not shown.
85
packages/client/test/all_test.dart
Normal file
85
packages/client/test/all_test.dart
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:angel_client/angel_client.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
var app = new MockAngel();
|
||||||
|
Service todoService = app.service('api/todos');
|
||||||
|
|
||||||
|
test('sets method,body,headers,path', () async {
|
||||||
|
await app.post('/post', headers: {'method': 'post'}, body: 'post');
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.path, '/post');
|
||||||
|
expect(app.client.spec.headers['method'], 'post');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), 'post');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('service methods', () {
|
||||||
|
test('index', () async {
|
||||||
|
await todoService.index();
|
||||||
|
expect(app.client.spec.method, 'GET');
|
||||||
|
expect(app.client.spec.path, '/api/todos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read', () async {
|
||||||
|
await todoService.read('sleep');
|
||||||
|
expect(app.client.spec.method, 'GET');
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create', () async {
|
||||||
|
await todoService.create({});
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modify', () async {
|
||||||
|
await todoService.modify('sleep', {});
|
||||||
|
expect(app.client.spec.method, 'PATCH');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update', () async {
|
||||||
|
await todoService.update('sleep', {});
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove', () async {
|
||||||
|
await todoService.remove('sleep');
|
||||||
|
expect(app.client.spec.method, 'DELETE');
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('authentication', () {
|
||||||
|
test('no type defaults to token', () async {
|
||||||
|
await app.authenticate(credentials: '<jwt>');
|
||||||
|
expect(app.client.spec.path, '/auth/token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets type', () async {
|
||||||
|
await app.authenticate(type: 'local');
|
||||||
|
expect(app.client.spec.path, '/auth/local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credentials send right body', () async {
|
||||||
|
await app
|
||||||
|
.authenticate(type: 'local', credentials: {'username': 'password'});
|
||||||
|
expect(
|
||||||
|
await read(app.client.spec.request.finalize()),
|
||||||
|
json.encode({'username': 'password'}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
70
packages/client/test/common.dart
Normal file
70
packages/client/test/common.dart
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:angel_client/base_angel_client.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/src/base_client.dart' as http;
|
||||||
|
import 'package:http/src/base_request.dart' as http;
|
||||||
|
import 'package:http/src/streamed_response.dart' as http;
|
||||||
|
|
||||||
|
Future<String> read(Stream<List<int>> stream) =>
|
||||||
|
stream.transform(utf8.decoder).join();
|
||||||
|
|
||||||
|
class MockAngel extends BaseAngelClient {
|
||||||
|
@override
|
||||||
|
final SpecClient client = new SpecClient();
|
||||||
|
|
||||||
|
MockAngel() : super(null, 'http://localhost:3000');
|
||||||
|
|
||||||
|
@override
|
||||||
|
authenticateViaPopup(String url, {String eventName = 'token'}) {
|
||||||
|
throw new UnsupportedError('Nope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecClient extends http.BaseClient {
|
||||||
|
Spec _spec;
|
||||||
|
|
||||||
|
Spec get spec => _spec;
|
||||||
|
|
||||||
|
@override
|
||||||
|
send(http.BaseRequest request) {
|
||||||
|
_spec = new Spec(request, request.method, request.url.path, request.headers,
|
||||||
|
request.contentLength);
|
||||||
|
dynamic data = {'text': 'Clean your room!', 'completed': true};
|
||||||
|
|
||||||
|
if (request.url.path.contains('auth')) {
|
||||||
|
data = {
|
||||||
|
'token': '<jwt>',
|
||||||
|
'data': {'username': 'password'}
|
||||||
|
};
|
||||||
|
} else if (request.url.path == '/api/todos' && request.method == 'GET') {
|
||||||
|
data = [data];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Future<http.StreamedResponse>.value(new http.StreamedResponse(
|
||||||
|
new Stream<List<int>>.fromIterable([utf8.encode(json.encode(data))]),
|
||||||
|
200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Spec {
|
||||||
|
final http.BaseRequest request;
|
||||||
|
final String method, path;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final int contentLength;
|
||||||
|
|
||||||
|
Spec(this.request, this.method, this.path, this.headers, this.contentLength);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return {
|
||||||
|
'method': method,
|
||||||
|
'path': path,
|
||||||
|
'headers': headers,
|
||||||
|
'content_length': contentLength,
|
||||||
|
}.toString();
|
||||||
|
}
|
||||||
|
}
|
10
packages/client/test/index.html
Normal file
10
packages/client/test/index.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Browser</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="application/dart" src="browser.dart"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
61
packages/client/test/list_test.dart
Normal file
61
packages/client/test/list_test.dart
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:async/async.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:angel_client/io.dart' as c;
|
||||||
|
import 'package:angel_framework/angel_framework.dart' as s;
|
||||||
|
import 'package:angel_framework/http.dart' as s;
|
||||||
|
import 'package:pedantic/pedantic.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
HttpServer server;
|
||||||
|
c.Angel app;
|
||||||
|
c.ServiceList list;
|
||||||
|
StreamQueue queue;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
var serverApp = new s.Angel();
|
||||||
|
var http = new s.AngelHttp(serverApp);
|
||||||
|
serverApp.use('/api/todos', new s.MapService(autoIdAndDateFields: false));
|
||||||
|
|
||||||
|
server = await http.startServer();
|
||||||
|
var uri = 'http://${server.address.address}:${server.port}';
|
||||||
|
app = new c.Rest(uri);
|
||||||
|
list = new c.ServiceList(app.service('api/todos'));
|
||||||
|
queue = new StreamQueue(list.onChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await server.close(force: true);
|
||||||
|
unawaited(list.close());
|
||||||
|
unawaited(list.service.close());
|
||||||
|
unawaited(app.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listens on create', () async {
|
||||||
|
unawaited(list.service.create({'foo': 'bar'}));
|
||||||
|
await list.onChange.first;
|
||||||
|
expect(list, [
|
||||||
|
{'foo': 'bar'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listens on modify', () async {
|
||||||
|
unawaited(list.service.create({'id': 1, 'foo': 'bar'}));
|
||||||
|
await queue.next;
|
||||||
|
|
||||||
|
await list.service.update(1, {'id': 1, 'bar': 'baz'});
|
||||||
|
await queue.next;
|
||||||
|
expect(list, [
|
||||||
|
{'id': 1, 'bar': 'baz'}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listens on remove', () async {
|
||||||
|
unawaited(list.service.create({'id': '1', 'foo': 'bar'}));
|
||||||
|
await queue.next;
|
||||||
|
|
||||||
|
await list.service.remove('1');
|
||||||
|
await queue.next;
|
||||||
|
expect(list, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
28
packages/client/test/shared.dart
Normal file
28
packages/client/test/shared.dart
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:angel_model/angel_model.dart';
|
||||||
|
|
||||||
|
class Postcard extends Model {
|
||||||
|
String location;
|
||||||
|
String message;
|
||||||
|
|
||||||
|
Postcard({String id, this.location, this.message}) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Postcard.fromJson(Map data) => new Postcard(
|
||||||
|
id: data['id'].toString(),
|
||||||
|
location: data['location'].toString(),
|
||||||
|
message: data['message'].toString());
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(other) {
|
||||||
|
if (!(other is Postcard)) return false;
|
||||||
|
|
||||||
|
return id == other.id &&
|
||||||
|
location == other.location &&
|
||||||
|
message == other.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map toJson() {
|
||||||
|
return {'id': id, 'location': location, 'message': message};
|
||||||
|
}
|
||||||
|
}
|
9
packages/client/web/index.html
Normal file
9
packages/client/web/index.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="main.dart.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
packages/client/web/main.dart
Normal file
8
packages/client/web/main.dart
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import 'dart:html';
|
||||||
|
import 'package:angel_client/browser.dart';
|
||||||
|
|
||||||
|
/// Dummy app to ensure client works with DDC.
|
||||||
|
main() {
|
||||||
|
var app = new Rest(window.location.origin);
|
||||||
|
window.alert(app.baseUrl.toString());
|
||||||
|
}
|
Loading…
Reference in a new issue