add: adding client package
This commit is contained in:
parent
5c925c481d
commit
4a42ce4bed
30 changed files with 1851 additions and 0 deletions
4
packages/client/.babelrc
Normal file
4
packages/client/.babelrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["es2015"],
|
||||
"plugins": ["add-module-exports"]
|
||||
}
|
72
packages/client/.gitignore
vendored
Normal file
72
packages/client/.gitignore
vendored
Normal 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
|
10
packages/client/.npmignore
Normal file
10
packages/client/.npmignore
Normal file
|
@ -0,0 +1,10 @@
|
|||
.babelrc
|
||||
.istanbul.yml
|
||||
.travis.yml
|
||||
.editorconfig
|
||||
.idea/
|
||||
src/
|
||||
test/
|
||||
!lib/
|
||||
.github/
|
||||
coverage
|
1
packages/client/.pubignore
Normal file
1
packages/client/.pubignore
Normal file
|
@ -0,0 +1 @@
|
|||
.DS_Store
|
12
packages/client/AUTHORS.md
Normal file
12
packages/client/AUTHORS.md
Normal 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.
|
122
packages/client/CHANGELOG.md
Normal file
122
packages/client/CHANGELOG.md
Normal 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
29
packages/client/LICENSE
Normal 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
108
packages/client/README.md
Normal 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,
|
||||
);
|
||||
}
|
||||
```
|
1
packages/client/analysis_options.yaml
Normal file
1
packages/client/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
14
packages/client/build.yaml
Normal file
14
packages/client/build.yaml
Normal file
|
@ -0,0 +1,14 @@
|
|||
targets:
|
||||
$default:
|
||||
builders:
|
||||
build_web_compilers|entrypoint:
|
||||
generate_for:
|
||||
- web/**.dart
|
||||
options:
|
||||
dart2js_args:
|
||||
- --dump-info
|
||||
- --fast-startup
|
||||
- --minify
|
||||
- --trust-type-annotations
|
||||
- --trust-primitives
|
||||
- --no-source-maps
|
1
packages/client/dart_test.yaml
Normal file
1
packages/client/dart_test.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
# platforms: [vm, content-shell]
|
11
packages/client/example/client/example_client.dart
Normal file
11
packages/client/example/client/example_client.dart
Normal 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}');
|
||||
}
|
36
packages/client/example/example1.dart
Normal file
36
packages/client/example/example1.dart
Normal 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);
|
||||
}
|
21
packages/client/example/main.dart
Normal file
21
packages/client/example/main.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'dart:async';
|
||||
import 'package: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};
|
||||
}
|
1
packages/client/lib/auth_types.dart
Normal file
1
packages/client/lib/auth_types.dart
Normal file
|
@ -0,0 +1 @@
|
|||
const String local = 'local', token = 'token';
|
468
packages/client/lib/base_angel_client.dart
Normal file
468
packages/client/lib/base_angel_client.dart
Normal 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;
|
||||
}
|
||||
}
|
79
packages/client/lib/browser.dart
Normal file
79
packages/client/lib/browser.dart
Normal 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();
|
||||
}
|
||||
}
|
19
packages/client/lib/flutter.dart
Normal file
19
packages/client/lib/flutter.dart
Normal file
|
@ -0,0 +1,19 @@
|
|||
/// Flutter-compatible client library for the Angel framework.
|
||||
library angel_client.flutter;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'base_angel_client.dart';
|
||||
export '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.');
|
||||
}
|
||||
}
|
74
packages/client/lib/io.dart
Normal file
74
packages/client/lib/io.dart
Normal 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);
|
||||
}
|
||||
}
|
347
packages/client/lib/platform_client.dart
Normal file
347
packages/client/lib/platform_client.dart
Normal 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();
|
||||
}
|
||||
}
|
35
packages/client/package.json
Normal file
35
packages/client/package.json
Normal file
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "angel_client",
|
||||
"version": "1.0.0-dev",
|
||||
"description": "Client library for the Angel framework.",
|
||||
"main": "build/angel_client.js",
|
||||
"jsnext:main": "lib/angel_client.js",
|
||||
"directories": {
|
||||
"test": "test"
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "npm run dartdevc && babel -o build/angel_client.js lib/angel_client.js",
|
||||
"dartdevc": "dartdevc --modules node -o lib/angel_client.js lib/angel_client.dart",
|
||||
"prepublish": "npm run compile",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/angel-dart/angel_client.git"
|
||||
},
|
||||
"keywords": [
|
||||
"angel",
|
||||
"angel_client"
|
||||
],
|
||||
"author": "Tobe O <thosakwe@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/angel-dart/angel_client/issues"
|
||||
},
|
||||
"homepage": "https://github.com/angel-dart/angel_client#readme",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.18.0",
|
||||
"babel-plugin-add-module-exports": "^0.2.1",
|
||||
"babel-preset-es2015": "^6.18.0"
|
||||
}
|
||||
}
|
42
packages/client/pubspec.yaml
Normal file
42
packages/client/pubspec.yaml
Normal 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
|
90
packages/client/test/all_test.dart
Normal file
90
packages/client/test/all_test.dart
Normal 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'}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
59
packages/client/test/auth_test.dart
Normal file
59
packages/client/test/auth_test.dart
Normal 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);
|
||||
});
|
||||
}
|
73
packages/client/test/common.dart
Normal file
73
packages/client/test/common.dart
Normal 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();
|
||||
}
|
||||
}
|
10
packages/client/test/index.html
Normal file
10
packages/client/test/index.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="application/dart" src="browser.dart"></script>
|
||||
</body>
|
||||
</html>
|
63
packages/client/test/list_test.dart
Normal file
63
packages/client/test/list_test.dart
Normal 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);
|
||||
});
|
||||
}
|
32
packages/client/test/shared.dart
Normal file
32
packages/client/test/shared.dart
Normal 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);
|
||||
}
|
9
packages/client/web/index.html
Normal file
9
packages/client/web/index.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Client</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="main.dart.js"></script>
|
||||
</body>
|
||||
</html>
|
8
packages/client/web/main.dart
Normal file
8
packages/client/web/main.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
import 'dart:html';
|
||||
import 'package: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());
|
||||
}
|
Loading…
Reference in a new issue