diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 38a2c18..f8c2d35 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -18,8 +18,6 @@ jobs: - uses: actions/checkout@v2 - name: Install dependencies run: dart pub get - - name: Print Dart version - run: dart --version - name: Format run: dart format --output none --set-exit-if-changed example lib test - name: Analyzer @@ -27,4 +25,4 @@ jobs: - name: Tests run: dart test --coverage=.coverage -j1 - name: Coverage - run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage + run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage 98 diff --git a/CHANGELOG.md b/CHANGELOG.md index 302bb29..f882fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.0] - 2023-09-07 +### Added +- Partial support for JSON:API v1.1 + +### Changed +- A bunch of BC-breaking changes. Please refer to the tests. +- Min SDK version is 3.0.0 +- Migrated to `http_interop`. You'll have to install `http_interop_http` or another implementation to get the HTTP client. + +### Removed +- Query filter. + ## [5.4.0] - 2023-04-30 ### Changed - Switch to http\_interop packages. @@ -13,9 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Client MessageConverter class to control HTTP request/response conversion. -### Fixed -- Content-Type being set for GET/OPTIONS requests ([issue](https://github.com/f3ath/json-api-dart/issues/129)). - ## [5.2.0] - 2022-06-01 ### Added - Support for included resources in create and update methods. Author: @kszczek @@ -229,6 +238,7 @@ the Document model. ### Added - Client: fetch resources, collections, related resources and relationships +[6.0.0]: https://github.com/f3ath/json-api-dart/compare/5.4.0...6.0.0 [5.4.0]: https://github.com/f3ath/json-api-dart/compare/5.3.0...5.4.0 [5.3.0]: https://github.com/f3ath/json-api-dart/compare/5.2.0...5.3.0 [5.2.0]: https://github.com/f3ath/json-api-dart/compare/5.1.0...5.2.0 @@ -263,4 +273,4 @@ the Document model. [0.4.0]: https://github.com/f3ath/json-api-dart/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/f3ath/json-api-dart/compare/0.2.0...0.3.0 [0.2.0]: https://github.com/f3ath/json-api-dart/compare/0.1.0...0.2.0 -[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0 \ No newline at end of file +[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0 diff --git a/README.md b/README.md index 06f7d06..9094870 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ TL;DR: ```dart +import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; @@ -14,7 +15,7 @@ void main() async { /// The [RoutingClient] is most likely the right choice. /// It has methods covering many standard use cases. - final client = RoutingClient(uriDesign); + final client = RoutingClient(uriDesign, Client(OneOffHandler())); try { /// Fetch the collection. @@ -31,14 +32,13 @@ void main() async { }); } on RequestFailure catch (e) { /// Catch error response - e.errors.forEach((error) => print('${error.title}')); + for (var error in e.errors) { + print(error.title); + } } } ``` This is a work-in-progress. You can help it by submitting a PR with a feature or documentation improvements. - - - [JSON:API]: https://jsonapi.org diff --git a/example/client.dart b/example/client.dart index 1a7df5a..a9b2387 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,3 +1,5 @@ +import 'package:http/http.dart' as http; +import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; @@ -8,27 +10,50 @@ void main() async { /// Use the standard recommended URL structure or implement your own final uriDesign = StandardUriDesign(Uri.parse(baseUri)); + /// This is the Dart's standard HTTP client. + /// Do not forget to close it in the end. + final httpClient = http.Client(); + + /// This is the adapter which decouples this JSON:API implementation + /// from the HTTP client. + /// Learn more: https://pub.dev/packages/http_interop + final httpHandler = ClientWrapper(httpClient); + + /// This is the basic JSON:API client. It is flexible but not very convenient + /// to use, because you would need to remember a lot of JSON:API protocol details. + /// We will use another wrapper on top of it. + final jsonApiClient = Client(httpHandler); + /// The [RoutingClient] is most likely the right choice. - /// It has methods covering many standard use cases. - final client = RoutingClient(uriDesign); + /// It is called routing because it routes the calls to the correct + /// URLs depending on the use case. Take a look at its methods, they cover + /// all the standard scenarios specified by the JSON:API standard. + final client = RoutingClient(uriDesign, jsonApiClient); try { /// Fetch the collection. /// See other methods to query and manipulate resources. final response = await client.fetchCollection('colors'); - final resources = response.collection; - resources.map((resource) => resource.attributes).forEach((attr) { - final name = attr['name']; - final red = attr['red']; - final green = attr['green']; - final blue = attr['blue']; + /// The fetched collection allows us to iterate over the resources + /// and to look into their attributes + for (final resource in response.collection) { + final { + 'name': name, + 'red': red, + 'green': green, + 'blue': blue, + } = resource.attributes; + print('${resource.type}:${resource.id}'); print('$name - $red:$green:$blue'); - }); + } } on RequestFailure catch (e) { /// Catch error response - for (var error in e.errors) { + for (final error in e.errors) { print(error.title); } } + + /// Free up the resources before exit. + httpClient.close(); } diff --git a/example/server.dart b/example/server.dart index 9293f90..57dccb9 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:http_interop/http_interop.dart' as interop; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; @@ -10,19 +11,24 @@ import 'server/in_memory_repo.dart'; import 'server/json_api_server.dart'; import 'server/repository.dart'; import 'server/repository_controller.dart'; -import 'server/try_catch_handler.dart'; Future main() async { final host = 'localhost'; final port = 8080; - final resources = ['colors']; - final repo = InMemoryRepo(resources); - await addColors(repo); + final repo = InMemoryRepo(['colors']); + await initRepo(repo); final controller = RepositoryController(repo, Uuid().v4); - HttpHandler handler = Router(controller, StandardUriDesign.matchTarget); - handler = TryCatchHandler(handler, onError: convertError); + interop.Handler handler = + ControllerRouter(controller, StandardUriDesign.matchTarget); + handler = TryCatchHandler(handler, + onError: ErrorConverter(onError: (e, stack) async { + stderr.writeln(e); + return Response(500, + document: OutboundErrorDocument( + [ErrorObject(title: 'Internal Server Error')])); + })); handler = LoggingHandler(handler, - onRequest: (r) => print('${r.method.toUpperCase()} ${r.uri}'), + onRequest: (r) => print('${r.method} ${r.uri}'), onResponse: (r) => print('${r.statusCode}')); final server = JsonApiServer(handler, host: host, port: port); @@ -33,14 +39,10 @@ Future main() async { await server.start(); - print('The server is listening at $host:$port.' - ' Try opening the following URL(s) in your browser:'); - for (var resource in resources) { - print('http://$host:$port/$resource'); - } + print('The server is listening at $host:$port.'); } -Future addColors(Repository repo) async { +Future initRepo(Repository repo) async { final models = { {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114}, {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203}, @@ -55,29 +57,3 @@ Future addColors(Repository repo) async { await repo.persist('colors', model); } } - -Future convertError(dynamic error) async { - if (error is MethodNotAllowed) { - return Response.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return Response.badRequest(); - } - if (error is CollectionNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); - } - if (error is ResourceNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); - } - if (error is RelationshipNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); - } - return Response(500, - document: OutboundErrorDocument([ - ErrorObject( - title: 'Error: ${error.runtimeType}', detail: error.toString()) - ])); -} diff --git a/example/server/cors_handler.dart b/example/server/cors_handler.dart index e42f2ef..c419a0a 100644 --- a/example/server/cors_handler.dart +++ b/example/server/cors_handler.dart @@ -1,31 +1,31 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/http.dart'; -class CorsHandler implements HttpHandler { +class CorsHandler implements Handler { CorsHandler(this._inner); - final HttpHandler _inner; + final Handler _inner; @override - Future handle(HttpRequest request) async { + Future handle(Request request) async { final headers = { - 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', - 'Access-Control-Expose-Headers': 'Location', + 'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'], + 'Access-Control-Expose-Headers': ['Location'], }; - if (request.isOptions) { + if (request.method.equals('OPTIONS')) { const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; - return HttpResponse(204) - ..headers.addAll({ - ...headers, - 'Access-Control-Allow-Methods': - // TODO: Make it work for all browsers. Why is toUpperCase() needed? - request.headers['Access-Control-Request-Method']?.toUpperCase() ?? - methods.join(', '), - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? '*', - }); + return Response( + 204, + Body.empty(), + Headers({ + ...headers, + 'Access-Control-Allow-Methods': + request.headers['Access-Control-Request-Method'] ?? methods, + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? ['*'], + })); } - return await _inner.handle(request) - ..headers.addAll(headers); + return await _inner.handle(request..headers.addAll(headers)); } } diff --git a/example/server/demo_handler.dart b/example/server/demo_handler.dart deleted file mode 100644 index 9f973cd..0000000 --- a/example/server/demo_handler.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; - -import 'in_memory_repo.dart'; -import 'repository.dart'; -import 'repository_controller.dart'; -import 'try_catch_handler.dart'; - -class DemoHandler extends LoggingHandler { - DemoHandler({ - Iterable types = const ['users', 'posts', 'comments'], - Function(HttpRequest request)? onRequest, - Function(HttpResponse response)? onResponse, - }) : super( - TryCatchHandler( - Router(RepositoryController(InMemoryRepo(types), _id), - StandardUriDesign.matchTarget), - onError: _onError), - onRequest: onRequest, - onResponse: onResponse); - - static Future _onError(dynamic error) async { - if (error is MethodNotAllowed) { - return Response.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return Response.badRequest(); - } - if (error is CollectionNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); - } - if (error is ResourceNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); - } - if (error is RelationshipNotFound) { - return Response.notFound( - OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); - } - return Response(500, - document: OutboundErrorDocument([ - ErrorObject( - title: 'Error: ${error.runtimeType}', detail: error.toString()) - ])); - } -} - -int _counter = 0; - -String _id() => (_counter++).toString(); diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart index d9a625f..0871d21 100644 --- a/example/server/in_memory_repo.dart +++ b/example/server/in_memory_repo.dart @@ -1,4 +1,5 @@ import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/src/nullable.dart'; import 'repository.dart'; @@ -28,11 +29,11 @@ class InMemoryRepo implements Repository { } @override - Stream addMany( - String type, String id, String rel, Iterable ids) { + Stream addMany( + String type, String id, String rel, Iterable ids) { final many = _many(type, id, rel); - many.addAll(ids.map(Ref.of)); - return Stream.fromIterable(many); + many.addAll(ids.map(Reference.of)); + return Stream.fromIterable(many).map((e) => e.toIdentifier()); } @override @@ -47,28 +48,32 @@ class InMemoryRepo implements Repository { @override Future replaceOne( - String type, String id, String rel, Identity? one) async { - _model(type, id).one[rel] = nullable(Ref.of)(one); + String type, String id, String rel, Identifier? one) async { + _model(type, id).one[rel] = nullable(Reference.of)(one); } @override - Stream deleteMany( - String type, String id, String rel, Iterable many) => - Stream.fromIterable(_many(type, id, rel)..removeAll(many.map(Ref.of))); + Stream deleteMany( + String type, String id, String rel, Iterable many) => + Stream.fromIterable( + _many(type, id, rel)..removeAll(many.map(Reference.of))) + .map((it) => it.toIdentifier()); @override - Stream replaceMany( - String type, String id, String rel, Iterable many) => - Stream.fromIterable(_many(type, id, rel) - ..clear() - ..addAll(many.map(Ref.of))); + Stream replaceMany( + String type, String id, String rel, Iterable many) { + final set = _many(type, id, rel); + set.clear(); + set.addAll(many.map(Reference.of)); + return Stream.fromIterable(set).map((it) => it.toIdentifier()); + } Map _collection(String type) => - (_storage[type] ?? (throw CollectionNotFound())); + (_storage[type] ?? (throw CollectionNotFound(type))); Model _model(String type, String id) => - _collection(type)[id] ?? (throw ResourceNotFound()); + _collection(type)[id] ?? (throw ResourceNotFound(type, id)); - Set _many(String type, String id, String rel) => - _model(type, id).many[rel] ?? (throw RelationshipNotFound()); + Set _many(String type, String id, String rel) => + _model(type, id).many[rel] ?? (throw RelationshipNotFound(type, id, rel)); } diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart index defdb7e..1d8510d 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -1,7 +1,7 @@ import 'dart:io'; +import 'package:http_interop/http_interop.dart'; import 'package:http_interop_io/http_interop_io.dart'; -import 'package:json_api/http.dart'; class JsonApiServer { JsonApiServer( @@ -16,7 +16,7 @@ class JsonApiServer { /// Server port final int port; - final HttpHandler _handler; + final Handler _handler; HttpServer? _server; /// Server uri diff --git a/example/server/repository.dart b/example/server/repository.dart index 2cd63ed..e838353 100644 --- a/example/server/repository.dart +++ b/example/server/repository.dart @@ -16,8 +16,8 @@ abstract class Repository { /// Throws [CollectionNotFound]. /// Throws [ResourceNotFound]. /// Throws [RelationshipNotFound]. - Stream addMany( - String type, String id, String rel, Iterable refs); + Stream addMany( + String type, String id, String rel, Iterable many); /// Delete the resource Future delete(String type, String id); @@ -25,33 +25,26 @@ abstract class Repository { /// Updates the model Future update(String type, String id, ModelProps props); - Future replaceOne(String type, String id, String rel, Identity? ref); + Future replaceOne(String type, String id, String rel, Identifier? one); /// Deletes refs from the to-many relationship. /// Returns the new actual refs. - Stream deleteMany( - String type, String id, String rel, Iterable refs); + Stream deleteMany( + String type, String id, String rel, Iterable many); /// Replaces refs in the to-many relationship. /// Returns the new actual refs. - Stream replaceMany( - String type, String id, String rel, Iterable refs); + Stream replaceMany( + String type, String id, String rel, Iterable many); } -class CollectionNotFound implements Exception {} +class Reference { + Reference(this.type, this.id); -class ResourceNotFound implements Exception {} + static Reference of(Identifier id) => Reference(id.type, id.id); -class RelationshipNotFound implements Exception {} - -class Ref with Identity { - Ref(this.type, this.id); - - static Ref of(Identity identity) => Ref(identity.type, identity.id); - - @override + Identifier toIdentifier() => Identifier(type, id); final String type; - @override final String id; @override @@ -59,22 +52,22 @@ class Ref with Identity { @override bool operator ==(Object other) => - other is Ref && type == other.type && id == other.id; + other is Reference && type == other.type && id == other.id; } class ModelProps { - static ModelProps fromResource(ResourceProperties res) { + static ModelProps fromResource(Resource res) { final props = ModelProps(); res.attributes.forEach((key, value) { props.attributes[key] = value; }); res.relationships.forEach((key, value) { if (value is ToOne) { - props.one[key] = nullable(Ref.of)(value.identifier); + props.one[key] = nullable(Reference.of)(value.identifier); return; } if (value is ToMany) { - props.many[key] = Set.of(value.map(Ref.of)); + props.many[key] = Set.of(value.map(Reference.of)); return; } }); @@ -82,8 +75,8 @@ class ModelProps { } final attributes = {}; - final one = {}; - final many = >{}; + final one = {}; + final many = >{}; void setFrom(ModelProps other) { other.attributes.forEach((key, value) { @@ -111,10 +104,10 @@ class Model extends ModelProps { }); one.forEach((key, value) { res.relationships[key] = - (value == null ? ToOne.empty() : ToOne(Identifier.of(value))); + (value == null ? ToOne.empty() : ToOne(value.toIdentifier())); }); many.forEach((key, value) { - res.relationships[key] = ToMany(value.map(Identifier.of)); + res.relationships[key] = ToMany(value.map((it) => it.toIdentifier())); }); return res; } diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart index 5e4100d..c1f9dd9 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -1,8 +1,12 @@ +import 'dart:convert'; + +import 'package:http_interop/extensions.dart'; +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; +import 'package:json_api/src/client/payload_codec.dart'; import 'package:json_api/src/nullable.dart'; import 'relationship_node.dart'; @@ -18,7 +22,7 @@ class RepositoryController implements Controller { final design = StandardUriDesign.pathOnly; @override - Future fetchCollection(HttpRequest request, Target target) async { + Future fetchCollection(http.Request request, Target target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) ..links['self'] = Link(design.collection(target.type)); @@ -33,7 +37,7 @@ class RepositoryController implements Controller { @override Future fetchResource( - HttpRequest request, ResourceTarget target) async { + http.Request request, ResourceTarget target) async { final resource = await _fetchLinkedResource(target.type, target.id); final doc = OutboundDataDocument.resource(resource) ..links['self'] = Link(design.resource(target.type, target.id)); @@ -45,14 +49,16 @@ class RepositoryController implements Controller { } @override - Future createResource(HttpRequest request, Target target) async { - final res = (await _decode(request)).dataAsNewResource(); - final ref = Ref(res.type, res.id ?? getId()); + Future createResource(http.Request request, Target target) async { + final document = await _decode(request); + final newResource = document.dataAsNewResource(); + final res = newResource.toResource(getId); await repo.persist( - res.type, Model(ref.id)..setFrom(ModelProps.fromResource(res))); - if (res.id != null) { + res.type, Model(res.id)..setFrom(ModelProps.fromResource(res))); + if (newResource.id != null) { return Response.noContent(); } + final ref = Reference.of(res.toIdentifier()); final self = Link(design.resource(ref.type, ref.id)); final resource = (await _fetchResource(ref.type, ref.id)) ..links['self'] = self; @@ -63,25 +69,24 @@ class RepositoryController implements Controller { @override Future addMany( - HttpRequest request, RelationshipTarget target) async { + http.Request request, RelationshipTarget target) async { final many = (await _decode(request)).asRelationship(); final refs = await repo .addMany(target.type, target.id, target.relationship, many) .toList(); - return Response.ok( - OutboundDataDocument.many(ToMany(refs.map(Identifier.of)))); + return Response.ok(OutboundDataDocument.many(ToMany(refs))); } @override Future deleteResource( - HttpRequest request, ResourceTarget target) async { + http.Request request, ResourceTarget target) async { await repo.delete(target.type, target.id); return Response.noContent(); } @override Future updateResource( - HttpRequest request, ResourceTarget target) async { + http.Request request, ResourceTarget target) async { await repo.update(target.type, target.id, ModelProps.fromResource((await _decode(request)).dataAsResource())); return Response.noContent(); @@ -89,18 +94,17 @@ class RepositoryController implements Controller { @override Future replaceRelationship( - HttpRequest request, RelationshipTarget target) async { + http.Request request, RelationshipTarget target) async { final rel = (await _decode(request)).asRelationship(); if (rel is ToOne) { final ref = rel.identifier; await repo.replaceOne(target.type, target.id, target.relationship, ref); - return Response.ok(OutboundDataDocument.one( - ref == null ? ToOne.empty() : ToOne(Identifier.of(ref)))); + return Response.ok( + OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(ref))); } if (rel is ToMany) { final ids = await repo .replaceMany(target.type, target.id, target.relationship, rel) - .map(Identifier.of) .toList(); return Response.ok(OutboundDataDocument.many(ToMany(ids))); } @@ -109,35 +113,35 @@ class RepositoryController implements Controller { @override Future deleteMany( - HttpRequest request, RelationshipTarget target) async { + http.Request request, RelationshipTarget target) async { final rel = (await _decode(request)).asToMany(); final ids = await repo .deleteMany(target.type, target.id, target.relationship, rel) - .map(Identifier.of) .toList(); return Response.ok(OutboundDataDocument.many(ToMany(ids))); } @override Future fetchRelationship( - HttpRequest request, RelationshipTarget target) async { + http.Request request, RelationshipTarget target) async { final model = (await repo.fetch(target.type, target.id)); if (model.one.containsKey(target.relationship)) { return Response.ok(OutboundDataDocument.one( - ToOne(nullable(Identifier.of)(model.one[target.relationship])))); + ToOne(model.one[target.relationship]?.toIdentifier()))); } - final many = model.many[target.relationship]; + final many = + model.many[target.relationship]?.map((it) => it.toIdentifier()); if (many != null) { - final doc = OutboundDataDocument.many(ToMany(many.map(Identifier.of))); + final doc = OutboundDataDocument.many(ToMany(many)); return Response.ok(doc); } - throw RelationshipNotFound(); + throw RelationshipNotFound(target.type, target.id, target.relationship); } @override Future fetchRelated( - HttpRequest request, RelatedTarget target) async { + http.Request request, RelatedTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final related = @@ -151,7 +155,7 @@ class RepositoryController implements Controller { await _fetchRelatedCollection(many).toList()); return Response.ok(doc); } - throw RelationshipNotFound(); + throw RelationshipNotFound(target.type, target.id, target.relationship); } /// Returns a stream of related resources recursively @@ -168,7 +172,8 @@ class RepositoryController implements Controller { /// Returns a stream of related resources Stream _getRelated(Resource resource, String relationship) async* { for (final _ in resource.relationships[relationship] ?? - (throw RelationshipNotFound())) { + (throw RelationshipNotFound( + resource.type, resource.id, relationship))) { yield await _fetchLinkedResource(_.type, _.id); } } @@ -187,19 +192,21 @@ class RepositoryController implements Controller { return (await repo.fetch(type, id)).toResource(type); } - Future _fetchRelatedResource(Ref ref) { + Future _fetchRelatedResource(Reference ref) { return _fetchLinkedResource(ref.type, ref.id); } - Stream _fetchRelatedCollection(Iterable refs) async* { + Stream _fetchRelatedCollection(Iterable refs) async* { for (final ref in refs) { final r = await _fetchRelatedResource(ref); if (r != null) yield r; } } - Future _decode(HttpRequest r) async => - InboundDocument(await PayloadCodec().decode(r.body)); + Future _decode(http.Request r) => r.body + .decode(utf8) + .then(const PayloadCodec().decode) + .then(InboundDocument.new); } typedef IdGenerator = String Function(); diff --git a/example/server/try_catch_handler.dart b/example/server/try_catch_handler.dart deleted file mode 100644 index a9a0240..0000000 --- a/example/server/try_catch_handler.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/http.dart'; - -class TryCatchHandler implements HttpHandler { - TryCatchHandler(this._inner, {this.onError = sendInternalServerError}); - - final HttpHandler _inner; - final Future Function(dynamic error) onError; - - static Future sendInternalServerError(dynamic e) async => - HttpResponse(500); - - @override - Future handle(HttpRequest request) async { - try { - return await _inner.handle(request); - } on HttpResponse catch (response) { - return response; - } catch (error) { - return await onError(error); - } - } -} diff --git a/lib/client.dart b/lib/client.dart index 9f4f0ef..034266e 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -23,9 +23,7 @@ /// The [RoutingClient] should be your default choice. library client; -export 'package:http_interop_http/http_interop_http.dart'; export 'package:json_api/src/client/client.dart'; -export 'package:json_api/src/client/persistent_handler.dart'; export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/response/collection_fetched.dart'; export 'package:json_api/src/client/response/related_resource_fetched.dart'; diff --git a/lib/document.dart b/lib/document.dart index 03bac6c..2618a54 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -2,15 +2,14 @@ library document; export 'package:json_api/src/document/error_object.dart'; -export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identity.dart'; export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/many.dart'; +export 'package:json_api/src/document/new_identifier.dart'; export 'package:json_api/src/document/new_resource.dart'; -export 'package:json_api/src/document/one.dart'; +export 'package:json_api/src/document/new_to_many.dart'; +export 'package:json_api/src/document/new_to_one.dart'; export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; -export 'package:json_api/src/document/resource_collection.dart'; -export 'package:json_api/src/document/resource_properties.dart'; +export 'package:json_api/src/document/to_many.dart'; +export 'package:json_api/src/document/to_one.dart'; diff --git a/lib/http.dart b/lib/http.dart index f7c2930..0cd26e6 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,15 +1,59 @@ -/// This is a thin HTTP layer abstraction used by the client and the server -library http; - -export 'package:http_interop/http_interop.dart' - show - HttpMessage, - HttpHeaders, - HttpRequest, - HttpResponse, - HttpHandler, - LoggingHandler; -export 'package:json_api/src/http/http_response_ext.dart'; -export 'package:json_api/src/http/media_type.dart'; -export 'package:json_api/src/http/payload_codec.dart'; -export 'package:json_api/src/http/status_code.dart'; +import 'dart:convert'; + +import 'package:http_interop/http_interop.dart'; + +class StatusCode { + const StatusCode(this.value); + + static const ok = 200; + static const created = 201; + static const accepted = 202; + static const noContent = 204; + static const badRequest = 400; + static const notFound = 404; + static const methodNotAllowed = 405; + static const unacceptable = 406; + static const unsupportedMediaType = 415; + + final int value; + + /// True for the requests processed asynchronously. + /// @see https://jsonapi.org/recommendations/#asynchronous-processing). + bool get isPending => value == accepted; + + /// True for successfully processed requests + bool get isSuccessful => value >= ok && value < 300 && !isPending; + + /// True for failed requests (i.e. neither successful nor pending) + bool get isFailed => !isSuccessful && !isPending; +} + +class Json extends Body { + Json(Map json) : super(jsonEncode(json), utf8); +} + +class LoggingHandler implements Handler { + LoggingHandler(this.handler, {this.onRequest, this.onResponse}); + + final Handler handler; + final Function(Request request)? onRequest; + final Function(Response response)? onResponse; + + @override + Future handle(Request request) async { + onRequest?.call(request); + final response = await handler.handle(request); + onResponse?.call(response); + return response; + } +} + +extension HeaderExt on Headers { + String? last(String key) { + final v = this[key]; + if (v != null && v.isNotEmpty) { + return v.last; + } + return null; + } +} diff --git a/lib/query.dart b/lib/query.dart index 1265a94..335eb69 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -2,7 +2,8 @@ library query; export 'package:json_api/src/query/fields.dart'; -export 'package:json_api/src/query/filter.dart'; export 'package:json_api/src/query/include.dart'; export 'package:json_api/src/query/page.dart'; +export 'package:json_api/src/query/query.dart'; +export 'package:json_api/src/query/query_encodable.dart'; export 'package:json_api/src/query/sort.dart'; diff --git a/lib/server.dart b/lib/server.dart index f3913a8..08f79a3 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -2,7 +2,12 @@ library server; export 'package:json_api/src/server/controller.dart'; +export 'package:json_api/src/server/controller_router.dart'; +export 'package:json_api/src/server/error_converter.dart'; +export 'package:json_api/src/server/errors/collection_not_found.dart'; export 'package:json_api/src/server/errors/method_not_allowed.dart'; +export 'package:json_api/src/server/errors/relationship_not_found.dart'; +export 'package:json_api/src/server/errors/resource_not_found.dart'; export 'package:json_api/src/server/errors/unmatched_target.dart'; export 'package:json_api/src/server/response.dart'; -export 'package:json_api/src/server/router.dart'; +export 'package:json_api/src/server/try_catch_handler.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index be1a50d..083f05d 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1,37 +1,39 @@ -import 'package:http_interop_http/http_interop_http.dart'; +import 'dart:convert'; + +import 'package:http_interop/extensions.dart'; +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/http.dart'; +import 'package:json_api/src/client/payload_codec.dart'; import 'package:json_api/src/client/request.dart'; import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/media_type.dart'; /// A basic JSON:API client. /// /// The JSON:API [Request] is converted to [HttpRequest] and sent downstream -/// using the [handler]. Received [HttpResponse] is then converted back to +/// using the [_handler]. Received [HttpResponse] is then converted back to /// JSON:API [Response]. JSON conversion is performed by the [codec]. class Client { - const Client( - {PayloadCodec codec = const PayloadCodec(), - HttpHandler handler = const DisposableHandler()}) - : _codec = codec, - _http = handler; + const Client(this._handler, {PayloadCodec codec = const PayloadCodec()}) + : _codec = codec; - final HttpHandler _http; + final http.Handler _handler; final PayloadCodec _codec; /// Sends the [request] to the given [uri]. Future send(Uri uri, Request request) async { - final body = await _encode(request.document); - final response = await _http.handle(HttpRequest( - request.method, - request.query.isEmpty - ? uri - : uri.replace(queryParameters: request.query), - body: body) - ..headers.addAll({ - 'Accept': mediaType, - if (body.isNotEmpty) 'Content-Type': mediaType, - ...request.headers - })); + final json = await _encode(request.document); + final body = http.Body(json, utf8); + final headers = http.Headers({ + 'Accept': [mediaType], + if (json.isNotEmpty) 'Content-Type': [mediaType], + ...request.headers + }); + final url = request.query.isEmpty + ? uri + : uri.replace(queryParameters: request.query.toQuery()); + final response = await _handler + .handle(http.Request(http.Method(request.method), url, body, headers)); final document = await _decode(response); return Response(response, document); @@ -40,6 +42,16 @@ class Client { Future _encode(Object? doc) async => doc == null ? '' : await _codec.encode(doc); - Future _decode(HttpResponse response) async => - response.hasDocument ? await _codec.decode(response.body) : null; + Future _decode(http.Response response) async { + final json = await response.body.decode(utf8); + if (json.isNotEmpty && + response.headers + .last('Content-Type') + ?.toLowerCase() + .startsWith(mediaType) == + true) { + return await _codec.decode(json); + } + return null; + } } diff --git a/lib/src/http/payload_codec.dart b/lib/src/client/payload_codec.dart similarity index 80% rename from lib/src/http/payload_codec.dart rename to lib/src/client/payload_codec.dart index 54cf3aa..e3ed74b 100644 --- a/lib/src/http/payload_codec.dart +++ b/lib/src/client/payload_codec.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'dart:convert'; /// Encodes/decodes JSON payload. +/// +/// The methods are designed to be asynchronous to allow for conversion to be +/// performed in isolates if needed. class PayloadCodec { const PayloadCodec(); diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart deleted file mode 100644 index b6d5007..0000000 --- a/lib/src/client/persistent_handler.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http_interop_http/http_interop_http.dart'; - -/// Handler which relies on the built-in Dart HTTP client. -/// It is the developer's responsibility to instantiate the client and -/// call `close()` on it in the end pf the application lifecycle. -class PersistentHandler extends HandlerWrapper { - /// Creates a new instance of the handler. Do not forget to call `close()` on - /// the [client] when it's not longer needed. - /// - /// Use [messageConverter] to fine tune the HTTP request/response conversion. - PersistentHandler( - Client client, - {@Deprecated('Deprecated in favor of MessageConverter.' - ' To be removed in version 6.0.0') - this.defaultEncoding = utf8, - MessageConverter? messageConverter}) - : super(client, - messageConverter: messageConverter ?? - MessageConverter(defaultResponseEncoding: defaultEncoding)); - - final Encoding defaultEncoding; -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index e9fa7c2..d2f30bf 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,9 +1,9 @@ -import 'package:json_api/http.dart'; +import 'package:http_interop/http_interop.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/client/client.dart'; /// A generic JSON:API request. -class Request with HttpHeaders { +class Request { /// Creates a new instance if the request with the specified HTTP [method] /// and [document]. Request(this.method, [this.document]); @@ -20,43 +20,16 @@ class Request with HttpHeaders { /// Creates a PATCH request. Request.patch([Object? document]) : this('patch', document); - /// HTTP method + /// HTTP method. final String method; /// JSON:API document. This object can be of any type as long as it is /// encodable by the [PayloadCodec] used in the [Client]. final Object? document; - /// Query parameters - final query = {}; - - /// Requests inclusion of related resources. - /// See https://jsonapi.org/format/#fetching-includes - void include(Iterable include) { - query.addAll(Include(include).asQueryParameters); - } - - /// Sets sorting parameters. - /// See https://jsonapi.org/format/#fetching-sorting - void sort(Iterable sort) { - query.addAll(Sort(sort).asQueryParameters); - } - - /// Requests sparse fieldsets. - /// See https://jsonapi.org/format/#fetching-sparse-fieldsets - void fields(Map> fields) { - query.addAll(Fields(fields).asQueryParameters); - } - - /// Sets pagination parameters. - /// See https://jsonapi.org/format/#fetching-pagination - void page(Map page) { - query.addAll(Page(page).asQueryParameters); - } - - /// Response filtering. - /// https://jsonapi.org/format/#fetching-filtering - void filter(Map filter) { - query.addAll(Filter(filter).asQueryParameters); - } + /// Query parameters. + final query = Query(); + + /// HTTP headers. + final headers = Headers({}); } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 8c51622..20732c1 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,12 +1,16 @@ +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/http.dart'; /// A generic JSON:API response. class Response { - Response(this.http, this.document); + Response(this.httpResponse, this.document); /// HTTP response - final HttpResponse http; + final http.Response httpResponse; /// Decoded JSON document final Map? document; + + /// Returns true if the [statusCode] represents a failure + bool get isFailed => StatusCode(httpResponse.statusCode).isFailed; } diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart index 99c94e4..d643258 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -1,8 +1,8 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; class CollectionFetched { - CollectionFetched(this.http, Map json) { + CollectionFetched(this.httpResponse, Map json) { final document = InboundDocument(json); collection.addAll(document.dataAsCollection()); included.addAll(document.included()); @@ -10,13 +10,13 @@ class CollectionFetched { links.addAll(document.links()); } - final HttpResponse http; + final Response httpResponse; /// The resource collection fetched from the server - final collection = ResourceCollection(); + final collection = []; /// Included resources - final included = ResourceCollection(); + final included = []; /// Top-level meta data final meta = {}; diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart index a6fb4ad..39a71a7 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -1,11 +1,11 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// A related resource response. /// /// https://jsonapi.org/format/#fetching-resources-responses class RelatedResourceFetched { - RelatedResourceFetched(this.http, Map json) + RelatedResourceFetched(this.httpResponse, Map json) : resource = InboundDocument(json).dataAsResourceOrNull() { final document = InboundDocument(json); included.addAll(document.included()); @@ -13,13 +13,13 @@ class RelatedResourceFetched { links.addAll(document.links()); } - final HttpResponse http; + final Response httpResponse; /// Related resource. May be null final Resource? resource; /// Included resources - final included = ResourceCollection(); + final included = []; /// Top-level meta data final meta = {}; diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index fa6ff0e..a99c78e 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -1,19 +1,19 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// A response to a relationship fetch request. class RelationshipFetched { - RelationshipFetched(this.http, this.relationship); + RelationshipFetched(this.httpResponse, this.relationship); - static RelationshipFetched many(HttpResponse http, Map json) => - RelationshipFetched(http, InboundDocument(json).asToMany()) + static RelationshipFetched many(Response httpResponse, Map json) => + RelationshipFetched(httpResponse, InboundDocument(json).asToMany()) ..included.addAll(InboundDocument(json).included()); - static RelationshipFetched one(HttpResponse http, Map json) => - RelationshipFetched(http, InboundDocument(json).asToOne()) + static RelationshipFetched one(Response httpResponse, Map json) => + RelationshipFetched(httpResponse, InboundDocument(json).asToOne()) ..included.addAll(InboundDocument(json).included()); - final HttpResponse http; + final Response httpResponse; final R relationship; - final included = ResourceCollection(); + final included = []; } diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index e596d92..2adb742 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -1,19 +1,19 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// A response to a relationship request. class RelationshipUpdated { - RelationshipUpdated(this.http, this.relationship); + RelationshipUpdated(this.httpResponse, this.relationship); - static RelationshipUpdated many(HttpResponse http, Map? json) => + static RelationshipUpdated many(Response httpResponse, Map? json) => RelationshipUpdated( - http, json == null ? null : InboundDocument(json).asToMany()); + httpResponse, json == null ? null : InboundDocument(json).asToMany()); - static RelationshipUpdated one(HttpResponse http, Map? json) => + static RelationshipUpdated one(Response httpResponse, Map? json) => RelationshipUpdated( - http, json == null ? null : InboundDocument(json).asToOne()); + httpResponse, json == null ? null : InboundDocument(json).asToOne()); - final HttpResponse http; + final Response httpResponse; /// Updated relationship. Null if "204 No Content" is returned. final R? relationship; diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart index 924c729..096a84c 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -1,16 +1,16 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// Thrown when the server returns a non-successful response. class RequestFailure implements Exception { - RequestFailure(this.http, Map? document) { + RequestFailure(this.httpResponse, Map? document) { if (document != null) { errors.addAll(InboundDocument(document).errors()); meta.addAll(InboundDocument(document).meta()); } } - final HttpResponse http; + final Response httpResponse; /// Error objects returned by the server final errors = []; @@ -20,5 +20,5 @@ class RequestFailure implements Exception { @override String toString() => - 'JSON:API request failed with HTTP status ${http.statusCode}'; + 'JSON:API request failed with HTTP status ${httpResponse.statusCode}.'; } diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart index 7cd3069..3b15781 100644 --- a/lib/src/client/response/resource_created.dart +++ b/lib/src/client/response/resource_created.dart @@ -1,19 +1,19 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// A response to a new resource creation request. /// This is always a "201 Created" response. /// /// https://jsonapi.org/format/#crud-creating-responses-201 class ResourceCreated { - ResourceCreated(this.http, Map json) + ResourceCreated(this.httpResponse, Map json) : resource = InboundDocument(json).dataAsResource() { meta.addAll(InboundDocument(json).meta()); links.addAll(InboundDocument(json).links()); included.addAll(InboundDocument(json).included()); } - final HttpResponse http; + final Response httpResponse; /// Created resource. final Resource resource; @@ -25,5 +25,5 @@ class ResourceCreated { final links = {}; /// Included resources - final included = ResourceCollection(); + final included = []; } diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart index 8cb4496..3e62829 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.dart @@ -1,16 +1,16 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; /// A response to fetch a primary resource request class ResourceFetched { - ResourceFetched(this.http, Map json) + ResourceFetched(this.httpResponse, Map json) : resource = InboundDocument(json).dataAsResource() { included.addAll(InboundDocument(json).included()); meta.addAll(InboundDocument(json).meta()); links.addAll(InboundDocument(json).links()); } - final HttpResponse http; + final Response httpResponse; final Resource resource; /// Top-level meta data @@ -20,5 +20,5 @@ class ResourceFetched { final links = {}; /// Included resources - final included = ResourceCollection(); + final included = []; } diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart index 1a8b67a..ca9b919 100644 --- a/lib/src/client/response/resource_updated.dart +++ b/lib/src/client/response/resource_updated.dart @@ -1,8 +1,8 @@ +import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; class ResourceUpdated { - ResourceUpdated(this.http, Map? json) : resource = _resource(json) { + ResourceUpdated(this.httpResponse, Map? json) : resource = _resource(json) { if (json != null) { included.addAll(InboundDocument(json).included()); meta.addAll(InboundDocument(json).meta()); @@ -20,7 +20,7 @@ class ResourceUpdated { return null; } - final HttpResponse http; + final Response httpResponse; /// The created resource. Null for "204 No Content" responses. late final Resource? resource; @@ -32,5 +32,5 @@ class ResourceUpdated { final links = {}; /// Included resources - final included = ResourceCollection(); + final included = []; } diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index f4c03c6..9a250d0 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/client/client.dart'; import 'package:json_api/src/client/request.dart'; @@ -15,13 +15,12 @@ import 'package:json_api/src/client/response/resource_updated.dart'; /// A routing JSON:API client class RoutingClient { - RoutingClient(this.baseUri, {Client client = const Client()}) - : _client = client; + RoutingClient(this._baseUri, this._client); final Client _client; - final UriDesign baseUri; + final UriDesign _baseUri; - /// Adds [identifiers] to a to-many relationship + /// Adds the [identifiers] to the to-many relationship /// identified by [type], [id], [relationship]. /// /// Optional arguments: @@ -32,18 +31,17 @@ class RoutingClient { String relationship, List identifiers, { Map meta = const {}, - Map headers = const {}, + Map> headers = const {}, }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.post( OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) ..headers.addAll(headers)); - return RelationshipUpdated.many(response.http, response.document); + return RelationshipUpdated.many(response.httpResponse, response.document); } - /// Creates a new resource in the collection of type [type]. - /// The server is responsible for assigning the resource id. + /// Creates a new resource with the given [type] and [id] on the server. /// /// Optional arguments: /// - [attributes] - resource attributes @@ -52,19 +50,21 @@ class RoutingClient { /// - [meta] - resource meta data /// - [documentMeta] - document meta /// - [headers] - any extra HTTP headers - Future createNew( - String type, { + /// - [query] - a collection of parameters to be included in the URI query + Future create( + String type, + String id, { Map attributes = const {}, Map one = const {}, Map> many = const {}, Map meta = const {}, Map documentMeta = const {}, - Map headers = const {}, - Iterable include = const [], + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.collection(type), - Request.post(OutboundDataDocument.newResource(NewResource(type) + _baseUri.collection(type), + Request.post(OutboundDataDocument.resource(Resource(type, id) ..attributes.addAll(attributes) ..relationships.addAll({ ...one.map((key, value) => MapEntry(key, ToOne(value))), @@ -73,181 +73,212 @@ class RoutingClient { ..meta.addAll(meta)) ..meta.addAll(documentMeta)) ..headers.addAll(headers) - ..include(include)); + ..query.mergeAll(query)); + return ResourceUpdated(response.httpResponse, response.document); + } + + /// Creates a new resource in the collection of type [type]. + /// The server is responsible for assigning the resource id. + /// + /// Optional arguments: + /// - [lid] - local resource id + /// - [attributes] - resource attributes + /// - [one] - resource to-one relationships + /// - [many] - resource to-many relationships + /// - [meta] - resource meta data + /// - [documentMeta] - document meta + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query + Future createNew( + String type, { + String? lid, + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map documentMeta = const {}, + Map> headers = const {}, + Iterable query = const [], + }) async { + final response = await send( + _baseUri.collection(type), + Request.post( + OutboundDataDocument.newResource(NewResource(type, lid: lid) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, NewToOne(value))), + ...many.map((key, value) => MapEntry(key, NewToMany(value))), + }) + ..meta.addAll(meta)) + ..meta.addAll(documentMeta)) + ..headers.addAll(headers) + ..query.mergeAll(query)); return ResourceCreated( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } - /// Deletes [identifiers] from a to-many relationship + /// Deletes the [identifiers] from the to-many relationship /// identified by [type], [id], [relationship]. /// /// Optional arguments: /// - [headers] - any extra HTTP headers + /// - [meta] - relationship meta data Future deleteFromMany( String type, String id, String relationship, List identifiers, { Map meta = const {}, - Map headers = const {}, + Map> headers = const {}, }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.delete( OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) ..headers.addAll(headers)); - return RelationshipUpdated.many(response.http, response.document); + return RelationshipUpdated.many(response.httpResponse, response.document); } - /// Fetches a primary collection of type [type]. + /// Fetches the primary collection of type [type]. /// /// Optional arguments: /// - [headers] - any extra HTTP headers - /// - [query] - any extra query parameters - /// - [page] - pagination options - /// - [filter] - filtering options - /// - [include] - request to include related resources - /// - [sort] - collection sorting options - /// - [fields] - sparse fields options + /// - [query] - a collection of parameters to be included in the URI query Future fetchCollection( String type, { - Map headers = const {}, - Map query = const {}, - Map page = const {}, - Map filter = const {}, - Iterable include = const [], - Iterable sort = const [], - Map> fields = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.collection(type), + _baseUri.collection(type), Request.get() ..headers.addAll(headers) - ..query.addAll(query) - ..page(page) - ..filter(filter) - ..include(include) - ..sort(sort) - ..fields(fields)); + ..query.mergeAll(query)); return CollectionFetched( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } - /// Fetches a related resource collection + /// Fetches the related resource collection /// identified by [type], [id], [relationship]. /// /// Optional arguments: /// - [headers] - any extra HTTP headers - /// - [query] - any extra query parameters - /// - [page] - pagination options - /// - [filter] - filtering options - /// - [include] - request to include related resources - /// - [sort] - collection sorting options - /// - [fields] - sparse fields options + /// - [query] - a collection of parameters to be included in the URI query Future fetchRelatedCollection( String type, String id, String relationship, { - Map headers = const {}, - Map page = const {}, - Map filter = const {}, - Iterable include = const [], - Iterable sort = const [], - Map> fields = const {}, - Map query = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.related(type, id, relationship), + _baseUri.related(type, id, relationship), Request.get() ..headers.addAll(headers) - ..query.addAll(query) - ..page(page) - ..filter(filter) - ..include(include) - ..sort(sort) - ..fields(fields)); + ..query.mergeAll(query)); return CollectionFetched( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } + /// Fetches the to-one relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future> fetchToOne( String type, String id, String relationship, { - Map headers = const {}, - Map query = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.get() ..headers.addAll(headers) - ..query.addAll(query)); + ..query.mergeAll(query)); return RelationshipFetched.one( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } + /// Fetches the to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future> fetchToMany( String type, String id, String relationship, { - Map headers = const {}, - Map query = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.get() ..headers.addAll(headers) - ..query.addAll(query)); + ..query.mergeAll(query)); return RelationshipFetched.many( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } + /// Fetches the related resource + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future fetchRelatedResource( String type, String id, String relationship, { - Map headers = const {}, - Map query = const {}, - Map filter = const {}, - Iterable include = const [], - Map> fields = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.related(type, id, relationship), + _baseUri.related(type, id, relationship), Request.get() ..headers.addAll(headers) - ..query.addAll(query) - ..filter(filter) - ..include(include) - ..fields(fields)); + ..query.mergeAll(query)); return RelatedResourceFetched( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } + /// Fetches the resource identified by [type] and [id]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future fetchResource( String type, String id, { - Map headers = const {}, - Map filter = const {}, - Iterable include = const [], - Map> fields = const {}, - Map query = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.resource(type, id), + _baseUri.resource(type, id), Request.get() ..headers.addAll(headers) - ..query.addAll(query) - ..filter(filter) - ..include(include) - ..fields(fields)); + ..query.mergeAll(query)); return ResourceFetched( - response.http, response.document ?? (throw FormatException())); + response.httpResponse, response.document ?? (throw FormatException())); } + /// Updates the resource identified by [type] and [id]. + /// + /// Optional arguments: + /// - [attributes] - attributes to update + /// - [one] - to-one relationships to update + /// - [many] - to-many relationships to update + /// - [meta] - resource meta data to update + /// - [documentMeta] - document meta data + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future updateResource( String type, String id, { @@ -256,11 +287,11 @@ class RoutingClient { Map> many = const {}, Map meta = const {}, Map documentMeta = const {}, - Map headers = const {}, - Iterable include = const [], + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.resource(type, id), + _baseUri.resource(type, id), Request.patch(OutboundDataDocument.resource(Resource(type, id) ..attributes.addAll(attributes) ..relationships.addAll({ @@ -270,86 +301,106 @@ class RoutingClient { ..meta.addAll(meta)) ..meta.addAll(documentMeta)) ..headers.addAll(headers) - ..include(include)); - return ResourceUpdated(response.http, response.document); - } - - /// Creates a new resource with the given id on the server. - Future create( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - Map documentMeta = const {}, - Map headers = const {}, - Iterable include = const [], - }) async { - final response = await send( - baseUri.collection(type), - Request.post(OutboundDataDocument.resource(Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, ToOne(value))), - ...many.map((key, value) => MapEntry(key, ToMany(value))), - }) - ..meta.addAll(meta)) - ..meta.addAll(documentMeta)) - ..headers.addAll(headers) - ..include(include)); - return ResourceUpdated(response.http, response.document); + ..query.mergeAll(query)); + return ResourceUpdated(response.httpResponse, response.document); } + /// Replaces the to-one relationship + /// identified by [type], [id], and [relationship] by setting + /// the new [identifier]. + /// + /// Optional arguments: + /// - [meta] - relationship metadata + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future> replaceToOne( String type, String id, String relationship, Identifier identifier, { Map meta = const {}, - Map headers = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.patch( OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta))) - ..headers.addAll(headers)); - return RelationshipUpdated.one(response.http, response.document); + ..headers.addAll(headers) + ..query.mergeAll(query)); + return RelationshipUpdated.one(response.httpResponse, response.document); } + /// Replaces the to-many relationship + /// identified by [type], [id], and [relationship] by setting + /// the new [identifiers]. + /// + /// Optional arguments: + /// - [meta] - relationship metadata + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future> replaceToMany( String type, String id, String relationship, Iterable identifiers, { Map meta = const {}, - Map headers = const {}, + Map> headers = const {}, + Iterable query = const [], }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.patch( OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers)); - return RelationshipUpdated.many(response.http, response.document); + ..headers.addAll(headers) + ..query.mergeAll(query)); + return RelationshipUpdated.many(response.httpResponse, response.document); } + /// Removes the to-one relationship + /// identified by [type], [id], and [relationship].. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query Future> deleteToOne( - String type, String id, String relationship, - {Map headers = const {}}) async { + String type, + String id, + String relationship, { + Map> headers = const {}, + Iterable query = const [], + }) async { final response = await send( - baseUri.relationship(type, id, relationship), + _baseUri.relationship(type, id, relationship), Request.patch(OutboundDataDocument.one(ToOne.empty())) - ..headers.addAll(headers)); - return RelationshipUpdated.one(response.http, response.document); + ..headers.addAll(headers) + ..query.mergeAll(query)); + return RelationshipUpdated.one(response.httpResponse, response.document); } - Future deleteResource(String type, String id) => - send(baseUri.resource(type, id), Request.delete()); + /// Deletes the resource identified by [type] and [id]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - a collection of parameters to be included in the URI query + Future deleteResource( + String type, + String id, { + Map> headers = const {}, + Iterable query = const [], + }) => + send( + _baseUri.resource(type, id), + Request.delete() + ..headers.addAll(headers) + ..query.mergeAll(query)); + /// Sends the [request] to the [uri] on the server. + /// This method can be used to send any non-standard requests. Future send(Uri uri, Request request) async { final response = await _client.send(uri, request); - if (response.http.isFailed) { - throw RequestFailure(response.http, response.document); + if (response.isFailed) { + throw RequestFailure(response.httpResponse, response.document); } return response; } diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart deleted file mode 100644 index 76a0c13..0000000 --- a/lib/src/document/identifier.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/src/document/identity.dart'; - -/// A Resource Identifier object -class Identifier with Identity { - Identifier(this.type, this.id); - - static Identifier of(Identity identity) => - Identifier(identity.type, identity.id); - - @override - final String type; - @override - final String id; - - /// Identifier meta-data. - final meta = {}; - - Map toJson() => - {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; -} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart deleted file mode 100644 index 9a16a54..0000000 --- a/lib/src/document/identity.dart +++ /dev/null @@ -1,9 +0,0 @@ -mixin Identity { - static bool same(Identity a, Identity b) => a.type == b.type && a.id == b.id; - - String get type; - - String get id; - - String get key => '$type:$id'; -} diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index 82f1122..962052a 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -1,12 +1,15 @@ import 'package:json_api/src/document/error_object.dart'; import 'package:json_api/src/document/error_source.dart'; -import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/new_relationship.dart'; import 'package:json_api/src/document/new_resource.dart'; -import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/new_to_many.dart'; +import 'package:json_api/src/document/new_to_one.dart'; import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/to_many.dart'; +import 'package:json_api/src/document/to_one.dart'; import 'package:json_api/src/nullable.dart'; /// Inbound JSON:API document @@ -79,18 +82,31 @@ class _Parser { return rel; } - Resource resource(Map json) => - Resource(json.get('type'), json.get('id')) + NewRelationship newRelationship(Map json) { + final rel = + json.containsKey('data') ? _newRel(json['data']) : NewRelationship(); + rel.links.addAll(links(json)); + rel.meta.addAll(meta(json)); + return rel; + } + + Resource resource(Map json) => Resource( + json.get('type'), + json.get('id'), + ) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getRelationships(json)) ..links.addAll(links(json)) ..meta.addAll(meta(json)); - NewResource newResource(Map json) => NewResource(json.get('type'), - json.containsKey('id') ? json.get('id') : null) - ..attributes.addAll(_getAttributes(json)) - ..relationships.addAll(_getRelationships(json)) - ..meta.addAll(meta(json)); + NewResource newResource(Map json) => NewResource( + json.get('type'), + id: json.getIfDefined('id'), + lid: json.getIfDefined('lid'), + ) + ..attributes.addAll(_getAttributes(json)) + ..relationships.addAll(_getNewRelationships(json)) + ..meta.addAll(meta(json)); /// Decodes Identifier from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. @@ -98,6 +114,21 @@ class _Parser { Identifier(json.get('type'), json.get('id')) ..meta.addAll(meta(json)); + /// Decodes NewIdentifier from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + NewIdentifier newIdentifier(Map json) { + final type = json.get('type'); + final id = json.getIfDefined('id'); + final lid = json.getIfDefined('lid'); + if (id != null) { + return Identifier(type, id)..meta.addAll(meta(json)); + } + if (lid != null) { + return LocalIdentifier(type, lid)..meta.addAll(meta(json)); + } + throw FormatException('Invalid JSON'); + } + ErrorObject errorObject(Map json) => ErrorObject( id: json.get('id', orGet: () => ''), status: json.get('status', orGet: () => ''), @@ -131,23 +162,46 @@ class _Parser { .get('relationships', orGet: () => {}) .map((key, value) => MapEntry(key, relationship(value))); + Map _getNewRelationships(Map json) => json + .get('relationships', orGet: () => {}) + .map((key, value) => MapEntry(key, newRelationship(value))); + Relationship _rel(data) { if (data == null) return ToOne.empty(); if (data is Map) return ToOne(identifier(data)); if (data is List) return ToMany(data.whereType().map(identifier)); throw FormatException('Invalid relationship object'); } + + NewRelationship _newRel(data) { + if (data == null) { + return NewToOne.empty(); + } + if (data is Map) { + return NewToOne(newIdentifier(data)); + } + if (data is List) { + return NewToMany(data.whereType().map(newIdentifier)); + } + throw FormatException('Invalid relationship object'); + } } extension _TypedGeter on Map { T get(String key, {T Function()? orGet}) { - if (containsKey(key)) { - final val = this[key]; - if (val is T) return val; - throw FormatException( - 'Key "$key": expected $T, found ${val.runtimeType}'); - } + if (containsKey(key)) return _get(key); if (orGet != null) return orGet(); throw FormatException('Key "$key" does not exist'); } + + T? getIfDefined(String key, {T Function()? orGet}) { + if (containsKey(key)) return _get(key); + return null; + } + + T _get(String key) { + final val = this[key]; + if (val is T) return val; + throw FormatException('Key "$key": expected $T, found ${val.runtimeType}'); + } } diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart deleted file mode 100644 index 93605b1..0000000 --- a/lib/src/document/many.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_collection.dart'; - -class ToMany extends Relationship { - ToMany(Iterable identifiers) { - for (var id in identifiers) { - _map[id.key] = id; - } - } - - final _map = {}; - - @override - Map toJson() => - {'data': _map.values.toList(), ...super.toJson()}; - - @override - Iterator get iterator => _map.values.iterator; - - /// Finds the referenced elements which are found in the [collection]. - /// The resulting [Iterable] may contain fewer elements than referred by the - /// relationship if the [collection] does not have all of them. - Iterable findIn(ResourceCollection collection) => - _map.keys.map((key) => collection[key]).whereType(); -} diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart new file mode 100644 index 0000000..1c0653d --- /dev/null +++ b/lib/src/document/new_identifier.dart @@ -0,0 +1,69 @@ +/// A new Resource Identifier object, used when creating new resources on the server. +sealed class NewIdentifier { + /// Resource type. + String get type; + + /// Resource id. + String? get id; + + /// Local Resource id. + String? get lid; + + /// Identifier meta-data. + Map get meta; + + Map toJson(); +} + +/// A Resource Identifier object +class Identifier implements NewIdentifier { + Identifier(this.type, this.id); + + /// Resource type. + @override + final String type; + + /// Resource id. + @override + final String id; + + @override + final lid = null; + + /// Identifier meta-data. + @override + final meta = {}; + + @override + Map toJson() => + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; +} + +class LocalIdentifier implements NewIdentifier { + LocalIdentifier(this.type, this.lid); + + /// Resource type. + @override + final String type; + + /// Resource id. + @override + final id = null; + + /// Local Resource id. + @override + final String lid; + + /// Identifier meta-data. + @override + final meta = {}; + + @override + Map toJson() => { + 'type': type, + 'lid': lid, + if (meta.isNotEmpty) 'meta': meta, + }; + + Identifier toIdentifier(String id) => Identifier(type, id)..meta.addAll(meta); +} diff --git a/lib/src/document/new_relationship.dart b/lib/src/document/new_relationship.dart new file mode 100644 index 0000000..f985ea0 --- /dev/null +++ b/lib/src/document/new_relationship.dart @@ -0,0 +1,19 @@ +import 'dart:collection'; + +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/new_identifier.dart'; + +class NewRelationship with IterableMixin { + final links = {}; + final meta = {}; + + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; + + // coverage:ignore-start + @override + Iterator get iterator => [].iterator; +// coverage:ignore-end +} diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index b8ee227..b111ab7 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -1,22 +1,85 @@ -import 'package:json_api/src/document/resource_properties.dart'; +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/new_relationship.dart'; +import 'package:json_api/src/document/new_to_many.dart'; +import 'package:json_api/src/document/new_to_one.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/to_many.dart'; +import 'package:json_api/src/document/to_one.dart'; /// A set of properties for a to-be-created resource which does not have the id yet. -class NewResource with ResourceProperties { - NewResource(this.type, [this.id]) { - ArgumentError.checkNotNull(type); - } +class NewResource { + NewResource(this.type, {this.id, this.lid}); /// Resource type final String type; - /// Nullable. Resource id. + /// Resource id. final String? id; + /// Local resource id. + final String? lid; + + /// Resource meta data. + final meta = {}; + + /// Resource attributes. + /// + /// See https://jsonapi.org/format/#document-resource-object-attributes + final attributes = {}; + + /// Resource relationships. + /// + /// See https://jsonapi.org/format/#document-resource-object-relationships + final relationships = {}; + Map toJson() => { 'type': type, if (id != null) 'id': id!, + if (lid != null) 'lid': lid!, if (attributes.isNotEmpty) 'attributes': attributes, if (relationships.isNotEmpty) 'relationships': relationships, if (meta.isNotEmpty) 'meta': meta, }; + + /// Converts this to a real [Resource] object, assigning the id if necessary. + Resource toResource(String Function() getId) { + final resource = Resource(type, id ?? getId()); + resource.attributes.addAll(attributes); + resource.relationships.addAll(_toRelationships(resource.id)); + resource.meta.addAll(meta); + return resource; + } + + Map _toRelationships(String id) => relationships + .map((k, v) => MapEntry(k, _toRelationship(v, id)..meta.addAll(v.meta))); + + Relationship _toRelationship(NewRelationship r, String id) { + if (r is NewToOne) { + return ToOne(_toIdentifierOrNull(r.identifier, id)); + } + if (r is NewToMany) { + return ToMany(r.map((identifier) => _toIdentifier(identifier, id))); + } + // coverage:ignore-line + throw StateError('Unexpected relationship type: ${r.runtimeType}'); + } + + Identifier? _toIdentifierOrNull(NewIdentifier? identifier, String id) { + if (identifier == null) return null; + return _toIdentifier(identifier, id); + } + + Identifier _toIdentifier(NewIdentifier identifier, String id) { + switch (identifier) { + case Identifier(): + return identifier; + case LocalIdentifier(): + if (identifier.type == type && identifier.lid == lid) { + return identifier.toIdentifier(id); + } + throw StateError( + 'Unmatched local id: "${identifier.lid}". Expected "$lid".'); + } + } } diff --git a/lib/src/document/new_to_many.dart b/lib/src/document/new_to_many.dart new file mode 100644 index 0000000..7d8da3d --- /dev/null +++ b/lib/src/document/new_to_many.dart @@ -0,0 +1,19 @@ +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/new_relationship.dart'; + +class NewToMany extends NewRelationship { + NewToMany(Iterable identifiers) { + _ids.addAll(identifiers); + } + + final _ids = []; + + @override + Map toJson() => { + 'data': [..._ids], + ...super.toJson() + }; + + @override + Iterator get iterator => _ids.iterator; +} diff --git a/lib/src/document/new_to_one.dart b/lib/src/document/new_to_one.dart new file mode 100644 index 0000000..aefcd46 --- /dev/null +++ b/lib/src/document/new_to_one.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/new_relationship.dart'; + +class NewToOne extends NewRelationship { + NewToOne(this.identifier); + + NewToOne.empty() : this(null); + + @override + Map toJson() => {'data': identifier, ...super.toJson()}; + + final NewIdentifier? identifier; + + @override + Iterator get iterator => + identifier == null ? super.iterator : [identifier!].iterator; +} diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart deleted file mode 100644 index 99680bd..0000000 --- a/lib/src/document/one.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_collection.dart'; - -class ToOne extends Relationship { - ToOne(this.identifier); - - ToOne.empty() : this(null); - - @override - Map toJson() => {'data': identifier, ...super.toJson()}; - - final Identifier? identifier; - - @override - Iterator get iterator => - identifier == null ? [].iterator : [identifier!].iterator; - - /// Finds the referenced resource in the [collection]. - Resource? findIn(ResourceCollection collection) => - collection[identifier?.key]; -} diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index a74d431..78192f6 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -1,9 +1,9 @@ import 'package:json_api/src/document/error_object.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/many.dart'; import 'package:json_api/src/document/new_resource.dart'; -import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/to_many.dart'; +import 'package:json_api/src/document/to_one.dart'; /// A sever-to-client document. class OutboundDocument { diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 2eecb4a..3526ffc 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,7 +1,7 @@ import 'dart:collection'; -import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/new_identifier.dart'; class Relationship with IterableMixin { final links = {}; @@ -13,5 +13,5 @@ class Relationship with IterableMixin { }; @override - Iterator get iterator => const [].iterator; + Iterator get iterator => [].iterator; } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 1b775dc..b9ca5f7 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,18 +1,50 @@ -import 'package:json_api/src/document/identity.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_properties.dart'; +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/to_many.dart'; +import 'package:json_api/src/document/to_one.dart'; -class Resource with ResourceProperties, Identity { +class Resource { Resource(this.type, this.id); - @override + /// Resource type. final String type; - @override + + /// Resource id. final String id; /// Resource links final links = {}; + /// Resource meta data. + final meta = {}; + + /// Resource attributes. + /// + /// See https://jsonapi.org/format/#document-resource-object-attributes + final attributes = {}; + + /// Resource relationships. + /// + /// See https://jsonapi.org/format/#document-resource-object-relationships + final relationships = {}; + + /// Creates a new [Identifier] for this resource. + Identifier toIdentifier() => Identifier(type, id); + + /// Returns a to-one relationship by its [name]. + ToOne? one(String name) => _rel(name); + + /// Returns a to-many relationship by its [name]. + ToMany? many(String name) => _rel(name); + + /// Returns a typed relationship by its [name]. + R? _rel(String name) { + final r = relationships[name]; + if (r is R) return r; + return null; + } + Map toJson() => { 'type': type, 'id': id, diff --git a/lib/src/document/resource_collection.dart b/lib/src/document/resource_collection.dart deleted file mode 100644 index 95533d8..0000000 --- a/lib/src/document/resource_collection.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/document.dart'; - -/// A collection of resources indexed by key. -class ResourceCollection with IterableMixin { - final _map = {}; - - Resource? operator [](Object? key) => _map[key]; - - void add(Resource resource) { - _map[resource.key] = resource; - } - - void addAll(Iterable resources) { - resources.forEach(add); - } - - @override - Iterator get iterator => _map.values.iterator; -} diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart deleted file mode 100644 index ef522fb..0000000 --- a/lib/src/document/resource_properties.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:json_api/src/document/many.dart'; -import 'package:json_api/src/document/one.dart'; -import 'package:json_api/src/document/relationship.dart'; - -mixin ResourceProperties { - /// Resource meta data. - final meta = {}; - - /// Resource attributes. - /// - /// See https://jsonapi.org/format/#document-resource-object-attributes - final attributes = {}; - - /// Resource relationships. - /// - /// See https://jsonapi.org/format/#document-resource-object-relationships - final relationships = {}; - - /// Returns a to-one relationship by its [name]. - ToOne? one(String name) => _rel(name); - - /// Returns a to-many relationship by its [name]. - ToMany? many(String name) => _rel(name); - - /// Returns a typed relationship by its [name]. - R? _rel(String name) { - final r = relationships[name]; - if (r is R) return r; - return null; - } -} diff --git a/lib/src/document/to_many.dart b/lib/src/document/to_many.dart new file mode 100644 index 0000000..902b1ff --- /dev/null +++ b/lib/src/document/to_many.dart @@ -0,0 +1,19 @@ +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/relationship.dart'; + +class ToMany extends Relationship { + ToMany(Iterable identifiers) { + _ids.addAll(identifiers); + } + + final _ids = []; + + @override + Map toJson() => { + 'data': [..._ids], + ...super.toJson() + }; + + @override + Iterator get iterator => _ids.iterator; +} diff --git a/lib/src/document/to_one.dart b/lib/src/document/to_one.dart new file mode 100644 index 0000000..ae61979 --- /dev/null +++ b/lib/src/document/to_one.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/document/new_identifier.dart'; +import 'package:json_api/src/document/relationship.dart'; + +class ToOne extends Relationship { + ToOne(this.identifier); + + ToOne.empty() : this(null); + + @override + Map toJson() => {'data': identifier, ...super.toJson()}; + + final Identifier? identifier; + + @override + Iterator get iterator => + identifier == null ? super.iterator : [identifier!].iterator; +} diff --git a/lib/src/http/http_response_ext.dart b/lib/src/http/http_response_ext.dart deleted file mode 100644 index 6259810..0000000 --- a/lib/src/http/http_response_ext.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:http_interop/http_interop.dart' as interop; -import 'package:json_api/src/http/media_type.dart'; -import 'package:json_api/src/http/status_code.dart'; - -/// The response sent by the server and received by the client -extension HttpResponseExt on interop.HttpResponse { - /// True if the body is not empty and the Content-Type - /// is `application/vnd.api+json` - bool get hasDocument => - body.isNotEmpty && - (headers['Content-Type'] ?? '').toLowerCase().startsWith(mediaType); - - /// Returns true if the [statusCode] represents a failure - bool get isFailed => StatusCode(statusCode).isFailed; -} diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart deleted file mode 100644 index c59ca8d..0000000 --- a/lib/src/http/status_code.dart +++ /dev/null @@ -1,23 +0,0 @@ -class StatusCode { - const StatusCode(this.value); - - static const ok = 200; - static const created = 201; - static const accepted = 202; - static const noContent = 204; - static const badRequest = 400; - static const notFound = 404; - static const methodNotAllowed = 405; - - final int value; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => value == accepted; - - /// True for successfully processed requests - bool get isSuccessful => value >= ok && value < 300 && !isPending; - - /// True for failed requests (i.e. neither successful nor pending) - bool get isFailed => !isSuccessful && !isPending; -} diff --git a/lib/src/http/media_type.dart b/lib/src/media_type.dart similarity index 100% rename from lib/src/http/media_type.dart rename to lib/src/media_type.dart diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart index f6dfb1e..baea320 100644 --- a/lib/src/nullable.dart +++ b/lib/src/nullable.dart @@ -1,2 +1,3 @@ +/// Nullable function application. U? Function(V? v) nullable(U Function(V v) f) => (v) => v == null ? null : f(v); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 05d99b1..0291e86 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -1,8 +1,10 @@ import 'dart:collection'; +import 'package:json_api/src/query/query_encodable.dart'; + /// Query parameters defining Sparse Fieldsets /// @see https://jsonapi.org/format/#fetching-sparse-fieldsets -class Fields with MapMixin> { +class Fields with MapMixin> implements QueryEncodable { /// The [fields] argument maps the resource type to a list of fields. /// /// Example: @@ -25,8 +27,9 @@ class Fields with MapMixin> { final _map = >{}; /// Converts to a map of query parameters - Map get asQueryParameters => - _map.map((k, v) => MapEntry('fields[$k]', v.join(','))); + @override + Map> toQuery() => + _map.map((k, v) => MapEntry('fields[$k]', [v.join(',')])); @override void operator []=(String key, Iterable value) => _map[key] = value; diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart deleted file mode 100644 index 4338740..0000000 --- a/lib/src/query/filter.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:collection'; - -class Filter with MapMixin { - /// Example: - /// ```dart - /// Filter({'post': '1,2', 'author': '12'}).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?filter[post]=1,2&filter[author]=12 - /// ``` - Filter([Map parameters = const {}]) { - addAll(parameters); - } - - static Filter fromUri(Uri uri) => Filter(uri.queryParametersAll - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) - ..removeWhere((k, v) => k.isEmpty)); - - static final _regex = RegExp(r'^filter\[(.+)\]$'); - - final _ = {}; - - /// Converts to a map of query parameters - Map get asQueryParameters => - _.map((k, v) => MapEntry('filter[$k]', v)); - - @override - String? operator [](Object? key) => _[key]; - - @override - void operator []=(String key, String value) => _[key] = value; - - @override - void clear() => _.clear(); - - @override - Iterable get keys => _.keys; - - @override - String? remove(Object? key) => _.remove(key); -} diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index 8b25bdb..8d26b23 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -1,8 +1,10 @@ import 'dart:collection'; +import 'package:json_api/src/query/query_encodable.dart'; + /// Query parameter defining inclusion of related resources. /// @see https://jsonapi.org/format/#fetching-includes -class Include with IterableMixin { +class Include with IterableMixin implements QueryEncodable { /// Example: /// ```dart /// Include(['comments', 'comments.author']); @@ -17,8 +19,10 @@ class Include with IterableMixin { final _ = []; /// Converts to a map of query parameters - Map get asQueryParameters => - {if (isNotEmpty) 'include': join(',')}; + @override + Map> toQuery() => { + if (isNotEmpty) 'include': [join(',')] + }; @override Iterator get iterator => _.iterator; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index d4f441e..e00ceea 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -1,8 +1,10 @@ import 'dart:collection'; +import 'package:json_api/src/query/query_encodable.dart'; + /// Query parameters defining the pagination data. /// @see https://jsonapi.org/format/#fetching-pagination -class Page with MapMixin { +class Page with MapMixin implements QueryEncodable { /// Example: /// ```dart /// Page({'limit': '10', 'offset': '20'}).addTo(url); @@ -24,8 +26,9 @@ class Page with MapMixin { final _ = {}; /// Converts to a map of query parameters - Map get asQueryParameters => - _.map((k, v) => MapEntry('page[$k]', v)); + @override + Map> toQuery() => + _.map((k, v) => MapEntry('page[$k]', [v])); @override String? operator [](Object? key) => _[key]; diff --git a/lib/src/query/query.dart b/lib/src/query/query.dart new file mode 100644 index 0000000..f4356e4 --- /dev/null +++ b/lib/src/query/query.dart @@ -0,0 +1,42 @@ +import 'package:json_api/src/query/query_encodable.dart'; + +/// Arbitrary query parameters. +class Query implements QueryEncodable { + Query([Map> parameters = const {}]) { + mergeMap(parameters); + } + + final _parameters = >{}; + + /// Returns true if the collection is empty. + get isEmpty => _parameters.isEmpty; + + /// Adds a new [value] for the [key]. + void addValue(String key, String value) { + _parameters.putIfAbsent(key, () => []); + _parameters[key]!.add(value); + } + + /// Merges the query [parameters] into this object. + void mergeMap(Map> parameters) { + parameters.forEach((key, values) { + for (final value in values) { + addValue(key, value); + } + }); + } + + /// Merges parameters from another [encodable] into this object. + void merge(QueryEncodable encodable) { + mergeMap(encodable.toQuery()); + } + + /// Merges parameters from other [encodables] into this object. + void mergeAll(Iterable encodables) { + encodables.forEach(merge); + } + + @override + Map> toQuery() => + _parameters.map((key, value) => MapEntry(key, value.toList())); +} diff --git a/lib/src/query/query_encodable.dart b/lib/src/query/query_encodable.dart new file mode 100644 index 0000000..055cead --- /dev/null +++ b/lib/src/query/query_encodable.dart @@ -0,0 +1,7 @@ +/// An object which cab be represented as URI query parameters. +abstract class QueryEncodable { + /// Returns the map representing query parameters. + /// Each key may have zero or more values. + /// `{'foo': ['bar', 'baz']}` represents `?foo=bar&foo=baz` + Map> toQuery(); +} diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 85200cc..e75f631 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -1,8 +1,10 @@ import 'dart:collection'; +import 'package:json_api/src/query/query_encodable.dart'; + /// Query parameters defining the sorting. /// @see https://jsonapi.org/format/#fetching-sorting -class Sort with IterableMixin { +class Sort with IterableMixin implements QueryEncodable { /// The [fields] arguments is the list of sorting criteria. /// /// Example: @@ -19,8 +21,10 @@ class Sort with IterableMixin { final _ = []; /// Converts to a map of query parameters - Map get asQueryParameters => - {if (isNotEmpty) 'sort': join(',')}; + @override + Map> toQuery() => { + if (isNotEmpty) 'sort': [join(',')] + }; @override int get length => _.length; diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 9759d0b..6936003 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,41 +1,43 @@ -import 'package:json_api/http.dart'; +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/routing.dart'; /// JSON:API controller abstract class Controller { /// Fetch a primary resource collection - Future fetchCollection(HttpRequest request, Target target); + Future fetchCollection(http.Request request, Target target); /// Create resource - Future createResource(HttpRequest request, Target target); + Future createResource(http.Request request, Target target); /// Fetch a single primary resource - Future fetchResource( - HttpRequest request, ResourceTarget target); + Future fetchResource( + http.Request request, ResourceTarget target); /// Updates a primary resource - Future updateResource( - HttpRequest request, ResourceTarget target); + Future updateResource( + http.Request request, ResourceTarget target); /// Deletes the primary resource - Future deleteResource( - HttpRequest request, ResourceTarget target); + Future deleteResource( + http.Request request, ResourceTarget target); /// Fetches a relationship - Future fetchRelationship( - HttpRequest rq, RelationshipTarget target); + Future fetchRelationship( + http.Request rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany(HttpRequest request, RelationshipTarget target); + Future addMany( + http.Request request, RelationshipTarget target); /// Updates the relationship - Future replaceRelationship( - HttpRequest request, RelationshipTarget target); + Future replaceRelationship( + http.Request request, RelationshipTarget target); /// Deletes the members from the to-many relationship - Future deleteMany( - HttpRequest request, RelationshipTarget target); + Future deleteMany( + http.Request request, RelationshipTarget target); /// Fetches related resource or collection - Future fetchRelated(HttpRequest request, RelatedTarget target); + Future fetchRelated( + http.Request request, RelatedTarget target); } diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart new file mode 100644 index 0000000..3f45e37 --- /dev/null +++ b/lib/src/server/controller_router.dart @@ -0,0 +1,80 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/unacceptable.dart'; +import 'package:json_api/src/server/errors/unmatched_target.dart'; +import 'package:json_api/src/server/errors/unsupported_media_type.dart'; + +class ControllerRouter implements Handler { + ControllerRouter(this._controller, this._matchTarget); + + final Controller _controller; + final Target? Function(Uri uri) _matchTarget; + + @override + Future handle(Request request) async { + _validate(request); + final target = _matchTarget(request.uri); + if (target is RelationshipTarget) { + if (request.method.equals('GET')) { + return await _controller.fetchRelationship(request, target); + } + if (request.method.equals('POST')) { + return await _controller.addMany(request, target); + } + if (request.method.equals('PATCH')) { + return await _controller.replaceRelationship(request, target); + } + if (request.method.equals('DELETE')) { + return await _controller.deleteMany(request, target); + } + throw MethodNotAllowed(request.method.value); + } + if (target is RelatedTarget) { + if (request.method.equals('GET')) { + return await _controller.fetchRelated(request, target); + } + throw MethodNotAllowed(request.method.value); + } + if (target is ResourceTarget) { + if (request.method.equals('GET')) { + return await _controller.fetchResource(request, target); + } + if (request.method.equals('PATCH')) { + return await _controller.updateResource(request, target); + } + if (request.method.equals('DELETE')) { + return await _controller.deleteResource(request, target); + } + throw MethodNotAllowed(request.method.value); + } + if (target is Target) { + if (request.method.equals('GET')) { + return await _controller.fetchCollection(request, target); + } + if (request.method.equals('POST')) { + return await _controller.createResource(request, target); + } + throw MethodNotAllowed(request.method.value); + } + throw UnmatchedTarget(request.uri); + } + + void _validate(Request request) { + final contentType = request.headers.last('Content-Type'); + if (contentType != null && !_isValid(MediaType.parse(contentType))) { + throw UnsupportedMediaType(); + } + final accept = request.headers.last('Accept'); + if (accept != null && !_isValid(MediaType.parse(accept))) { + throw Unacceptable(); + } + } + + bool _isValid(MediaType mediaType) { + return mediaType.parameters.isEmpty; // TODO: check for ext and profile + } +} diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart new file mode 100644 index 0000000..b1d9126 --- /dev/null +++ b/lib/src/server/error_converter.dart @@ -0,0 +1,70 @@ +import 'package:http_interop/http_interop.dart' as http; +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/errors/collection_not_found.dart'; +import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/relationship_not_found.dart'; +import 'package:json_api/src/server/errors/resource_not_found.dart'; +import 'package:json_api/src/server/errors/unacceptable.dart'; +import 'package:json_api/src/server/errors/unmatched_target.dart'; +import 'package:json_api/src/server/errors/unsupported_media_type.dart'; +import 'package:json_api/src/server/response.dart'; + +/// The error converter maps server exceptions to JSON:API responses. +/// It is designed to be used with the TryCatchHandler from the `json_api:http` +/// package and provides some meaningful defaults out of the box. +class ErrorConverter { + ErrorConverter({ + this.onMethodNotAllowed, + this.onUnmatchedTarget, + this.onCollectionNotFound, + this.onResourceNotFound, + this.onRelationshipNotFound, + this.onError, + }); + + final Future Function(MethodNotAllowed)? onMethodNotAllowed; + final Future Function(UnmatchedTarget)? onUnmatchedTarget; + final Future Function(CollectionNotFound)? + onCollectionNotFound; + final Future Function(ResourceNotFound)? onResourceNotFound; + final Future Function(RelationshipNotFound)? + onRelationshipNotFound; + final Future Function(dynamic, StackTrace)? onError; + + Future call(Object? error, StackTrace trace) async => + switch (error) { + MethodNotAllowed() => + await onMethodNotAllowed?.call(error) ?? Response.methodNotAllowed(), + UnmatchedTarget() => + await onUnmatchedTarget?.call(error) ?? Response.badRequest(), + CollectionNotFound() => await onCollectionNotFound?.call(error) ?? + Response.notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Collection Not Found', + detail: 'Type: ${error.type}', + ) + ])), + ResourceNotFound() => await onResourceNotFound?.call(error) ?? + Response.notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Resource Not Found', + detail: 'Type: ${error.type}, id: ${error.id}', + ) + ])), + RelationshipNotFound() => await onRelationshipNotFound?.call(error) ?? + Response.notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Relationship Not Found', + detail: 'Type: ${error.type}' + ', id: ${error.id}' + ', relationship: ${error.relationship}', + ) + ])), + UnsupportedMediaType() => Response.unsupportedMediaType(), + Unacceptable() => Response.unacceptable(), + _ => await onError?.call(error, trace) ?? + Response(500, + document: OutboundErrorDocument( + [ErrorObject(title: 'Internal Server Error')])) + }; +} diff --git a/lib/src/server/errors/collection_not_found.dart b/lib/src/server/errors/collection_not_found.dart new file mode 100644 index 0000000..c6110e1 --- /dev/null +++ b/lib/src/server/errors/collection_not_found.dart @@ -0,0 +1,7 @@ +/// A collection is not found on the server. +class CollectionNotFound implements Exception { + CollectionNotFound(this.type); + + /// Collection type. + final String type; +} diff --git a/lib/src/server/errors/relationship_not_found.dart b/lib/src/server/errors/relationship_not_found.dart new file mode 100644 index 0000000..9a2ae1d --- /dev/null +++ b/lib/src/server/errors/relationship_not_found.dart @@ -0,0 +1,8 @@ +/// A relationship is not found on the server. +class RelationshipNotFound implements Exception { + RelationshipNotFound(this.type, this.id, this.relationship); + + final String type; + final String id; + final String relationship; +} diff --git a/lib/src/server/errors/resource_not_found.dart b/lib/src/server/errors/resource_not_found.dart new file mode 100644 index 0000000..6848d9f --- /dev/null +++ b/lib/src/server/errors/resource_not_found.dart @@ -0,0 +1,7 @@ +/// A resource is not found on the server. +class ResourceNotFound implements Exception { + ResourceNotFound(this.type, this.id); + + final String type; + final String id; +} diff --git a/lib/src/server/errors/unacceptable.dart b/lib/src/server/errors/unacceptable.dart new file mode 100644 index 0000000..ac6d685 --- /dev/null +++ b/lib/src/server/errors/unacceptable.dart @@ -0,0 +1 @@ +class Unacceptable implements Exception {} diff --git a/lib/src/server/errors/unsupported_media_type.dart b/lib/src/server/errors/unsupported_media_type.dart new file mode 100644 index 0000000..8fde40a --- /dev/null +++ b/lib/src/server/errors/unsupported_media_type.dart @@ -0,0 +1 @@ +class UnsupportedMediaType implements Exception {} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 90d092e..b15084c 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,22 +1,24 @@ import 'dart:convert'; +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/media_type.dart'; /// JSON:API response -class Response extends HttpResponse { - Response(int statusCode, {this.document}) : super(statusCode) { +class Response extends http.Response { + Response(int statusCode, {D? document}) + : super( + statusCode, + document != null + ? http.Body(jsonEncode(document), utf8) + : http.Body.empty(), + http.Headers({})) { if (document != null) { - headers['Content-Type'] = mediaType; + headers['Content-Type'] = [mediaType]; } } - final D? document; - - @override - String get body => nullable(jsonEncode)(document) ?? ''; - static Response ok(OutboundDocument document) => Response(StatusCode.ok, document: document); @@ -24,7 +26,7 @@ class Response extends HttpResponse { static Response created(OutboundDocument document, String location) => Response(StatusCode.created, document: document) - ..headers['location'] = location; + ..headers['location'] = [location]; static Response notFound([OutboundErrorDocument? document]) => Response(StatusCode.notFound, document: document); @@ -34,4 +36,10 @@ class Response extends HttpResponse { static Response badRequest([OutboundErrorDocument? document]) => Response(StatusCode.badRequest, document: document); + + static Response unsupportedMediaType([OutboundErrorDocument? document]) => + Response(StatusCode.unsupportedMediaType, document: document); + + static Response unacceptable([OutboundErrorDocument? document]) => + Response(StatusCode.unacceptable, document: document); } diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart deleted file mode 100644 index aeb29b7..0000000 --- a/lib/src/server/router.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/errors/method_not_allowed.dart'; -import 'package:json_api/src/server/errors/unmatched_target.dart'; - -class Router implements HttpHandler { - Router(this._controller, this._matchTarget); - - final Controller _controller; - final Target? Function(Uri uri) _matchTarget; - - @override - Future handle(HttpRequest request) async { - final target = _matchTarget(request.uri); - if (target is RelationshipTarget) { - if (request.isGet) { - return await _controller.fetchRelationship(request, target); - } - if (request.isPost) { - return await _controller.addMany(request, target); - } - if (request.isPatch) { - return await _controller.replaceRelationship(request, target); - } - if (request.isDelete) { - return await _controller.deleteMany(request, target); - } - throw MethodNotAllowed(request.method); - } - if (target is RelatedTarget) { - if (request.isGet) { - return await _controller.fetchRelated(request, target); - } - throw MethodNotAllowed(request.method); - } - if (target is ResourceTarget) { - if (request.isGet) { - return await _controller.fetchResource(request, target); - } - if (request.isPatch) { - return await _controller.updateResource(request, target); - } - if (request.isDelete) { - return await _controller.deleteResource(request, target); - } - throw MethodNotAllowed(request.method); - } - if (target is Target) { - if (request.isGet) { - return await _controller.fetchCollection(request, target); - } - if (request.isPost) { - return await _controller.createResource(request, target); - } - throw MethodNotAllowed(request.method); - } - throw UnmatchedTarget(request.uri); - } -} diff --git a/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart new file mode 100644 index 0000000..312d1ea --- /dev/null +++ b/lib/src/server/try_catch_handler.dart @@ -0,0 +1,26 @@ +import 'package:http_interop/http_interop.dart'; + +/// An [Handler] wrapper which calls the [wrapped] handler and does +/// the following: +/// - when an instance of [Response] is returned or thrown by the +/// [wrapped] handler, the response is returned +/// - when another error is thrown by the [wrapped] handler and +/// the [onError] callback is set, the error will be converted to a response +/// - otherwise the error will be rethrown. +class TryCatchHandler implements Handler { + TryCatchHandler(this.wrapped, {this.onError}); + + final Handler wrapped; + final Future Function(dynamic, StackTrace)? onError; + + @override + Future handle(Request request) async { + try { + return await wrapped.handle(request); + } on Response catch (response) { + return response; + } catch (error, stacktrace) { + return await onError?.call(error, stacktrace) ?? (throw error); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 18d2116..a3a3cde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,24 +1,25 @@ name: json_api -version: 5.4.0 +version: 6.0.0 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) environment: - sdk: '>=2.19.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: - http: ^0.13.4 http_parser: ^4.0.0 - http_interop: ^0.1.0 - http_interop_http: ^0.1.1 + http_interop: ^0.8.0 dev_dependencies: - lints: ^1.0.1 + lints: ^2.1.1 test: ^1.21.1 stream_channel: ^2.1.0 uuid: ^3.0.0 coverage: ^1.3.0 check_coverage: ^0.0.4 - http_interop_io: ^0.1.0 + http: ^1.1.0 + http_interop_http: ^0.6.0 + http_interop_io: ^0.6.0 + cider: link_template: diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index a2a2177..4d3e32b 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,16 +1,16 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../../example/server/demo_handler.dart'; +import '../test_handler.dart'; void main() { late RoutingClient client; setUp(() async { - client = RoutingClient(StandardUriDesign.pathOnly, - client: Client(handler: DemoHandler())); + client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler())); }); group('CRUD', () { @@ -27,35 +27,37 @@ void main() { .resource; post = (await client.createNew('posts', attributes: {'title': 'Hello world'}, - one: {'author': Identifier.of(alice)}, + one: {'author': alice.toIdentifier()}, many: {'comments': []})) .resource; comment = (await client.createNew('comments', attributes: {'text': 'Hi Alice'}, - one: {'author': Identifier.of(bob)})) + one: {'author': bob.toIdentifier()})) .resource; secretComment = (await client.createNew('comments', attributes: {'text': 'Secret comment'}, - one: {'author': Identifier.of(bob)})) + one: {'author': bob.toIdentifier()})) .resource; await client - .addMany(post.type, post.id, 'comments', [Identifier.of(comment)]); + .addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); }); test('Fetch a complex resource', () async { - final response = await client.fetchCollection('posts', - include: ['author', 'comments', 'comments.author']); + final response = await client.fetchCollection('posts', query: [ + Include(['author', 'comments', 'comments.author']) + ]); - expect(response.http.statusCode, 200); + expect(response.httpResponse.statusCode, 200); expect(response.collection.length, 1); expect(response.included.length, 3); final fetchedPost = response.collection.first; expect(fetchedPost.attributes['title'], 'Hello world'); - final fetchedAuthor = - fetchedPost.one('author')!.findIn(response.included); - expect(fetchedAuthor?.attributes['name'], 'Alice'); + final fetchedAuthor = response.included + .where(fetchedPost.one('author')!.identifier!.identifies) + .single; + expect(fetchedAuthor.attributes['name'], 'Alice'); final fetchedComment = fetchedPost.many('comments')!.findIn(response.included).single; @@ -93,37 +95,43 @@ void main() { test('Fetch a to-one relationship', () async { await client.fetchToOne(post.type, post.id, 'author').then((r) { - expect(Identity.same(r.relationship.identifier!, alice), isTrue); + expect(r.relationship.identifier!.identifies(alice), isTrue); }); }); test('Fetch a to-many relationship', () async { await client.fetchToMany(post.type, post.id, 'comments').then((r) { - expect(Identity.same(r.relationship.single, comment), isTrue); + expect(r.relationship.single.identifies(comment), isTrue); }); }); test('Delete a to-one relationship', () async { await client.deleteToOne(post.type, post.id, 'author'); - await client - .fetchResource(post.type, post.id, include: ['author']).then((r) { + await client.fetchResource(post.type, post.id, query: [ + Include(['author']) + ]).then((r) { expect(r.resource.one('author'), isEmpty); }); }); test('Replace a to-one relationship', () async { await client.replaceToOne( - post.type, post.id, 'author', Identifier.of(bob)); - await client - .fetchResource(post.type, post.id, include: ['author']).then((r) { - expect(r.resource.one('author')?.findIn(r.included)?.attributes['name'], + post.type, post.id, 'author', bob.toIdentifier()); + await client.fetchResource(post.type, post.id, query: [ + Include(['author']) + ]).then((r) { + expect( + r.included + .where(r.resource.one('author')!.identifier!.identifies) + .single + .attributes['name'], 'Bob'); }); }); test('Delete from a to-many relationship', () async { await client.deleteFromMany( - post.type, post.id, 'comments', [Identifier.of(comment)]); + post.type, post.id, 'comments', [comment.toIdentifier()]); await client.fetchResource(post.type, post.id).then((r) { expect(r.resource.many('comments'), isEmpty); }); @@ -131,9 +139,10 @@ void main() { test('Replace a to-many relationship', () async { await client.replaceToMany( - post.type, post.id, 'comments', [Identifier.of(secretComment)]); - await client - .fetchResource(post.type, post.id, include: ['comments']).then((r) { + post.type, post.id, 'comments', [secretComment.toIdentifier()]); + await client.fetchResource(post.type, post.id, query: [ + Include(['comments']) + ]).then((r) { expect( r.resource .many('comments')! @@ -166,9 +175,23 @@ void main() { await action(); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); + expect(e.httpResponse.statusCode, 404); } } }); }); } + +extension _ToManyExt on ToMany { + /// Finds the referenced elements which are found in the [collection]. + /// The resulting [Iterable] may contain fewer elements than referred by the + /// relationship if the [collection] does not have all of them. + Iterable findIn(Iterable collection) => collection.where( + (resource) => any((identifier) => identifier.identifies(resource))); +} + +extension _IdentifierExt on Identifier { + /// True if this identifier identifies the [resource]. + bool identifies(Resource resource) => + type == resource.type && id == resource.id; +} diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index d399bcb..db60cff 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,14 +1,14 @@ +import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; import 'package:test/test.dart'; -import '../../example/server/demo_handler.dart'; +import '../test_handler.dart'; void main() { late Client client; setUp(() async { - client = Client(handler: DemoHandler()); + client = Client(TestHandler()); }); group('Errors', () { @@ -22,13 +22,35 @@ void main() { ]; for (final action in actions) { final response = await action(); - expect(response.http.statusCode, 405); + expect(response.httpResponse.statusCode, 405); } }); test('Bad request when target can not be matched', () async { - final r = await DemoHandler() - .handle(HttpRequest('get', Uri.parse('/a/long/prefix/'))); + final r = await TestHandler().handle(http.Request(http.Method('get'), + Uri.parse('/a/long/prefix/'), http.Body.empty(), http.Headers({}))); expect(r.statusCode, 400); }); + test('Unsupported extension', () async { + final r = await TestHandler().handle(http.Request( + http.Method('get'), + Uri.parse('/posts/1'), + http.Body.empty(), + http.Headers({ + 'Content-Type': ['application/vnd.api+json; ext=foobar'], + 'Accept': ['application/vnd.api+json'] + }))); + expect(r.statusCode, 415); + }); + test('Unacceptable', () async { + final r = await TestHandler().handle(http.Request( + http.Method('get'), + Uri.parse('/posts/1'), + http.Body.empty(), + http.Headers({ + 'Content-Type': ['application/vnd.api+json'], + 'Accept': ['application/vnd.api+json; ext=foobar'] + }))); + expect(r.statusCode, 406); + }); }); } diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index e1b0990..a48d2b9 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,35 +1,50 @@ import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../../example/server/demo_handler.dart'; +import '../test_handler.dart'; void main() { late RoutingClient client; setUp(() async { - client = RoutingClient(StandardUriDesign.pathOnly, - client: Client(handler: DemoHandler())); + client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler())); }); group('Resource creation', () { test('Resource id assigned on the server', () async { await client .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { - expect(r.http.statusCode, 201); - expect(r.http.headers['location'], '/posts/${r.resource.id}'); + expect(r.httpResponse.statusCode, 201); + expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']); expect(r.links['self'].toString(), '/posts/${r.resource.id}'); expect(r.resource.type, 'posts'); expect(r.resource.attributes['title'], 'Hello world'); expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); }); }); + + test('Resource id assigned on the server using local id', () async { + await client.createNew('posts', + lid: 'lid', + attributes: {'title': 'Hello world'}, + one: {'self': LocalIdentifier('posts', 'lid')}).then((r) { + expect(r.httpResponse.statusCode, 201); + expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']); + expect(r.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); + expect(r.resource.attributes['title'], 'Hello world'); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); + }); + }); + test('Resource id assigned on the client', () async { await client.create('posts', '12345', attributes: {'title': 'Hello world'}).then((r) { - expect(r.http.statusCode, 204); + expect(r.httpResponse.statusCode, 204); expect(r.resource, isNull); - expect(r.http.headers['location'], isNull); + expect(r.httpResponse.headers['location'], isNull); }); }); }); diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index a33c45e..21de2ef 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,3 +1,4 @@ +import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -6,15 +7,16 @@ import 'e2e_test_set.dart'; void main() { late RoutingClient client; + group('On Browser', () { + setUpAll(() async { + final channel = spawnHybridUri('hybrid_server.dart'); + final serverUrl = await channel.stream.first; - setUp(() async { - final channel = spawnHybridUri('hybrid_server.dart'); - final serverUrl = await channel.stream.first; + client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())), + Client(OneOffHandler())); + }); - client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString()))); - }); - - test('On Browser', () async { - await e2eTests(client); + testLocationIsSet(() => client); + testAllHttpMethods(() => client); }, testOn: 'browser'); } diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index 04f63be..4d7a3fd 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -1,34 +1,36 @@ import 'package:json_api/client.dart'; import 'package:test/test.dart'; -Future e2eTests(RoutingClient client) async { - await _testAllHttpMethods(client); - await _testLocationIsSet(client); -} - -Future _testAllHttpMethods(RoutingClient client) async { +Future testAllHttpMethods(RoutingClient Function() client) async { final id = '12345'; - // POST - await client.create('posts', id, attributes: {'title': 'Hello world'}); - // GET - await client.fetchResource('posts', id).then((r) { - expect(r.resource.attributes['title'], 'Hello world'); + test('POST', () async { + await client().create('posts', id, attributes: {'title': 'Hello world'}); + }); + test('GET', () async { + await client().fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Hello world'); + }); }); - // PATCH - await client.updateResource('posts', id, attributes: {'title': 'Bye world'}); - await client.fetchResource('posts', id).then((r) { - expect(r.resource.attributes['title'], 'Bye world'); + test('PATCH', () async { + await client() + .updateResource('posts', id, attributes: {'title': 'Bye world'}); + await client().fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Bye world'); + }); }); - // DELETE - await client.deleteResource('posts', id); - await client.fetchCollection('posts').then((r) { - expect(r.collection, isEmpty); + test('DELETE', () async { + await client().deleteResource('posts', id); + await client().fetchCollection('posts').then((r) { + expect(r.collection, isEmpty); + }); }); } -Future _testLocationIsSet(RoutingClient client) async { - await client - .createNew('posts', attributes: {'title': 'Location test'}).then((r) { - expect(r.http.headers['Location'], isNotEmpty); +void testLocationIsSet(RoutingClient Function() client) { + test('Location is set', () async { + final r = await client() + .createNew('posts', attributes: {'title': 'Location test'}); + expect(r.httpResponse.headers['Location'], isNotEmpty); + await client().deleteResource('posts', r.resource.id); }); } diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 578430f..ff36393 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,12 +1,12 @@ import 'package:stream_channel/stream_channel.dart'; -import '../../example/server/demo_handler.dart'; import '../../example/server/json_api_server.dart'; +import '../test_handler.dart'; void hybridMain(StreamChannel channel, Object message) async { final host = 'localhost'; final port = 8000; - final server = JsonApiServer(DemoHandler(), host: host, port: port); + final server = JsonApiServer(TestHandler(), host: host, port: port); await server.start(); channel.sink.add('http://$host:$port'); } diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 1c3d98f..d644f35 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -1,27 +1,31 @@ +import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../../example/server/demo_handler.dart'; import '../../example/server/json_api_server.dart'; +import '../test_handler.dart'; import 'e2e_test_set.dart'; void main() { late RoutingClient client; late JsonApiServer server; - setUp(() async { - server = JsonApiServer(DemoHandler(), port: 8001); - await server.start(); - client = RoutingClient(StandardUriDesign( - Uri(scheme: 'http', host: server.host, port: server.port))); - }); + group('On VM', () { + setUpAll(() async { + server = JsonApiServer(TestHandler(), port: 8001); + await server.start(); + client = RoutingClient( + StandardUriDesign( + Uri(scheme: 'http', host: server.host, port: server.port)), + Client(OneOffHandler())); + }); - tearDown(() async { - await server.stop(); - }); + tearDownAll(() async { + await server.stop(); + }); - test('On VM', () async { - await e2eTests(client); + testLocationIsSet(() => client); + testAllHttpMethods(() => client); }, testOn: 'vm'); } diff --git a/test/test_handler.dart b/test/test_handler.dart new file mode 100644 index 0000000..d26214e --- /dev/null +++ b/test/test_handler.dart @@ -0,0 +1,31 @@ +import 'package:http_interop/http_interop.dart' as http; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; + +import '../example/server/in_memory_repo.dart'; +import '../example/server/repository_controller.dart'; + +class TestHandler extends LoggingHandler { + TestHandler( + {Iterable types = const ['users', 'posts', 'comments'], + Function(http.Request request)? onRequest, + Function(http.Response response)? onResponse, + Future Function(dynamic, StackTrace)? onError}) + : super( + TryCatchHandler( + ControllerRouter(RepositoryController(InMemoryRepo(types), _id), + StandardUriDesign.matchTarget), + onError: ErrorConverter( + onError: onError ?? + (err, trace) { + print(trace); + throw err; + })), + onRequest: onRequest, + onResponse: onResponse); +} + +int _counter = 0; + +String _id() => (_counter++).toString(); diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index c01b518..6fe5120 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'package:http_interop/extensions.dart'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -10,65 +12,65 @@ import 'response.dart' as mock; void main() { final http = MockHandler(); - final client = - RoutingClient(StandardUriDesign.pathOnly, client: Client(handler: http)); + final client = RoutingClient(StandardUriDesign.pathOnly, Client(http)); group('Failure', () { test('RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); expect(e.errors.first.title, 'Invalid Attribute'); } }); test('ServerError', () async { - http.response = mock.error500; + http.response = mock.error500(); try { await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 500); + expect(e.httpResponse.statusCode, 500); } }); }); group('Fetch Collection', () { test('Min', () async { - http.response = mock.collectionMin; + http.response = mock.collectionMin(); final response = await client.fetchCollection('articles'); expect(response.collection.single.type, 'articles'); expect(response.collection.single.id, '1'); expect(response.included, isEmpty); - expect(http.request.method, 'get'); + expect(http.request.method.value, 'get'); expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); }); test('Full', () async { - http.response = mock.collectionFull; + http.response = mock.collectionFull(); final response = await client.fetchCollection('articles', headers: { - 'foo': 'bar' - }, query: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, page: { - 'limit': '10' - }, sort: [ - 'title', - '-date' + 'foo': ['bar'] + }, query: [ + Query({ + 'foo': ['bar'] + }), + Include(['author']), + Fields({ + 'author': ['name'] + }), + Page({'limit': '10'}), + Sort(['title', '-date']) ]); expect(response.collection.length, 1); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.value, 'get'); expect(http.request.uri.path, '/articles'); expect(http.request.uri.queryParameters, { 'include': 'author', @@ -77,44 +79,47 @@ void main() { 'page[limit]': '10', 'foo': 'bar' }); - expect(http.request.headers, - {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'], + 'foo': ['bar'] + }); expect(response.meta, {'hello': 'world'}); }); group('Fetch Related Collection', () { test('Min', () async { - http.response = mock.collectionFull; + http.response = mock.collectionFull(); final response = await client.fetchRelatedCollection('people', '1', 'articles'); expect(response.collection.length, 1); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.path, '/people/1/articles'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); test('Full', () async { - http.response = mock.collectionFull; + http.response = mock.collectionFull(); final response = await client .fetchRelatedCollection('people', '1', 'articles', headers: { - 'foo': 'bar' - }, query: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, page: { - 'limit': '10' - }, sort: [ - 'title', - '-date' + 'foo': ['bar'] + }, query: [ + Query({ + 'foo': ['bar'] + }), + Include(['author']), + Page({'limit': '10'}), + Fields({ + 'author': ['name'] + }), + Sort(['title', '-date']) ]); expect(response.collection.length, 1); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.path, '/people/1/articles'); expect(http.request.uri.queryParameters, { 'include': 'author', @@ -123,8 +128,10 @@ void main() { 'page[limit]': '10', 'foo': 'bar' }); - expect(http.request.headers, - {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'], + 'foo': ['bar'] + }); expect(response.meta, {'hello': 'world'}); }); @@ -132,33 +139,39 @@ void main() { group('Fetch Primary Resource', () { test('Min', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.fetchResource('articles', '1'); expect(response.resource.type, 'articles'); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); test('Full', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.fetchResource('articles', '1', headers: { - 'foo': 'bar' - }, query: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }); + 'foo': ['bar'] + }, query: [ + Query({ + 'foo': ['bar'] + }), + Include(['author']), + Fields({ + 'author': ['name'] + }) + ]); expect(response.resource.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.path, '/articles/1'); expect(http.request.uri.queryParameters, {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); - expect(http.request.headers, - {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'], + 'foo': ['bar'] + }); expect(response.meta, {'hello': 'world'}); }); @@ -166,96 +179,114 @@ void main() { group('Fetch Related Resource', () { test('Min', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); test('Full', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client .fetchRelatedResource('articles', '1', 'author', headers: { - 'foo': 'bar' - }, query: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }); + 'foo': ['bar'] + }, query: [ + Query({ + 'foo': ['bar'] + }), + Include(['author']), + Fields({ + 'author': ['name'] + }) + ]); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.path, '/articles/1/author'); expect(http.request.uri.queryParameters, {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); - expect(http.request.headers, - {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'], + 'foo': ['bar'] + }); expect(response.meta, {'hello': 'world'}); }); test('Missing resource', () async { - http.response = mock.relatedResourceNull; + http.response = mock.relatedResourceNull(); final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource, isNull); expect(response.included, isEmpty); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); }); group('Fetch Relationship', () { test('Min', () async { - http.response = mock.one; + http.response = mock.one(); final response = await client.fetchToOne('articles', '1', 'author'); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'] + }); }); test('Full', () async { - http.response = mock.one; - final response = await client.fetchToOne('articles', '1', 'author', - headers: {'foo': 'bar'}, query: {'foo': 'bar'}); + http.response = mock.one(); + final response = + await client.fetchToOne('articles', '1', 'author', headers: { + 'foo': ['bar'] + }, query: [ + Query({ + 'foo': ['bar'] + }) + ]); expect(response.included.length, 3); - expect(http.request.method, 'get'); + expect(http.request.method.equals('get'), true); expect(http.request.uri.path, '/articles/1/relationships/author'); expect(http.request.uri.queryParameters, {'foo': 'bar'}); - expect(http.request.headers, - {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + expect(http.request.headers, { + 'Accept': ['application/vnd.api+json'], + 'foo': ['bar'] + }); }); }); group('Create New Resource', () { test('Min', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.createNew('articles'); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'articles'} }); }); test('Full', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.createNew('articles', attributes: { 'cool': true }, one: { @@ -267,20 +298,20 @@ void main() { }, documentMeta: { 'hello': 'world' }, headers: { - 'foo': 'bar' + 'foo': ['bar'] }); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'attributes': {'cool': true}, @@ -310,37 +341,37 @@ void main() { group('Create Resource', () { test('Min', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.create('articles', '1'); expect(response.resource?.type, 'articles'); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Min with 204 No Content', () async { - http.response = mock.noContent; + http.response = mock.noContent(); final response = await client.create('articles', '1'); expect(response.resource, isNull); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Full', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.create('articles', '1', attributes: { 'cool': true }, one: { @@ -352,17 +383,17 @@ void main() { }, documentMeta: { 'hello': 'world' }, headers: { - 'foo': 'bar' + 'foo': ['bar'] }); expect(response.resource?.type, 'articles'); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'id': '1', @@ -393,37 +424,37 @@ void main() { group('Update Resource', () { test('Min', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.updateResource('articles', '1'); expect(response.resource?.type, 'articles'); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Min with 204 No Content', () async { - http.response = mock.noContent; + http.response = mock.noContent(); final response = await client.updateResource('articles', '1'); expect(response.resource, isNull); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Full', () async { - http.response = mock.primaryResource; + http.response = mock.primaryResource(); final response = await client.updateResource('articles', '1', attributes: { 'cool': true @@ -436,17 +467,17 @@ void main() { }, documentMeta: { 'hello': 'world' }, headers: { - 'foo': 'bar' + 'foo': ['bar'] }); expect(response.resource?.type, 'articles'); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'id': '1', @@ -477,54 +508,59 @@ void main() { group('Replace One', () { test('Min', () async { - http.response = mock.one; + http.response = mock.one(); final response = await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')); expect(response.relationship, isA()); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'people', 'id': '42'} }); }); test('Full', () async { - http.response = mock.one; + http.response = mock.one(); final response = await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42'), - meta: {'hello': 'world'}, headers: {'foo': 'bar'}); + meta: { + 'hello': 'world' + }, + headers: { + 'foo': ['bar'] + }); expect(response.relationship, isA()); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': {'type': 'people', 'id': '42'}, 'meta': {'hello': 'world'} }); }); test('Throws RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); test('Throws FormatException', () async { - http.response = mock.many; + http.response = mock.many(); expect( () => client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')), @@ -534,48 +570,50 @@ void main() { group('Delete One', () { test('Min', () async { - http.response = mock.oneEmpty; + http.response = mock.oneEmpty(); final response = await client.deleteToOne('articles', '1', 'author'); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), {'data': null}); + expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null}); }); test('Full', () async { - http.response = mock.oneEmpty; - final response = await client - .deleteToOne('articles', '1', 'author', headers: {'foo': 'bar'}); + http.response = mock.oneEmpty(); + final response = + await client.deleteToOne('articles', '1', 'author', headers: { + 'foo': ['bar'] + }); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), {'data': null}); + expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null}); }); test('Throws RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client.deleteToOne('articles', '1', 'author'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); test('Throws FormatException', () async { - http.response = mock.many; + http.response = mock.many(); expect(() => client.deleteToOne('articles', '1', 'author'), throwsFormatException); }); @@ -583,17 +621,17 @@ void main() { group('Delete Many', () { test('Min', () async { - http.response = mock.many; + http.response = mock.many(); final response = await client .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, 'delete'); + expect(http.request.method.equals('delete'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -601,19 +639,23 @@ void main() { }); test('Full', () async { - http.response = mock.many; - final response = await client.deleteFromMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], - meta: {'hello': 'world'}, headers: {'foo': 'bar'}); + http.response = mock.many(); + final response = await client.deleteFromMany('articles', '1', 'tags', [ + Identifier('tags', '1') + ], meta: { + 'hello': 'world' + }, headers: { + 'foo': ['bar'] + }); expect(response.relationship, isA()); - expect(http.request.method, 'delete'); + expect(http.request.method.equals('delete'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -622,19 +664,19 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); test('Throws FormatException', () async { - http.response = mock.one; + http.response = mock.one(); expect( () => client.deleteFromMany( 'articles', '1', 'tags', [Identifier('tags', '1')]), @@ -644,17 +686,17 @@ void main() { group('Replace Many', () { test('Min', () async { - http.response = mock.many; + http.response = mock.many(); final response = await client .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -662,19 +704,23 @@ void main() { }); test('Full', () async { - http.response = mock.many; - final response = await client.replaceToMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], - meta: {'hello': 'world'}, headers: {'foo': 'bar'}); + http.response = mock.many(); + final response = await client.replaceToMany('articles', '1', 'tags', [ + Identifier('tags', '1') + ], meta: { + 'hello': 'world' + }, headers: { + 'foo': ['bar'] + }); expect(response.relationship, isA()); - expect(http.request.method, 'patch'); + expect(http.request.method.equals('patch'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -683,19 +729,19 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); test('Throws FormatException', () async { - http.response = mock.one; + http.response = mock.one(); expect( () => client.replaceToMany( 'articles', '1', 'tags', [Identifier('tags', '1')]), @@ -705,17 +751,17 @@ void main() { group('Add Many', () { test('Min', () async { - http.response = mock.many; + http.response = mock.many(); final response = await client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -723,19 +769,23 @@ void main() { }); test('Full', () async { - http.response = mock.many; - final response = await client.addMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], - meta: {'hello': 'world'}, headers: {'foo': 'bar'}); + http.response = mock.many(); + final response = await client.addMany('articles', '1', 'tags', [ + Identifier('tags', '1') + ], meta: { + 'hello': 'world' + }, headers: { + 'foo': ['bar'] + }); expect(response.relationship, isA()); - expect(http.request.method, 'post'); + expect(http.request.method.equals('post'), true); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'Accept': 'application/vnd.api+json', - 'Content-Type': 'application/vnd.api+json', - 'foo': 'bar' + 'Accept': ['application/vnd.api+json'], + 'Content-Type': ['application/vnd.api+json'], + 'foo': ['bar'] }); - expect(jsonDecode(http.request.body), { + expect(jsonDecode(await http.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -744,20 +794,20 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422; + http.response = mock.error422(); try { await client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); + expect(e.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); expect(e.toString(), contains('422')); } }); test('Throws FormatException', () async { - http.response = mock.one; + http.response = mock.one(); expect( () => client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), diff --git a/test/unit/client/encoding_test.dart b/test/unit/client/encoding_test.dart deleted file mode 100644 index 67605d1..0000000 --- a/test/unit/client/encoding_test.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - group('Decode body with', () { - final stringBodyRu = 'йцукен'; - final bytesBodyRu = utf8.encode(stringBodyRu); - final stringBodyEn = 'qwerty'; - final bytesBodyEn = utf8.encode(stringBodyEn); - - buildResponse( - List bytesBody, - Encoding encoding, - ) async { - final dartHttp = PersistentHandler( - MockClient( - (request) async { - return http.Response.bytes(bytesBody, 200); - }, - ), - // ignore: deprecated_member_use_from_same_package - defaultEncoding: encoding, - ); - - return dartHttp.handle(HttpRequest('get', Uri.parse('http://test.com'))); - } - - test('UTF-8 ru', () async { - final response = await buildResponse(bytesBodyRu, utf8); - expect(response.body, equals(stringBodyRu)); - }); - - test('latin1 ru', () async { - final response = await buildResponse(bytesBodyRu, latin1); - expect(response.body, isNot(equals(stringBodyRu))); - }); - - test('UTF-8 en', () async { - final response = await buildResponse(bytesBodyEn, utf8); - expect(response.body, equals(stringBodyEn)); - }); - - test('latin1 en', () async { - final response = await buildResponse(bytesBodyEn, latin1); - expect(response.body, equals(stringBodyEn)); - }); - }); -} diff --git a/test/unit/client/message_converter_test.dart b/test/unit/client/message_converter_test.dart deleted file mode 100644 index be0b862..0000000 --- a/test/unit/client/message_converter_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -void main() { - final converter = MessageConverter(); - final uri = Uri.parse('https://example.com'); - - test('No headers are set for GET requests', () { - final r = converter.request(HttpRequest('GET', uri)); - expect(r.headers, isEmpty); - }); - - test('No headers are set for OPTIONS requests', () { - final r = converter.request(HttpRequest('OPTIONS', uri)); - expect(r.headers, isEmpty); - }); - - test('No headers are set for DELETE requests', () { - final r = converter.request(HttpRequest('DELETE', uri)); - expect(r.headers, isEmpty); - }); -} diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart index 9dfe2ff..d25b8a7 100644 --- a/test/unit/client/mock_handler.dart +++ b/test/unit/client/mock_handler.dart @@ -1,11 +1,11 @@ -import 'package:json_api/http.dart'; +import 'package:http_interop/http_interop.dart'; -class MockHandler implements HttpHandler { - late HttpRequest request; - late HttpResponse response; +class MockHandler implements Handler { + late Request request; + late Response response; @override - Future handle(HttpRequest request) async { + Future handle(Request request) async { this.request = request; return response; } diff --git a/test/unit/http/payload_codec_test.dart b/test/unit/client/payload_codec_test.dart similarity index 77% rename from test/unit/http/payload_codec_test.dart rename to test/unit/client/payload_codec_test.dart index ef1057a..a4dde40 100644 --- a/test/unit/http/payload_codec_test.dart +++ b/test/unit/client/payload_codec_test.dart @@ -1,4 +1,4 @@ -import 'package:json_api/http.dart'; +import 'package:json_api/src/client/payload_codec.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart index a89e5f0..ce1c81e 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -1,17 +1,23 @@ -import 'dart:convert'; - +import 'package:http_interop/http_interop.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/media_type.dart'; + +final headers = Headers({ + 'Content-Type': [mediaType] +}); -final collectionMin = HttpResponse(200, - body: jsonEncode({ +collectionMin() => Response( + 200, + Json({ 'data': [ {'type': 'articles', 'id': '1'} ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final collectionFull = HttpResponse(200, - body: jsonEncode({ +collectionFull() => Response( + 200, + Json({ 'links': { 'self': 'http://example.com/articles', 'next': 'http://example.com/articles?page[offset]=2', @@ -79,11 +85,12 @@ final collectionFull = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final primaryResource = HttpResponse(200, - body: jsonEncode({ +primaryResource() => Response( + 200, + Json({ 'links': {'self': 'http://example.com/articles/1'}, 'meta': {'hello': 'world'}, 'data': { @@ -130,17 +137,21 @@ final primaryResource = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })) - ..headers.addAll({'Content-Type': mediaType}); -final relatedResourceNull = HttpResponse(200, - body: jsonEncode({ + }), + headers); + +relatedResourceNull() => Response( + 200, + Json({ 'links': {'self': 'http://example.com/articles/1/author'}, 'meta': {'hello': 'world'}, 'data': null - })) - ..headers.addAll({'Content-Type': mediaType}); -final one = HttpResponse(200, - body: jsonEncode({ + }), + headers); + +one() => Response( + 200, + Json({ 'links': { 'self': '/articles/1/relationships/author', 'related': '/articles/1/author' @@ -181,11 +192,12 @@ final one = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final oneEmpty = HttpResponse(200, - body: jsonEncode({ +oneEmpty() => Response( + 200, + Json({ 'links': { 'self': '/articles/1/relationships/author', 'related': '/articles/1/author' @@ -226,11 +238,12 @@ final oneEmpty = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final many = HttpResponse(200, - body: jsonEncode({ +many() => Response( + 200, + Json({ 'links': { 'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags' @@ -239,13 +252,14 @@ final many = HttpResponse(200, 'data': [ {'type': 'tags', 'id': '12'} ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final noContent = HttpResponse(204); +noContent() => Response(204, Body.empty(), Headers({})); -final error422 = HttpResponse(422, - body: jsonEncode({ +error422() => Response( + 422, + Json({ 'meta': {'hello': 'world'}, 'errors': [ { @@ -255,7 +269,7 @@ final error422 = HttpResponse(422, 'detail': 'First name must contain at least three characters.' } ] - })) - ..headers.addAll({'Content-Type': mediaType}); + }), + headers); -final error500 = HttpResponse(500); +error500() => Response(500, Body.empty(), Headers({})); diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart index f3d826a..1e0cd9d 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -10,17 +10,24 @@ void main() { jsonEncode({'type': 'test_type'})); expect( - jsonEncode(NewResource('test_type') + jsonEncode(NewResource('test_type', id: 'test_id', lid: 'test_lid') ..meta['foo'] = [42] ..attributes['color'] = 'green' ..relationships['one'] = - (ToOne(Identifier('rel', '1')..meta['rel'] = 1) + (NewToOne(Identifier('rel', '1')..meta['rel'] = 1) ..meta['one'] = 1) - ..relationships['many'] = - (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) - ..meta['many'] = 1)), + ..relationships['self'] = (NewToOne( + LocalIdentifier('test_type', 'test_lid')..meta['rel'] = 1) + ..meta['one'] = 1) + ..relationships['many'] = (NewToMany([ + Identifier('rel', '1')..meta['rel'] = 1, + LocalIdentifier('test_type', 'test_lid')..meta['rel'] = 1, + ]) + ..meta['many'] = 1)), jsonEncode({ 'type': 'test_type', + 'id': 'test_id', + 'lid': 'test_lid', 'attributes': {'color': 'green'}, 'relationships': { 'one': { @@ -31,6 +38,14 @@ void main() { }, 'meta': {'one': 1} }, + 'self': { + 'data': { + 'type': 'test_type', + 'lid': 'test_lid', + 'meta': {'rel': 1} + }, + 'meta': {'one': 1} + }, 'many': { 'data': [ { @@ -38,6 +53,11 @@ void main() { 'id': '1', 'meta': {'rel': 1} }, + { + 'type': 'test_type', + 'lid': 'test_lid', + 'meta': {'rel': 1} + }, ], 'meta': {'many': 1} } diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index 5631f3a..7886a66 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -90,7 +90,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.one(ToOne(Identifier.of(book)) + toObject(OutboundDataDocument.one(ToOne(book.toIdentifier()) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -111,7 +111,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.many(ToMany([Identifier.of(book)]) + toObject(OutboundDataDocument.many(ToMany([book.toIdentifier()]) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), diff --git a/test/unit/http/request_test.dart b/test/unit/http/request_test.dart deleted file mode 100644 index 96eaaff..0000000 --- a/test/unit/http/request_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - group('HttpRequest', () { - final uri = Uri(); - final get = HttpRequest('get', uri); - final post = HttpRequest('post', uri); - final delete = HttpRequest('delete', uri); - final patch = HttpRequest('patch', uri); - final options = HttpRequest('options', uri); - final fail = HttpRequest('fail', uri); - test('getters', () { - expect(get.isGet, isTrue); - expect(post.isPost, isTrue); - expect(delete.isDelete, isTrue); - expect(patch.isPatch, isTrue); - expect(options.isOptions, isTrue); - - expect(fail.isGet, isFalse); - expect(fail.isPost, isFalse); - expect(fail.isDelete, isFalse); - expect(fail.isPatch, isFalse); - expect(fail.isOptions, isFalse); - }); - test('converts method to lowercase', () { - expect(HttpRequest('pAtCh', Uri()).method, 'patch'); - }); - }); -} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index 2708c98..965c631 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -52,8 +52,11 @@ void main() { Fields({ 'articles': ['title', 'body'], 'people': ['name'] - }).asQueryParameters, - {'fields[articles]': 'title,body', 'fields[people]': 'name'}); + }).toQuery(), + { + 'fields[articles]': ['title,body'], + 'fields[people]': ['name'] + }); }); }); } diff --git a/test/unit/query/filter_test.dart b/test/unit/query/filter_test.dart deleted file mode 100644 index 5c8f028..0000000 --- a/test/unit/query/filter_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:test/test.dart'; - -void main() { - group('Filter', () { - test('emptiness', () { - expect(Filter().isEmpty, isTrue); - expect(Filter().isNotEmpty, isFalse); - expect(Filter({'foo': 'bar'}).isEmpty, isFalse); - expect(Filter({'foo': 'bar'}).isNotEmpty, isTrue); - }); - - test('add, remove, clear', () { - final f = Filter(); - f['foo'] = 'bar'; - f['bar'] = 'foo'; - expect(f['foo'], 'bar'); - expect(f['bar'], 'foo'); - f.remove('foo'); - expect(f['foo'], isNull); - f.clear(); - expect(f.isEmpty, isTrue); - }); - - test('Can decode url', () { - final uri = Uri.parse('/articles?filter[post]=1,2&filter[author]=12'); - final filter = Filter.fromUri(uri); - expect(filter['post'], '1,2'); - expect(filter['author'], '12'); - }); - - test('Can convert to query parameters', () { - expect(Filter({'post': '1,2', 'author': '12'}).asQueryParameters, - {'filter[post]': '1,2', 'filter[author]': '12'}); - }); - }); -} diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index ef91bcc..7f4eef5 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -25,7 +25,8 @@ void main() { }); test('Can convert to query parameters', () { - expect(Include(['author', 'comments.author']).asQueryParameters, - {'include': 'author,comments.author'}); + expect(Include(['author', 'comments.author']).toQuery(), { + 'include': ['author,comments.author'] + }); }); } diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart index 5b878e8..facc6d7 100644 --- a/test/unit/query/page_test.dart +++ b/test/unit/query/page_test.dart @@ -30,8 +30,10 @@ void main() { }); test('can convert to query parameters', () { - expect(Page({'limit': '10', 'offset': '20'}).asQueryParameters, - {'page[limit]': '10', 'page[offset]': '20'}); + expect(Page({'limit': '10', 'offset': '20'}).toQuery(), { + 'page[limit]': ['10'], + 'page[offset]': ['20'] + }); }); }); } diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index effa6e7..2591013 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -26,7 +26,8 @@ void main() { }); test('Can convert to query parameters', () { - expect(Sort(['-created', 'title']).asQueryParameters, - {'sort': '-created,title'}); + expect(Sort(['-created', 'title']).toQuery(), { + 'sort': ['-created,title'] + }); }); } diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart new file mode 100644 index 0000000..b2fbc4b --- /dev/null +++ b/test/unit/server/error_converter_test.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +import 'package:http_interop/extensions.dart'; +import 'package:json_api/server.dart'; +import 'package:test/test.dart'; + +void main() { + test('500', () async { + final r = await ErrorConverter().call('Foo', StackTrace.current); + expect(r.statusCode, equals(500)); + expect(await r.body.decode(utf8), + equals('{"errors":[{"title":"Internal Server Error"}]}')); + }); +}