add: adding client package

This commit is contained in:
Patrick Stewart 2024-12-15 10:03:05 -07:00
parent 5c925c481d
commit 4a42ce4bed
30 changed files with 1851 additions and 0 deletions

4
packages/client/.babelrc Normal file
View file

@ -0,0 +1,4 @@
{
"presets": ["es2015"],
"plugins": ["add-module-exports"]
}

72
packages/client/.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,10 @@
.babelrc
.istanbul.yml
.travis.yml
.editorconfig
.idea/
src/
test/
!lib/
.github/
coverage

View file

@ -0,0 +1 @@
.DS_Store

View file

@ -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.

View file

@ -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<String, dynamic>` as body, not just `Map<String, String>`.
## 2.0.1
* Change `BaseAngelClient` constructor to accept `dynamic` instead of `String` for `baseUrl.
## 2.0.0
* Deprecate `basePath` in favor of `baseUrl`.
* `Angel` now extends `http.Client`.
* Deprecate `auth_types`.
## 2.0.0-alpha.2
* Make Service `index` always return `List<Data>`.
* Add `Service.map`.
## 2.0.0-alpha.1
* Refactor `params` to `Map<String, dynamic>`.
## 2.0.0-alpha
* Depend on Dart 2.
* Depend on Angel 2.
* Remove `dart2_constant`.
## 1.2.0+2
* Code cleanup + housekeeping, update to `dart2_constant`, and
ensured build works with `2.0.0-dev.64.1`.
## 1.2.0+1
* Removed a type annotation in `authenticateViaPopup` to prevent breaking with DDC.
## 1.2.0
* `ServiceList` now uses `Equality` from `package:collection` to compare items.
* `Service`s will now add service errors to corresponding streams if there is a listener.
## 1.1.0+3
* `ServiceList` no longer ignores empty `index` events.
## 1.1.0+2
* Updated `ServiceList` to also fire on `index`.
## 1.1.0+1
* Added `ServiceList`.

29
packages/client/LICENSE Normal file
View file

@ -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.

108
packages/client/README.md Normal file
View file

