diff --git a/packages/client/.babelrc b/packages/client/.babelrc new file mode 100644 index 00000000..c56a9bf9 --- /dev/null +++ b/packages/client/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015"], + "plugins": ["add-module-exports"] +} \ No newline at end of file diff --git a/packages/client/.gitignore b/packages/client/.gitignore new file mode 100644 index 00000000..7752ec67 --- /dev/null +++ b/packages/client/.gitignore @@ -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 \ No newline at end of file diff --git a/packages/client/.npmignore b/packages/client/.npmignore new file mode 100644 index 00000000..754250b1 --- /dev/null +++ b/packages/client/.npmignore @@ -0,0 +1,10 @@ +.babelrc +.istanbul.yml +.travis.yml +.editorconfig +.idea/ +src/ +test/ +!lib/ +.github/ +coverage \ No newline at end of file diff --git a/packages/client/.travis.yml b/packages/client/.travis.yml new file mode 100644 index 00000000..0eb6fac6 --- /dev/null +++ b/packages/client/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 00000000..1d0f2aee --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,43 @@ +# 2.0.2 +* `_join` previously discarded quer parameters, etc. +* Allow any `Map` as body, not just `Map`. + +# 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`. +* Add `Service.map`. + +# 2.0.0-alpha.1 +* Refactor `params` to `Map`. + +# 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`. \ No newline at end of file diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 00000000..eb4ce33e --- /dev/null +++ b/packages/client/LICENSE @@ -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. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 00000000..6800b328 --- /dev/null +++ b/packages/client/README.md @@ -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 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("todos") + Service Todos = app.service("todos", type: Todo); + List 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 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, + ); +} +``` \ No newline at end of file diff --git a/packages/client/analysis_options.yaml b/packages/client/analysis_options.yaml new file mode 100644 index 00000000..64bd1e8f --- /dev/null +++ b/packages/client/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false + # errors: + # unawaited_futures: ignore \ No newline at end of file diff --git a/packages/client/build.yaml b/packages/client/build.yaml new file mode 100644 index 00000000..44932516 --- /dev/null +++ b/packages/client/build.yaml @@ -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 \ No newline at end of file diff --git a/packages/client/dart_test.yaml b/packages/client/dart_test.yaml new file mode 100644 index 00000000..c81b6f17 --- /dev/null +++ b/packages/client/dart_test.yaml @@ -0,0 +1 @@ +# platforms: [vm, content-shell] \ No newline at end of file diff --git a/packages/client/example/main.dart b/packages/client/example/main.dart new file mode 100644 index 00000000..d237c4bb --- /dev/null +++ b/packages/client/example/main.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'package:angel_client/angel_client.dart'; + +Future doSomething(Angel app) async { + var userService = app + .service>('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 toMap(User user) => {'name': user.name}; +} diff --git a/packages/client/lib/angel_client.dart b/packages/client/lib/angel_client.dart new file mode 100644 index 00000000..72b2c223 --- /dev/null +++ b/packages/client/lib/angel_client.dart @@ -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 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(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 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 authenticate( + {@required String type, + credentials, + String authEndpoint = '/auth', + @deprecated String reviveEndpoint = '/auth/token'}); + + /// Shorthand for authenticating via a JWT string. + Future 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 authenticateViaPopup(String url, {String eventName = 'token'}); + + /// Disposes of any outstanding resources. + Future close(); + + /// Applies an [AngelConfigurer] to this instance. + Future configure(AngelConfigurer configurer) async { + await configurer(this); + } + + /// Logs the current user out of the application. + FutureOr 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]. + /// + /// You can pass a custom [deserializer], which is typically necessary in cases where + /// `dart:mirrors` does not exist. + Service service(String path, + {@deprecated Type type, AngelDeserializer deserializer}); + + @override + Future delete(url, {Map headers}); + + @override + Future get(url, {Map headers}); + + @override + Future head(url, {Map headers}); + + @override + Future patch(url, + {body, Map headers, Encoding encoding}); + + @override + Future post(url, + {body, Map headers, Encoding encoding}); + + @override + Future put(url, + {body, Map headers, Encoding encoding}); +} + +/// Represents the result of authentication with an Angel server. +class AngelAuthResult { + String _token; + final Map data = {}; + + /// The JSON Web token that was sent with this response. + String get token => _token; + + AngelAuthResult({String token, Map 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) ?? {}); + + 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 toJson() { + return {'token': token, 'data': data}; + } +} + +/// Queries a service on an Angel server, with the same API. +abstract class Service { + /// Fired on `indexed` events. + Stream> get onIndexed; + + /// Fired on `read` events. + Stream get onRead; + + /// Fired on `created` events. + Stream get onCreated; + + /// Fired on `modified` events. + Stream get onModified; + + /// Fired on `updated` events. + Stream get onUpdated; + + /// Fired on `removed` events. + Stream get onRemoved; + + /// The Angel instance powering this service. + Angel get app; + + Future close(); + + /// Retrieves all resources. + Future> index([Map params]); + + /// Retrieves the desired resource. + Future read(Id id, [Map params]); + + /// Creates a resource. + Future create(Data data, [Map params]); + + /// Modifies a resource. + Future modify(Id id, Data data, [Map params]); + + /// Overwrites a resource. + Future update(Id id, Data data, [Map params]); + + /// Removes the given resource. + Future remove(Id id, [Map 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 map(U Function(Data) encoder, Data Function(U) decoder) { + return new _MappedService(this, encoder, decoder); + } +} + +class _MappedService extends Service { + final Service 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 create(U data, [Map params]) { + return inner.create(decoder(data)).then(encoder); + } + + @override + Future> index([Map params]) { + return inner.index(params).then((l) => l.map(encoder).toList()); + } + + @override + Future modify(Id id, U data, [Map params]) { + return inner.modify(id, decoder(data), params).then(encoder); + } + + @override + Stream get onCreated => inner.onCreated.map(encoder); + + @override + Stream> get onIndexed => + inner.onIndexed.map((l) => l.map(encoder).toList()); + + @override + Stream get onModified => inner.onModified.map(encoder); + + @override + Stream get onRead => inner.onRead.map(encoder); + + @override + Stream get onRemoved => inner.onRemoved.map(encoder); + + @override + Stream get onUpdated => inner.onUpdated.map(encoder); + + @override + Future read(Id id, [Map params]) { + return inner.read(id, params).then(encoder); + } + + @override + Future remove(Id id, [Map params]) { + return inner.remove(id, params).then(encoder); + } + + @override + Future update(Id id, U data, [Map 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 extends DelegatingList { + /// 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 get equality => _equality; + + Equality _equality; + + final Service service; + + final StreamController> _onChange = + new StreamController(); + + final List _subs = []; + + ServiceList(this.service, {this.idField = 'id', Equality equality}) + : super([]) { + _equality = equality; + _equality ??= new EqualityBy((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 = []; + + 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> get onChange => _onChange.stream; + + Future close() async { + await _onChange.close(); + } +} diff --git a/packages/client/lib/auth_types.dart b/packages/client/lib/auth_types.dart new file mode 100644 index 00000000..5b98fe57 --- /dev/null +++ b/packages/client/lib/auth_types.dart @@ -0,0 +1,4 @@ +@deprecated +library auth_types; + +const String local = 'local', token = 'token'; diff --git a/packages/client/lib/base_angel_client.dart b/packages/client/lib/base_angel_client.dart new file mode 100644 index 00000000..7912243b --- /dev/null +++ b/packages/client/lib/base_angel_client.dart @@ -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 _readHeaders = const {'Accept': 'application/json'}; +const Map _writeHeaders = const { + 'Accept': 'application/json', + 'Content-Type': 'application/json' +}; + +Map _buildQuery(Map 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 _onAuthenticated = + new StreamController(); + final List _services = []; + final http.BaseClient client; + + @override + Stream get onAuthenticated => _onAuthenticated.stream; + + BaseAngelClient(this.client, baseUrl) : super(baseUrl); + + @override + Future 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 close() async { + client.close(); + await _onAuthenticated.close(); + await Future.wait(_services.map((s) => s.close())).then((_) { + _services.clear(); + }); + } + + Future logout() async { + authToken = null; + } + + @override + Future 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 sendUnstreamed( + String method, url, Map 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) { + request.bodyBytes = new List.from(body); + } else if (body is Map) { + 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, or Map.'); + } + } + + return http.Response.fromStream(await send(request)); + } + + @override + Service service(String path, + {Type type, AngelDeserializer deserializer}) { + var url = baseUrl.replace(path: p.join(baseUrl.path, path)); + var s = new BaseAngelService(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 delete(url, {Map headers}) async { + return sendUnstreamed('DELETE', _join(url), headers); + } + + @override + Future get(url, {Map headers}) async { + return sendUnstreamed('GET', _join(url), headers); + } + + @override + Future head(url, {Map headers}) async { + return sendUnstreamed('HEAD', _join(url), headers); + } + + @override + Future patch(url, + {body, Map headers, Encoding encoding}) async { + return sendUnstreamed('PATCH', _join(url), headers, body, encoding); + } + + @override + Future post(url, + {body, Map headers, Encoding encoding}) async { + return sendUnstreamed('POST', _join(url), headers, body, encoding); + } + + @override + Future put(url, + {body, Map headers, Encoding encoding}) async { + return sendUnstreamed('PUT', _join(url), headers, body, encoding); + } +} + +class BaseAngelService extends Service { + @override + final BaseAngelClient app; + final Uri baseUrl; + final http.BaseClient client; + final AngelDeserializer deserializer; + + final StreamController> _onIndexed = new StreamController(); + final StreamController _onRead = new StreamController(), + _onCreated = new StreamController(), + _onModified = new StreamController(), + _onUpdated = new StreamController(), + _onRemoved = new StreamController(); + + @override + Stream> get onIndexed => _onIndexed.stream; + + @override + Stream get onRead => _onRead.stream; + + @override + Stream get onCreated => _onCreated.stream; + + @override + Stream get onModified => _onModified.stream; + + @override + Stream get onUpdated => _onUpdated.stream; + + @override + Stream 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 send(http.BaseRequest request) { + if (app.authToken != null && app.authToken.isNotEmpty) { + request.headers['Authorization'] = 'Bearer ${app.authToken}'; + } + + return client.send(request); + } + + @override + Future> index([Map 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 read(id, [Map 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 create(data, [Map 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 modify(id, data, [Map 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 update(id, data, [Map 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 remove(id, [Map 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; + } +} diff --git a/packages/client/lib/browser.dart b/packages/client/lib/browser.dart new file mode 100644 index 00000000..5b25459a --- /dev/null +++ b/packages/client/lib/browser.dart @@ -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 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 authenticateViaPopup(String url, + {String eventName = 'token', String errorMessage}) { + var ctrl = new StreamController(); + 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(); + } +} diff --git a/packages/client/lib/flutter.dart b/packages/client/lib/flutter.dart new file mode 100644 index 00000000..5e908fb5 --- /dev/null +++ b/packages/client/lib/flutter.dart @@ -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 authenticateViaPopup(String url, + {String eventName = 'token'}) { + throw new UnimplementedError( + 'Opening popup windows is not supported in the `flutter` client.'); + } +} diff --git a/packages/client/lib/io.dart b/packages/client/lib/io.dart new file mode 100644 index 00000000..c94a4ee2 --- /dev/null +++ b/packages/client/lib/io.dart @@ -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 _services = []; + + Rest(String path) : super(new http.Client() as http.BaseClient, path); + + @override + Service service(String path, + {Type type, AngelDeserializer deserializer}) { + var url = baseUrl.replace(path: p.join(baseUrl.path, path)); + var s = new RestService(client, this, url, type); + _services.add(s); + return s; + } + + @override + Stream 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 extends BaseAngelService { + 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); + } +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000..70867bee --- /dev/null +++ b/packages/client/package.json @@ -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 ", + "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" + } +} diff --git a/packages/client/pubspec.yaml b/packages/client/pubspec.yaml new file mode 100644 index 00000000..6888dffb --- /dev/null +++ b/packages/client/pubspec.yaml @@ -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 +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 diff --git a/packages/client/test/.DS_Store b/packages/client/test/.DS_Store new file mode 100644 index 00000000..f17ada42 Binary files /dev/null and b/packages/client/test/.DS_Store differ diff --git a/packages/client/test/all_test.dart b/packages/client/test/all_test.dart new file mode 100644 index 00000000..f80821bb --- /dev/null +++ b/packages/client/test/all_test.dart @@ -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: ''); + 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'}), + ); + }); + }); +} diff --git a/packages/client/test/common.dart b/packages/client/test/common.dart new file mode 100644 index 00000000..8d7e2c90 --- /dev/null +++ b/packages/client/test/common.dart @@ -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 read(Stream> 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': '', + 'data': {'username': 'password'} + }; + } else if (request.url.path == '/api/todos' && request.method == 'GET') { + data = [data]; + } + + return new Future.value(new http.StreamedResponse( + new Stream>.fromIterable([utf8.encode(json.encode(data))]), + 200, + headers: { + 'content-type': 'application/json', + }, + )); + } +} + +class Spec { + final http.BaseRequest request; + final String method, path; + final Map 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(); + } +} diff --git a/packages/client/test/index.html b/packages/client/test/index.html new file mode 100644 index 00000000..6d164dc3 --- /dev/null +++ b/packages/client/test/index.html @@ -0,0 +1,10 @@ + + + + + Browser + + + + + \ No newline at end of file diff --git a/packages/client/test/list_test.dart b/packages/client/test/list_test.dart new file mode 100644 index 00000000..ba632a9e --- /dev/null +++ b/packages/client/test/list_test.dart @@ -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); + }); +} diff --git a/packages/client/test/shared.dart b/packages/client/test/shared.dart new file mode 100644 index 00000000..11b6c660 --- /dev/null +++ b/packages/client/test/shared.dart @@ -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}; + } +} diff --git a/packages/client/web/index.html b/packages/client/web/index.html new file mode 100644 index 00000000..7728ad7e --- /dev/null +++ b/packages/client/web/index.html @@ -0,0 +1,9 @@ + + + + Client + + + + + \ No newline at end of file diff --git a/packages/client/web/main.dart b/packages/client/web/main.dart new file mode 100644 index 00000000..9e12ec08 --- /dev/null +++ b/packages/client/web/main.dart @@ -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()); +}