diff --git a/packages/client/.babelrc b/packages/client/.babelrc new file mode 100644 index 0000000..c56a9bf --- /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 0000000..9878442 --- /dev/null +++ b/packages/client/.gitignore @@ -0,0 +1,72 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.dart_tool +.packages +.pub/ +build/ + +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +### Dart template +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub + +# SDK 1.20 and later (no longer creates packages directories) + +# Older SDK versions +# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20) +.project +.buildlog +**/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 + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: + +## VsCode +.vscode/ + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +.idea/ +/out/ +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.DS_Store \ No newline at end of file diff --git a/packages/client/.npmignore b/packages/client/.npmignore new file mode 100644 index 0000000..754250b --- /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/.pubignore b/packages/client/.pubignore new file mode 100644 index 0000000..496ee2c --- /dev/null +++ b/packages/client/.pubignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/packages/client/AUTHORS.md b/packages/client/AUTHORS.md new file mode 100644 index 0000000..ac95ab5 --- /dev/null +++ b/packages/client/AUTHORS.md @@ -0,0 +1,12 @@ +Primary Authors +=============== + +* __[Thomas Hii](dukefirehawk.apps@gmail.com)__ + + Thomas is the current maintainer of the code base. He has refactored and migrated the + code base to support NNBD. + +* __[Tobe O](thosakwe@gmail.com)__ + + Tobe has written much of the original code prior to NNBD migration. He has moved on and + is no longer involved with the project. diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 0000000..11a7b47 --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,122 @@ +# Change Log + +## 8.2.1 + +* Updated error handling + +## 8.2.0 + +* Require Dart >= 3.3 +* Updated `lints` to 4.0.0 + +## 8.1.1 + +* Updated repository link + +## 8.1.0 + +* Updated `lints` to 3.0.0 +* Fixed linter warnings + +## 8.0.0 + +* Require Dart >= 3.0 +* Updated `http` to 1.0.0 + +## 7.0.0 + +* Require Dart >= 2.17 + +## 6.0.0 + +* Require Dart >= 2.16 + +## 5.0.0 + +* Skipped release + +## 4.2.0 + +* Updated `package:build_runner` +* Updated `package:build_web_compilers` + +## 4.1.0 + +* Updated `package:belatuk_json_serializer` +* Updated linter to `package:lints` + +## 4.0.2 + +* Added logging +* Added unit test for authentication + +## 4.0.1 + +* Updated README +* Refactored NNBD fixes +* Fixed path issue on Windows +* All 13 unit tests passed + +## 4.0.0 + +* Migrated to support Dart >= 2.12 NNBD + +## 3.0.0 + +* Migrated to work with Dart >= 2.12 Non NNBD + +## 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`. diff --git a/packages/client/LICENSE b/packages/client/LICENSE new file mode 100644 index 0000000..df5e063 --- /dev/null +++ b/packages/client/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, dukefirehawk.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..fdf8bdf --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,108 @@ +# Angel3 Client + +![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_client?include_prereleases) +[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety) +[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM) +[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/client/LICENSE) + +A browser, mobile and command line based client that supports querying Angel3 backend. + +## Usage + +```dart +// Choose one or the other, depending on platform +import 'package:platform_client/io.dart'; +import 'package:platform_client/browser.dart'; +import 'package:platform_client/flutter.dart'; + +main() async { + Angel app = 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 `package:belatuk_json_serializer`. 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 = ServiceList(app.service('api/todos')); + + return StreamBuilder( + stream: list.onChange, + builder: _yourBuildFunction, + ); +} +``` diff --git a/packages/client/analysis_options.yaml b/packages/client/analysis_options.yaml new file mode 100644 index 0000000..ea2c9e9 --- /dev/null +++ b/packages/client/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/client/build.yaml b/packages/client/build.yaml new file mode 100644 index 0000000..4493251 --- /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 0000000..c81b6f1 --- /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/client/example_client.dart b/packages/client/example/client/example_client.dart new file mode 100644 index 0000000..592fea8 --- /dev/null +++ b/packages/client/example/client/example_client.dart @@ -0,0 +1,11 @@ +import 'package:platform_client/io.dart' as c; + +void main() async { + c.Angel client = c.Rest('http://localhost:3000'); + + const Map user = {'username': 'foo', 'password': 'bar'}; + + var localAuth = await client.authenticate(type: 'local', credentials: user); + print('JWT: ${localAuth.token}'); + print('Data: ${localAuth.data}'); +} diff --git a/packages/client/example/example1.dart b/packages/client/example/example1.dart new file mode 100644 index 0000000..2e1bdcb --- /dev/null +++ b/packages/client/example/example1.dart @@ -0,0 +1,36 @@ +import 'package:platform_foundation/core.dart'; +import 'package:platform_auth/auth.dart'; + +import 'package:platform_foundation/http.dart'; +import 'package:logging/logging.dart'; + +void main() async { + const Map user = {'username': 'foo', 'password': 'bar'}; + var localOpts = + AngelAuthOptions>(canRespondWithJson: true); + + Application app = Application(); + PlatformHttp http = PlatformHttp(app, useZone: false); + var auth = PlatformAuth( + serializer: (_) async => 'baz', deserializer: (_) async => user); + + auth.strategies['local'] = LocalAuthStrategy((username, password) async { + if (username == 'foo' && password == 'bar') { + return user; + } + + return {}; + }, allowBasic: false); + + app.post('/auth/local', auth.authenticate('local', localOpts)); + + await app.configure(auth.configureServer); + + app.logger = Logger('auth_test') + ..onRecord.listen((rec) { + print( + '${rec.time}: ${rec.level.name}: ${rec.loggerName}: ${rec.message}'); + }); + + await http.startServer('127.0.0.1', 3000); +} diff --git a/packages/client/example/main.dart b/packages/client/example/main.dart new file mode 100644 index 0000000..862456a --- /dev/null +++ b/packages/client/example/main.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'package:platform_client/platform_client.dart'; + +Future doSomething(Angel app) async { + var userService = app + .service>('api/users') + .map(User.fromMap, User.toMap); + + var users = await (userService.index() as FutureOr>); + 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/auth_types.dart b/packages/client/lib/auth_types.dart new file mode 100644 index 0000000..afca9ab --- /dev/null +++ b/packages/client/lib/auth_types.dart @@ -0,0 +1 @@ +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 0000000..7895285 --- /dev/null +++ b/packages/client/lib/base_angel_client.dart @@ -0,0 +1,468 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart'; +import 'package:path/path.dart'; +import 'package:logging/logging.dart'; +import 'platform_client.dart'; + +const Map _readHeaders = {'Accept': 'application/json'}; +const Map _writeHeaders = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' +}; + +Map _buildQuery(Map? params) { + return params?.map((k, v) => MapEntry(k, v.toString())) ?? {}; +} + +bool _invalid(Response response) => + response.statusCode < 200 || response.statusCode >= 300; + +PlatformHttpException failure(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 PlatformHttpException.fromMap(v as Map); + } else { + return PlatformHttpException( + message: message ?? + 'Unhandled exception while connecting to Angel backend.', + statusCode: response.statusCode, + stackTrace: stack); + } + } catch (e, st) { + return PlatformHttpException( + message: message ?? + 'Angel backend did not return JSON - an error likely occurred.', + statusCode: response.statusCode, + stackTrace: stack ?? st); + } +} + +abstract class BaseAngelClient extends Angel { + final _log = Logger('BaseAngelClient'); + final StreamController _onAuthenticated = + StreamController(); + final List _services = []; + final BaseClient client; + + final Context _p = Context(style: Style.url); + + @override + Stream get onAuthenticated => _onAuthenticated.stream; + + BaseAngelClient(this.client, baseUrl) : super(baseUrl); + + @override + Future authenticate( + {String? type, credentials, String authEndpoint = '/auth'}) async { + type ??= 'token'; + + var segments = baseUrl.pathSegments + .followedBy(_p.split(authEndpoint)) + .followedBy([type]); + + //var p1 = p.joinAll(segments).replaceAll('\\', '/'); + + var url = baseUrl.replace(path: _p.joinAll(segments)); + 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); + _log.info(response.headers); + + var v = jsonDecode(response.body); + + if (v is! Map || !v.containsKey('data') || !v.containsKey('token')) { + throw PlatformHttpException.notAuthenticated( + message: "Auth endpoint '$url' did not return a proper response."); + } + + var r = AngelAuthResult.fromMap(v); + _onAuthenticated.add(r); + return r; + } on PlatformHttpException { + rethrow; + } catch (e, st) { + _log.severe(st); + throw failure(response, error: e, stack: st); + } + } + + @override + Future close() async { + client.close(); + await _onAuthenticated.close(); + await Future.wait(_services.map((s) => s.close())).then((_) { + _services.clear(); + }); + } + + @override + Future logout() async { + authToken = null; + } + + @override + Future send(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 = 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 = List.from(body); + } else if (body is Map) { + request.bodyFields = + body.map((k, v) => MapEntry(k, v is String ? v : v.toString())); + } else { + _log.severe('Body is not a String, List, or Map'); + throw ArgumentError.value(body, 'body', + 'must be a String, List, or Map.'); + } + } + + return 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 = BaseAngelService(client, this, url, + deserializer: deserializer); + _services.add(s); + return s as Service; + } + + 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 { + final _log = Logger('BaseAngelService'); + + @override + final BaseAngelClient app; + final Uri baseUrl; + final BaseClient client; + final AngelDeserializer? deserializer; + + final Context _p = Context(style: Style.url); + + final StreamController> _onIndexed = StreamController(); + final StreamController _onRead = StreamController(), + _onCreated = StreamController(), + _onModified = StreamController(), + _onUpdated = StreamController(), + _onRemoved = 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}) + : baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString()); + + Data? deserialize(x) { + return deserializer != null ? deserializer!(x) : x as Data?; + } + + String makeBody(x) { + //return json.encode(x); + return jsonEncode(x); + } + + Future send(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(); + var r = []; + for (var element in v) { + var a = deserialize(element); + if (a != null) { + r.add(a); + } + } + _onIndexed.add(r); + return r; + } catch (e, st) { + if (_onIndexed.hasListener) { + _onIndexed.addError(e, st); + } else { + _log.severe(st); + throw failure(response, error: e, stack: st); + } + } + + return []; + } + + @override + Future read(id, [Map? params]) async { + var pa = _p.join(baseUrl.path, id.toString()); + print(pa); + 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 { + _log.severe(st); + 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 { + _log.severe(st); + 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 { + _log.severe(st); + 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 { + _log.severe(st); + 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 { + _log.severe(st); + 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 0000000..5a372be --- /dev/null +++ b/packages/client/lib/browser.dart @@ -0,0 +1,79 @@ +/// 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 'platform_client.dart'; +// import 'auth_types.dart' as auth_types; +import 'base_angel_client.dart'; +export 'platform_client.dart'; + +/// Queries an Angel server via REST. +class Rest extends BaseAngelClient { + Rest(String basePath) : super(http.BrowserClient(), basePath); + + @override + Future authenticate( + {String? type, credentials, String authEndpoint = '/auth'}) async { + if (type == null || type == 'token') { + if (!window.localStorage.containsKey('token')) { + throw 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 = StreamController(); + var wnd = window.open(url, 'angel_client_auth_popup'); + + Timer t; + StreamSubscription? sub; + t = Timer.periodic(Duration(milliseconds: 500), (timer) { + if (!ctrl.isClosed) { + if (wnd.closed!) { + ctrl.addError(PlatformHttpException.notAuthenticated( + message: + errorMessage ?? 'Authentication via popup window failed.')); + ctrl.close(); + timer.cancel(); + sub?.cancel(); + } + } else { + timer.cancel(); + } + }); + + sub = window.on[eventName].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 0000000..c0853a0 --- /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 'platform_client.dart'; + +/// Queries an Angel server via REST. +class Rest extends BaseAngelClient { + Rest(String basePath) : super(http.Client() as http.BaseClient, basePath); + + @override + Stream authenticateViaPopup(String url, + {String eventName = 'token'}) { + throw 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 0000000..816d100 --- /dev/null +++ b/packages/client/lib/io.dart @@ -0,0 +1,74 @@ +/// Command-line client library for the Angel framework. +library angel_client.cli; + +import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:platform_json_serializer/json_serializer.dart' as god; +import 'package:path/path.dart' as p; +import 'package:logging/logging.dart'; +import 'platform_client.dart'; +import 'base_angel_client.dart'; +export 'platform_client.dart'; + +/// Queries an Angel server via REST. +class Rest extends BaseAngelClient { + //final _log = Logger('REST'); + final List _services = []; + + Rest(String path) : super(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 = RestService(client, this, url, type); + _services.add(s); + return s as Service; + } + + @override + Stream authenticateViaPopup(String url, + {String eventName = 'token'}) { + throw UnimplementedError( + 'Opening popup windows is not supported in the `dart:io` client.'); + } + + @override + 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 _log = Logger('RestService'); + + final Type? type; + + RestService(super.client, super.app, super.url, this.type); + + @override + Data? deserialize(x) { + _log.info(x); + if (type != null) { + return x.runtimeType == type + ? x as Data? + : god.deserializeDatum(x, outputType: type) as Data?; + } + + return x as Data?; + } + + @override + String makeBody(x) { + _log.info(x); + if (type != null) { + return super.makeBody(god.serializeObject(x)); + } + + return super.makeBody(x); + } +} diff --git a/packages/client/lib/platform_client.dart b/packages/client/lib/platform_client.dart new file mode 100644 index 0000000..b06012f --- /dev/null +++ b/packages/client/lib/platform_client.dart @@ -0,0 +1,347 @@ +/// Client library for the Angel framework. +library platform_client; + +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +//import 'package:logging/logging.dart'; +export 'package:platform_support/exceptions.dart'; + +/// A function that configures an [Angel] client in some way. +typedef AngelConfigurer = FutureOr Function(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 AngelDeserializer = T? Function(dynamic x); + +/// Represents an Angel server that we are querying. +abstract class Angel extends http.BaseClient { + //final _log = Logger('Angel'); + + /// 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) + : baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(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'}); + + /// 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. + @override + 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, + {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 = 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 FormatException( + 'The required "token" field was not present in the given data.'); + } else if (data!['data'] is! Map) { + throw 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) => + 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 _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() => 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 = StreamController(); + + final List _subs = []; + + ServiceList(this.service, {this.idField = 'id', Equality? equality}) + : super([]) { + _equality = equality; + _equality ??= EqualityBy((map) { + if (map is Map) { + return map[idField] as Id?; + } else { + throw 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 + void handleModified(Data item) { + var indices = []; + + for (var 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/package.json b/packages/client/package.json new file mode 100644 index 0000000..70867be --- /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 0000000..d920a51 --- /dev/null +++ b/packages/client/pubspec.yaml @@ -0,0 +1,42 @@ +name: platform_client +version: 8.2.0 +description: A browser, mobile and command line based client that supports querying Angel3 servers +homepage: https://angel3-framework.web.app/ +repository: https://github.com/dart-backend/angel/tree/master/packages/client +environment: + sdk: '>=3.3.0 <4.0.0' +dependencies: + platform_support: ^8.0.0 + platform_json_serializer: ^7.0.0 + collection: ^1.17.0 + http: ^1.0.0 + meta: ^1.9.0 + path: ^1.8.0 + logging: ^1.1.0 +dev_dependencies: + platform_foundation: ^8.0.0 + platform_model: ^8.0.0 + platform_testing: ^8.0.0 + platform_container: ^8.0.0 + platform_auth: ^8.0.0 + async: ^2.11.0 + quiver: ^3.2.0 + build_runner: ^2.4.0 + build_web_compilers: ^4.0.0 + test: ^1.24.0 + lints: ^4.0.0 +# dependency_overrides: +# angel3_container: +# path: ../container/angel_container +# angel3_framework: +# path: ../framework +# angel3_http_exception: +# path: ../http_exception +# angel3_model: +# path: ../model +# angel3_route: +# path: ../route +# angel3_mock_request: +# path: ../mock_request +# angel3_auth: +# path: ../auth \ No newline at end of file diff --git a/packages/client/test/all_test.dart b/packages/client/test/all_test.dart new file mode 100644 index 0000000..602d631 --- /dev/null +++ b/packages/client/test/all_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'package:test/test.dart'; +import 'common.dart'; + +void main() { + var app = MockAngel(); + var todoService = app.service('api/todos'); + + test('sets method,body,headers,path', () async { + await app.post(Uri.parse('/post'), + headers: {'method': 'post'}, body: 'post'); + expect((app.client as SpecClient).spec!.method, 'POST'); + expect((app.client as SpecClient).spec!.path, '/post'); + expect((app.client as SpecClient).spec!.headers['method'], 'post'); + expect(await read((app.client as SpecClient).spec!.request.finalize()), + 'post'); + }); + + group('service methods', () { + test('index', () async { + await todoService.index(); + expect((app.client as SpecClient).spec!.method, 'GET'); + expect((app.client as SpecClient).spec!.path, '/api/todos'); + }); + + test('read', () async { + await todoService.read('sleep'); + expect((app.client as SpecClient).spec!.method, 'GET'); + expect((app.client as SpecClient).spec!.path, '/api/todos/sleep'); + }); + + test('create', () async { + await todoService.create({}); + expect((app.client as SpecClient).spec!.method, 'POST'); + expect((app.client as SpecClient).spec!.headers['content-type'], + startsWith('application/json')); + expect((app.client as SpecClient).spec!.path, '/api/todos'); + expect(await read((app.client as SpecClient).spec!.request.finalize()), + '{}'); + }); + + test('modify', () async { + await todoService.modify('sleep', {}); + expect((app.client as SpecClient).spec!.method, 'PATCH'); + expect((app.client as SpecClient).spec!.headers['content-type'], + startsWith('application/json')); + expect((app.client as SpecClient).spec!.path, '/api/todos/sleep'); + expect(await read((app.client as SpecClient).spec!.request.finalize()), + '{}'); + }); + + test('update', () async { + await todoService.update('sleep', {}); + expect((app.client as SpecClient).spec!.method, 'POST'); + expect((app.client as SpecClient).spec!.headers['content-type'], + startsWith('application/json')); + expect((app.client as SpecClient).spec!.path, '/api/todos/sleep'); + expect(await read((app.client as SpecClient).spec!.request.finalize()), + '{}'); + }); + + test('remove', () async { + await todoService.remove('sleep'); + expect((app.client as SpecClient).spec!.method, 'DELETE'); + expect((app.client as SpecClient).spec!.path, '/api/todos/sleep'); + }); + }); + + group('authentication', () { + test('no type defaults to token', () async { + await app.authenticate(credentials: ''); + expect((app.client as SpecClient).spec!.path, '/auth/token'); + }); + + test('sets type', () async { + await app.authenticate(type: 'local'); + expect((app.client as SpecClient).spec!.path, '/auth/local'); + }); + + test('credentials send right body', () async { + await app + .authenticate(type: 'local', credentials: {'username': 'password'}); + print((app.client as SpecClient).spec?.headers); + expect( + await read((app.client as SpecClient).spec!.request.finalize()), + json.encode({'username': 'password'}), + ); + }); + }); +} diff --git a/packages/client/test/auth_test.dart b/packages/client/test/auth_test.dart new file mode 100644 index 0000000..4fbe243 --- /dev/null +++ b/packages/client/test/auth_test.dart @@ -0,0 +1,59 @@ +import 'package:platform_auth/auth.dart'; +import 'package:platform_client/io.dart' as c; +import 'package:platform_foundation/core.dart'; +import 'package:platform_foundation/http.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +const Map user = {'username': 'foo', 'password': 'bar'}; +var localOpts = AngelAuthOptions>(canRespondWithJson: true); + +void main() { + late Application app; + late PlatformHttp http; + late c.Angel client; + + setUp(() async { + app = Application(); + http = PlatformHttp(app, useZone: false); + var auth = PlatformAuth( + serializer: (_) async => 'baz', deserializer: (_) async => user); + + auth.strategies['local'] = LocalAuthStrategy( + (username, password) async { + if (username == 'foo' && password == 'bar') { + return user; + } + + return {}; + }, + ); + + app.post('/auth/local', auth.authenticate('local', localOpts)); + + await app.configure(auth.configureServer); + + app.logger = Logger('auth_test') + ..onRecord.listen((rec) { + print( + '${rec.time}: ${rec.level.name}: ${rec.loggerName}: ${rec.message}'); + }); + + var server = await http.startServer(); + + client = c.Rest('http://${server.address.address}:${server.port}'); + }); + + tearDown(() { + http.close(); + client.close(); + }); + + test('auth event fires', () async { + var localAuth = await client.authenticate(type: 'local', credentials: user); + print('JWT: ${localAuth.token}'); + print('Data: ${localAuth.data}'); + + expect(localAuth.data, user); + }); +} diff --git a/packages/client/test/common.dart b/packages/client/test/common.dart new file mode 100644 index 0000000..09cf067 --- /dev/null +++ b/packages/client/test/common.dart @@ -0,0 +1,73 @@ +import 'dart:async'; +import 'package:platform_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 { + final SpecClient specClient = SpecClient(); + + @override + get client => specClient; + + MockAngel() : super(SpecClient(), 'http://localhost:3000'); + + @override + Stream authenticateViaPopup(String url, + {String eventName = 'token'}) { + throw UnsupportedError('Nope'); + } +} + +class SpecClient extends http.BaseClient { + Spec? _spec; + + Spec? get spec => _spec; + + @override + Future send(http.BaseRequest request) { + _spec = 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 Future.value(http.StreamedResponse( + 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 0000000..6d164dc --- /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 0000000..2a13ade --- /dev/null +++ b/packages/client/test/list_test.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:async/async.dart'; +import 'dart:io'; +import 'package:platform_client/io.dart' as c; +import 'package:platform_foundation/core.dart' as s; +import 'package:platform_foundation/http.dart' as s; +import 'package:platform_container/mirrors.dart'; +import 'package:test/test.dart'; + +void main() { + late HttpServer server; + late c.Angel app; + late c.ServiceList list; + late StreamQueue queue; + + setUp(() async { + var serverApp = s.Application(reflector: MirrorsReflector()); + var http = s.PlatformHttp(serverApp); + serverApp.use('/api/todos', s.MapService(autoIdAndDateFields: false)); + server = await http.startServer(); + + var uri = 'http://${server.address.address}:${server.port}'; + app = c.Rest(uri); + list = c.ServiceList(app.service('api/todos')); + queue = 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 0000000..46d2187 --- /dev/null +++ b/packages/client/test/shared.dart @@ -0,0 +1,32 @@ +import 'package:platform_model/model.dart'; +import 'package:quiver/core.dart'; + +class Postcard extends Model { + String? location; + String? message; + + Postcard({String? id, this.location, this.message}) { + this.id = id; + } + + factory Postcard.fromJson(Map data) => 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}; + } + + @override + int get hashCode => hash2(id, location); +} diff --git a/packages/client/web/index.html b/packages/client/web/index.html new file mode 100644 index 0000000..7728ad7 --- /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 0000000..426204e --- /dev/null +++ b/packages/client/web/main.dart @@ -0,0 +1,8 @@ +import 'dart:html'; +import 'package:platform_client/browser.dart'; + +/// Dummy app to ensure client works with DDC. +void main() { + var app = Rest(window.location.origin); + window.alert(app.baseUrl.toString()); +}