@ -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<Map> 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<Todo>("todos")
Service Todos = app.service("todos", type: Todo);
List<Todo> todos = await Todos.index();
print(todos.length);
}
```
Just like on the server, services support `index`, `read`, `create`, `modify`, `update` and `remove`.
## Authentication
Local auth:
```dart
var auth = await app.authenticate(type: 'local', credentials: {username: ..., password: ...});
print(auth.token);
print(auth.data); // User object
```
Revive an existing jwt:
```dart
Future<AngelAuthResult> reviveJwt(String jwt) {
return app.authenticate(credentials: {'token': jwt});
}
```
Via Popup:
```dart
app.authenticateViaPopup('/auth/google').listen((jwt) {
// Do something with the JWT
});
```
Resume a session from localStorage (browser only):
```dart
// Automatically checks for JSON-encoded 'token' in localStorage,
// and tries to revive it
await app.authenticate();
```
Logout:
```dart
await app.logout();
```
## Live Updates
Oftentimes, you will want to update a collection based on updates from a service. Use `ServiceList` for this case:
```dart
build(BuildContext context) async {
var list = ServiceList(app.service('api/todos'));
return StreamBuilder(
stream: list.onChange,
builder: _yourBuildFunction,
);
}
```

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,14 @@
targets:
$default:
builders:
build_web_compilers|entrypoint:
generate_for:
- web/**.dart
options:
dart2js_args:
- --dump-info
- --fast-startup
- --minify
- --trust-type-annotations
- --trust-primitives
- --no-source-maps

View file

@ -0,0 +1 @@
# platforms: [vm, content-shell]

View file

@ -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<String, String> user = {'username': 'foo', 'password': 'bar'};
var localAuth = await client.authenticate(type: 'local', credentials: user);
print('JWT: ${localAuth.token}');
print('Data: ${localAuth.data}');
}

View file

@ -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<String, String> user = {'username': 'foo', 'password': 'bar'};
var localOpts =
AngelAuthOptions<Map<String, String>>(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);
}

View file

@ -0,0 +1,21 @@
import 'dart:async';
import 'package:platform_client/platform_client.dart';
Future doSomething(Angel app) async {
var userService = app
.service<String, Map<String, dynamic>>('api/users')
.map(User.fromMap, User.toMap);
var users = await (userService.index() as FutureOr<List<User>>);
print('Name: ${users.first.name}');
}
class User {
final String? name;
User({this.name});
static User fromMap(Map data) => User(name: data['name'] as String?);
static Map<String, String?> toMap(User user) => {'name': user.name};
}

View file

@ -0,0 +1 @@
const String local = 'local', token = 'token';

View file

@ -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<String, String> _readHeaders = {'Accept': 'application/json'};
const Map<String, String> _writeHeaders = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
Map<String, String> _buildQuery(Map<String, dynamic>? 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<AngelAuthResult> _onAuthenticated =
StreamController<AngelAuthResult>();
final List<Service> _services = [];
final BaseClient client;
final Context _p = Context(style: Style.url);
@override
Stream<AngelAuthResult> get onAuthenticated => _onAuthenticated.stream;
BaseAngelClient(this.client, baseUrl) : super(baseUrl);
@override
Future<AngelAuthResult> 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<void> close() async {
client.close();
await _onAuthenticated.close();
await Future.wait(_services.map((s) => s.close())).then((_) {
_services.clear();
});
}
@override
Future<void> logout() async {
authToken = null;
}
@override
Future<StreamedResponse> 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<Response> sendUnstreamed(
String method, url, Map<String, String>? 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<int>) {
request.bodyBytes = List<int>.from(body);
} else if (body is Map<String, dynamic>) {
request.bodyFields =
body.map((k, v) => MapEntry(k, v is String ? v : v.toString()));
} else {
_log.severe('Body is not a String, List<int>, or Map<String, String>');
throw ArgumentError.value(body, 'body',
'must be a String, List<int>, or Map<String, String>.');
}
}
return Response.fromStream(await send(request));
}
@override
Service<Id, Data> service<Id, Data>(String path,
{Type? type, AngelDeserializer<Data>? deserializer}) {
var url = baseUrl.replace(path: _p.join(baseUrl.path, path));
var s = BaseAngelService<Id, Data>(client, this, url,
deserializer: deserializer);
_services.add(s);
return s as Service<Id, Data>;
}
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<Response> delete(url, {Map<String, String> headers}) async {
// return sendUnstreamed('DELETE', _join(url), headers);
//}
@override
Future<Response> get(url, {Map<String, String>? headers}) async {
return sendUnstreamed('GET', _join(url), headers);
}
@override
Future<Response> head(url, {Map<String, String>? headers}) async {
return sendUnstreamed('HEAD', _join(url), headers);
}
@override
Future<Response> patch(url,
{body, Map<String, String>? headers, Encoding? encoding}) async {
return sendUnstreamed('PATCH', _join(url), headers, body, encoding);
}
@override
Future<Response> post(url,
{body, Map<String, String>? headers, Encoding? encoding}) async {
return sendUnstreamed('POST', _join(url), headers, body, encoding);
}
@override
Future<Response> put(url,
{body, Map<String, String>? headers, Encoding? encoding}) async {
return sendUnstreamed('PUT', _join(url), headers, body, encoding);
}
}
class BaseAngelService<Id, Data> extends Service<Id, Data?> {
final _log = Logger('BaseAngelService');
@override
final BaseAngelClient app;
final Uri baseUrl;
final BaseClient client;
final AngelDeserializer<Data>? deserializer;
final Context _p = Context(style: Style.url);
final StreamController<List<Data?>> _onIndexed = StreamController();
final StreamController<Data?> _onRead = StreamController(),
_onCreated = StreamController(),
_onModified = StreamController(),
_onUpdated = StreamController(),
_onRemoved = StreamController();
@override
Stream<List<Data?>> get onIndexed => _onIndexed.stream;
@override
Stream<Data?> get onRead => _onRead.stream;
@override
Stream<Data?> get onCreated => _onCreated.stream;
@override
Stream<Data?> get onModified => _onModified.stream;
@override
Stream<Data?> get onUpdated => _onUpdated.stream;
@override
Stream<Data?> get onRemoved => _onRemoved.stream;
@override
Future close() async {
await _onIndexed.close();
await _onRead.close();
await _onCreated.close();
await _onModified.close();
await _onUpdated.close();
await _onRemoved.close();
}
BaseAngelService(this.client, this.app, baseUrl, {this.deserializer})
: 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<StreamedResponse> send(BaseRequest request) {
if (app.authToken != null && app.authToken!.isNotEmpty) {
request.headers['Authorization'] = 'Bearer ${app.authToken}';
}
return client.send(request);
}
@override
Future<List<Data>> index([Map<String, dynamic>? params]) async {
var url = baseUrl.replace(queryParameters: _buildQuery(params));
var response = await app.sendUnstreamed('GET', url, _readHeaders);
try {
if (_invalid(response)) {
if (_onIndexed.hasListener) {
_onIndexed.addError(failure(response));
} else {
throw failure(response);
}
}
var v = json.decode(response.body) as List;
//var r = v.map(deserialize).toList();
var r = <Data>[];
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<Data?> read(id, [Map<String, dynamic>? 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<Data?> create(data, [Map<String, dynamic>? params]) async {
var url = baseUrl.replace(queryParameters: _buildQuery(params));
var response =
await app.sendUnstreamed('POST', url, _writeHeaders, makeBody(data));
try {
if (_invalid(response)) {
if (_onCreated.hasListener) {
_onCreated.addError(failure(response));
} else {
throw failure(response);
}
}
var r = deserialize(json.decode(response.body));
_onCreated.add(r);
return r;
} catch (e, st) {
if (_onCreated.hasListener) {
_onCreated.addError(e, st);
} else {
_log.severe(st);
throw failure(response, error: e, stack: st);
}
}
return null;
}
@override
Future<Data?> modify(id, data, [Map<String, dynamic>? params]) async {
var url = baseUrl.replace(
path: _p.join(baseUrl.path, id.toString()),
queryParameters: _buildQuery(params));
var response =
await app.sendUnstreamed('PATCH', url, _writeHeaders, makeBody(data));
try {
if (_invalid(response)) {
if (_onModified.hasListener) {
_onModified.addError(failure(response));
} else {
throw failure(response);
}
}
var r = deserialize(json.decode(response.body));
_onModified.add(r);
return r;
} catch (e, st) {
if (_onModified.hasListener) {
_onModified.addError(e, st);
} else {
_log.severe(st);
throw failure(response, error: e, stack: st);
}
}
return null;
}
@override
Future<Data?> update(id, data, [Map<String, dynamic>? params]) async {
var url = baseUrl.replace(
path: _p.join(baseUrl.path, id.toString()),
queryParameters: _buildQuery(params));
var response =
await app.sendUnstreamed('POST', url, _writeHeaders, makeBody(data));
try {
if (_invalid(response)) {
if (_onUpdated.hasListener) {
_onUpdated.addError(failure(response));
} else {
throw failure(response);
}
}
var r = deserialize(json.decode(response.body));
_onUpdated.add(r);
return r;
} catch (e, st) {
if (_onUpdated.hasListener) {
_onUpdated.addError(e, st);
} else {
_log.severe(st);
throw failure(response, error: e, stack: st);
}
}
return null;
}
@override
Future<Data?> remove(id, [Map<String, dynamic>? params]) async {
var url = baseUrl.replace(
path: _p.join(baseUrl.path, id.toString()),
queryParameters: _buildQuery(params));
var response = await app.sendUnstreamed('DELETE', url, _readHeaders);
try {
if (_invalid(response)) {
if (_onRemoved.hasListener) {
_onRemoved.addError(failure(response));
} else {
throw failure(response);
}
}
var r = deserialize(json.decode(response.body));
_onRemoved.add(r);
return r;
} catch (e, st) {
if (_onRemoved.hasListener) {
_onRemoved.addError(e, st);
} else {
_log.severe(st);
throw failure(response, error: e, stack: st);
}
}
return null;
}
}

View file

@ -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<AngelAuthResult> 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<String> authenticateViaPopup(String url,
{String eventName = 'token', String? errorMessage}) {
var ctrl = StreamController<String>();
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();
}
}

View file

@ -0,0 +1,19 @@
/// Flutter-compatible client library for the Angel framework.
library angel_client.flutter;
import 'dart:async';
import 'package:http/http.dart' as http;
import 'base_angel_client.dart';
export '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<String> authenticateViaPopup(String url,
{String eventName = 'token'}) {
throw UnimplementedError(
'Opening popup windows is not supported in the `flutter` client.');
}
}

View file

@ -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<Service> _services = [];
Rest(String path) : super(http.Client() as http.BaseClient, path);
@override
Service<Id, Data> service<Id, Data>(String path,
{Type? type, AngelDeserializer? deserializer}) {
var url = baseUrl.replace(path: p.join(baseUrl.path, path));
var s = RestService<Id, Data>(client, this, url, type);
_services.add(s);
return s as Service<Id, Data>;
}
@override
Stream<String> 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<Id, Data> extends BaseAngelService<Id, Data> {
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);
}
}

View file

@ -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<void> 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> = 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<AngelAuthResult> get onAuthenticated;
/// Authenticates against the server.
///
/// This is designed with `package:angel_auth` in mind.
///
/// The [type] is appended to the [authEndpoint], ex. `local` becomes `/auth/local`.
///
/// The given [credentials] are sent to server as-is; the request body is sent as JSON.
Future<AngelAuthResult> authenticate(
{required String type, credentials, String authEndpoint = '/auth'});
/// Shorthand for authenticating via a JWT string.
Future<AngelAuthResult> reviveJwt(String token,
{String authEndpoint = '/auth'}) {
return authenticate(
type: 'token',
credentials: {'token': token},
authEndpoint: authEndpoint);
}
/// Opens the [url] in a new window, and returns a [Stream] that will fire a JWT on successful authentication.
Stream<String> authenticateViaPopup(String url, {String eventName = 'token'});
/// Disposes of any outstanding resources.
@override
Future<void> close();
/// Applies an [AngelConfigurer] to this instance.
Future<void> configure(AngelConfigurer configurer) async {
await configurer(this);
}
/// Logs the current user out of the application.
FutureOr<void> logout();
/// Creates a [Service] instance that queries a given path on the server.
///
/// This expects that there is an Angel `Service` mounted on the server.
///
/// In other words, all endpoints will return [Data], except for the root of
/// [path], which returns a [List<Data>].
///
/// You can pass a custom [deserializer], which is typically necessary in cases where
/// `dart:mirrors` does not exist.
Service<Id, Data> service<Id, Data>(String path,
{AngelDeserializer<Data>? deserializer});
//@override
//Future<http.Response> delete(url, {Map<String, String> headers});
@override
Future<http.Response> get(url, {Map<String, String>? headers});
@override
Future<http.Response> head(url, {Map<String, String>? headers});
@override
Future<http.Response> patch(url,
{body, Map<String, String>? headers, Encoding? encoding});
@override
Future<http.Response> post(url,
{body, Map<String, String>? headers, Encoding? encoding});
@override
Future<http.Response> put(url,
{body, Map<String, String>? headers, Encoding? encoding});
}
/// Represents the result of authentication with an Angel server.
class AngelAuthResult {
String? _token;
final Map<String, dynamic> data = {};
/// The JSON Web token that was sent with this response.
String? get token => _token;
AngelAuthResult({String? token, Map<String, dynamic> data = const {}}) {
_token = token;
this.data.addAll(data);
}
/// Attempts to deserialize a response from a [Map].
factory AngelAuthResult.fromMap(Map? data) {
final result = AngelAuthResult();
if (data is Map && data.containsKey('token') && data['token'] is String) {
result._token = data['token'].toString();
}
if (data is Map) {
result.data.addAll((data['data'] as Map<String, dynamic>?) ?? {});
}
if (result.token == null) {
throw 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<String, dynamic> toJson() {
return {'token': token, 'data': data};
}
}
/// Queries a service on an Angel server, with the same API.
abstract class Service<Id, Data> {
/// Fired on `indexed` events.
Stream<List<Data>> get onIndexed;
/// Fired on `read` events.
Stream<Data> get onRead;
/// Fired on `created` events.
Stream<Data> get onCreated;
/// Fired on `modified` events.
Stream<Data> get onModified;
/// Fired on `updated` events.
Stream<Data> get onUpdated;
/// Fired on `removed` events.
Stream<Data> get onRemoved;
/// The Angel instance powering this service.
Angel get app;
Future close();
/// Retrieves all resources.
Future<List<Data>?> index([Map<String, dynamic>? params]);
/// Retrieves the desired resource.
Future<Data> read(Id id, [Map<String, dynamic>? params]);
/// Creates a resource.
Future<Data> create(Data data, [Map<String, dynamic>? params]);
/// Modifies a resource.
Future<Data> modify(Id id, Data data, [Map<String, dynamic>? params]);
/// Overwrites a resource.
Future<Data> update(Id id, Data data, [Map<String, dynamic>? params]);
/// Removes the given resource.
Future<Data> remove(Id id, [Map<String, dynamic>? params]);
/// Creates a [Service] that wraps over this one, and maps input and output using two converter functions.
///
/// Handy utility for handling data in a type-safe manner.
Service<Id, U> map<U>(U Function(Data) encoder, Data Function(U) decoder) {
return _MappedService(this, encoder, decoder);
}
}
class _MappedService<Id, Data, U> extends Service<Id, U> {
final Service<Id, Data> inner;
final U Function(Data) encoder;
final Data Function(U) decoder;
_MappedService(this.inner, this.encoder, this.decoder);
@override
Angel get app => inner.app;
@override
Future close() => Future.value();
@override
Future<U> create(U data, [Map<String, dynamic>? params]) {
return inner.create(decoder(data)).then(encoder);
}
@override
Future<List<U>> index([Map<String, dynamic>? params]) {
return inner.index(params).then((l) => l!.map(encoder).toList());
}
@override
Future<U> modify(Id id, U data, [Map<String, dynamic>? params]) {
return inner.modify(id, decoder(data), params).then(encoder);
}
@override
Stream<U> get onCreated => inner.onCreated.map(encoder);
@override
Stream<List<U>> get onIndexed =>
inner.onIndexed.map((l) => l.map(encoder).toList());
@override
Stream<U> get onModified => inner.onModified.map(encoder);
@override
Stream<U> get onRead => inner.onRead.map(encoder);
@override
Stream<U> get onRemoved => inner.onRemoved.map(encoder);
@override
Stream<U> get onUpdated => inner.onUpdated.map(encoder);
@override
Future<U> read(Id id, [Map<String, dynamic>? params]) {
return inner.read(id, params).then(encoder);
}
@override
Future<U> remove(Id id, [Map<String, dynamic>? params]) {
return inner.remove(id, params).then(encoder);
}
@override
Future<U> update(Id id, U data, [Map<String, dynamic>? params]) {
return inner.update(id, decoder(data), params).then(encoder);
}
}
/// A [List] that automatically updates itself whenever the referenced [service] fires an event.
class ServiceList<Id, Data> extends DelegatingList<Data> {
/// A field name used to compare [Map] by ID.
final String idField;
/// A function used to compare the ID's two items for equality.
///
/// Defaults to comparing the [idField] of `Map` instances.
Equality<Data>? get equality => _equality;
Equality<Data>? _equality;
final Service<Id, Data> service;
final StreamController<ServiceList<Id, Data>> _onChange = StreamController();
final List<StreamSubscription> _subs = [];
ServiceList(this.service, {this.idField = 'id', Equality<Data>? equality})
: super([]) {
_equality = equality;
_equality ??= EqualityBy<Data, Id?>((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 = <int>[];
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<ServiceList<Id, Data>> get onChange => _onChange.stream;
Future close() async {
await _onChange.close();
}
}

View file

@ -0,0 +1,35 @@
{
"name": "angel_client",
"version": "1.0.0-dev",
"description": "Client library for the Angel framework.",
"main": "build/angel_client.js",
"jsnext:main": "lib/angel_client.js",
"directories": {
"test": "test"
},
"scripts": {
"compile": "npm run dartdevc && babel -o build/angel_client.js lib/angel_client.js",
"dartdevc": "dartdevc --modules node -o lib/angel_client.js lib/angel_client.dart",
"prepublish": "npm run compile",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/angel-dart/angel_client.git"
},
"keywords": [
"angel",
"angel_client"
],
"author": "Tobe O <thosakwe@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/angel-dart/angel_client/issues"
},
"homepage": "https://github.com/angel-dart/angel_client#readme",
"devDependencies": {
"babel-cli": "^6.18.0",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-preset-es2015": "^6.18.0"
}
}

View file

@ -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

View file

@ -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: '<jwt>');
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'}),
);
});
});
}

View file

@ -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<String, String> user = {'username': 'foo', 'password': 'bar'};
var localOpts = AngelAuthOptions<Map<String, String>>(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);
});
}

View file

@ -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<String> read(Stream<List<int>> 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<String> authenticateViaPopup(String url,
{String eventName = 'token'}) {
throw UnsupportedError('Nope');
}
}
class SpecClient extends http.BaseClient {
Spec? _spec;
Spec? get spec => _spec;
@override
Future<http.StreamedResponse> 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': '<jwt>',
'data': {'username': 'password'}
};
} else if (request.url.path == '/api/todos' && request.method == 'GET') {
data = [data];
}
return Future<http.StreamedResponse>.value(http.StreamedResponse(
Stream<List<int>>.fromIterable([utf8.encode(json.encode(data))]),
200,
headers: {
'content-type': 'application/json',
},
));
}
}
class Spec {
final http.BaseRequest request;
final String method, path;
final Map<String, String> headers;
final int? contentLength;
Spec(this.request, this.method, this.path, this.headers, this.contentLength);
@override
String toString() {
return {
'method': method,
'path': path,
'headers': headers,
'content_length': contentLength,
}.toString();
}
}

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Browser</title>
</head>
<body>
<script type="application/dart" src="browser.dart"></script>
</body>
</html>

View file

@ -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);
});
}

View file

@ -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);
}

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Client</title>
</head>
<body>
<script src="main.dart.js"></script>
</body>
</html>

View file

@ -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());
}