diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc3aae2..176957ce 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). +## [Unreleased] +### Added +- Automatically encode DateTime objects as an ISO-8601 string +- The "conflict" server response + +### Changed +- URL Design matching now respects the base URL +- Allow null to be returned by error interceptors + +### Fixed +- StandardUriDesign working inconsistently, depending on the trailing slash in the path + ## [8.1.0] - 2024-08-29 ### Added - The rawResponse property to the responses of the RoutingClient @@ -13,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [8.0.0] - 2024-07-01 ### Added -- CORS middware +- CORS middleware ### Changed - Bump http\_interop to v2.0 @@ -264,6 +276,7 @@ the Document model. ### Added - Client: fetch resources, collections, related resources and relationships +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/8.1.0...HEAD [8.1.0]: https://github.com/f3ath/json-api-dart/compare/8.0.0...8.1.0 [8.0.0]: https://github.com/f3ath/json-api-dart/compare/7.0.1...8.0.0 [7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1 diff --git a/analysis_options.yaml b/analysis_options.yaml index 1ccb251c..8fff8b58 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1,11 @@ include: package:lints/recommended.yaml +formatter: + page_width: 100 + linter: rules: - - sort_constructors_first - - sort_unnamed_constructors_first + - use_declaring_parameters + - unnecessary_primary_constructor_body + - unnecessary_type_name_in_constructor + + - prefer_if_null_operators diff --git a/example/client.dart b/example/client.dart index e2e6f672..ff07ff7a 100644 --- a/example/client.dart +++ b/example/client.dart @@ -17,7 +17,7 @@ void main() async { /// This is the interface which decouples this JSON:API implementation /// from the HTTP client. /// Learn more: https://pub.dev/packages/http_interop - final httpHandler = httpClient.handleInterop; + final httpHandler = httpClient.sendInterop; /// 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. @@ -28,7 +28,7 @@ void main() async { /// 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); + final client = RoutingClient(uriDesign.uri, jsonApiClient); try { /// Fetch the collection. @@ -38,12 +38,7 @@ void main() async { /// 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; + final {'name': name, 'red': red, 'green': green, 'blue': blue} = resource.attributes; print('${resource.type}:${resource.id}'); print('$name - $red:$green:$blue'); } diff --git a/example/server.dart b/example/server.dart index a8173b4e..7592546a 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,14 +1,13 @@ 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:http_interop_middleware/http_interop_middleware.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:uuid/uuid.dart'; import 'server/in_memory_repo.dart'; import 'server/json_api_server.dart'; +import 'server/logger_middleware.dart'; import 'server/repository.dart'; import 'server/repository_controller.dart'; @@ -18,21 +17,16 @@ Future main() async { final repo = InMemoryRepo(['colors']); await initRepo(repo); final controller = RepositoryController(repo, Uuid().v4); - interop.Handler handler = - ControllerRouter(controller, StandardUriDesign.matchTarget).handle; - - handler = tryCatchMiddleware(handler, - onError: ErrorConverter(onError: (e, stack) async { - stderr.writeln(e); - stderr.writeln(stack); - return response(500, - document: OutboundErrorDocument( - [ErrorObject(title: 'Internal Server Error')])); - }).call); - - handler = loggingMiddleware(handler, - onRequest: (r) => print('${r.method} ${r.uri}'), - onResponse: (r) => print('${r.statusCode}')); + + final handler = errorConverter() + .wrapIn(loggerMiddleware) + .call( + router( + getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), + getController: (_, _) => controller, + ), + ); + final server = JsonApiServer(handler, host: host, port: port); ProcessSignal.sigint.watch().listen((event) async { @@ -46,16 +40,19 @@ Future main() async { } Future initRepo(Repository repo) async { - final models = { - {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114}, - {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203}, - {'name': 'Lime', 'r': 0, 'g': 255, 'b': 0}, - {'name': 'Peru', 'r': 205, 'g': 133, 'b': 63}, - }.map((color) => Model(Uuid().v4()) - ..attributes['name'] = color['name'] - ..attributes['red'] = color['r'] - ..attributes['green'] = color['g'] - ..attributes['blue'] = color['b']); + final models = + { + {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114}, + {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203}, + {'name': 'Lime', 'r': 0, 'g': 255, 'b': 0}, + {'name': 'Peru', 'r': 205, 'g': 133, 'b': 63}, + }.map( + (color) => Model(Uuid().v4()) + ..attributes['name'] = color['name'] + ..attributes['red'] = color['r'] + ..attributes['green'] = color['g'] + ..attributes['blue'] = color['b'], + ); for (final model in models) { await repo.persist('colors', model); } diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart index 0871d219..e61db14c 100644 --- a/example/server/in_memory_repo.dart +++ b/example/server/in_memory_repo.dart @@ -4,8 +4,8 @@ import 'package:json_api/src/nullable.dart'; import 'repository.dart'; -class InMemoryRepo implements Repository { - InMemoryRepo(Iterable types) { +class InMemoryRepo(Iterable types) implements Repository { + this { for (var type in types) { _storage[type] = {}; } @@ -29,8 +29,7 @@ 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(Reference.of)); return Stream.fromIterable(many).map((e) => e.toIdentifier()); @@ -47,21 +46,17 @@ class InMemoryRepo implements Repository { } @override - Future replaceOne( - String type, String id, String rel, Identifier? one) async { + Future replaceOne(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(Reference.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 replaceMany(String type, String id, String rel, Iterable many) { final set = _many(type, id, rel); set.clear(); set.addAll(many.map(Reference.of)); diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart index 9430821e..430d413b 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -3,20 +3,15 @@ import 'dart:io'; import 'package:http_interop/http_interop.dart'; import 'package:http_interop_io/http_interop_io.dart'; -class JsonApiServer { - JsonApiServer( - this._handler, { - this.host = 'localhost', - this.port = 8080, - }); +class JsonApiServer( + final Handler _handler, { /// Server host name - final String host; + final String host = 'localhost', /// Server port - final int port; - - final Handler _handler; + final int port = 8080, +}) { HttpServer? _server; /// Server uri diff --git a/example/server/logger_middleware.dart b/example/server/logger_middleware.dart new file mode 100644 index 00000000..86e08277 --- /dev/null +++ b/example/server/logger_middleware.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:http_interop_middleware/http_interop_middleware.dart'; + +/// Middleware that logs all requests and responses to stderr. +final Middleware loggerMiddleware = middleware( + onRequest: (r) async { + stderr.writeln(r); + return null; + }, + onResponse: (r, _) async { + stderr.writeln(r); + return null; + }, + onError: (e, t, _) async { + stderr.writeln(e); + stderr.writeln(t); + return null; + }, +); diff --git a/example/server/relationship_node.dart b/example/server/relationship_node.dart index 44bf41f4..2a9c5551 100644 --- a/example/server/relationship_node.dart +++ b/example/server/relationship_node.dart @@ -1,18 +1,14 @@ /// Relationship tree node -class RelationshipNode { - RelationshipNode(this.name); - +class RelationshipNode( + /// The name of the relationship + final String name, +) { static Iterable forest(Iterable relationships) { final root = RelationshipNode(''); - relationships - .map((rel) => rel.trim().split('.').map((e) => e.trim())) - .forEach(root.add); + relationships.map((rel) => rel.trim().split('.').map((e) => e.trim())).forEach(root.add); return root.children; } - /// The name of the relationship - final String name; - Iterable get children => _map.values; final _map = {}; diff --git a/example/server/repository.dart b/example/server/repository.dart index e8383533..ab010bf3 100644 --- a/example/server/repository.dart +++ b/example/server/repository.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; -abstract class Repository { +abstract class Repository() { /// Fetches a collection. /// Throws [CollectionNotFound]. Stream fetchCollection(String type); @@ -16,8 +16,7 @@ abstract class Repository { /// Throws [CollectionNotFound]. /// Throws [ResourceNotFound]. /// Throws [RelationshipNotFound]. - Stream addMany( - String type, String id, String rel, Iterable many); + Stream addMany(String type, String id, String rel, Iterable many); /// Delete the resource Future delete(String type, String id); @@ -29,33 +28,25 @@ abstract class Repository { /// Deletes refs from the to-many relationship. /// Returns the new actual refs. - Stream deleteMany( - String type, String id, String rel, Iterable many); + 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 many); + Stream replaceMany(String type, String id, String rel, Iterable many); } -class Reference { - Reference(this.type, this.id); - +class Reference(final String type, final String id) { static Reference of(Identifier id) => Reference(id.type, id.id); Identifier toIdentifier() => Identifier(type, id); - final String type; - final String id; - @override final hashCode = 0; @override - bool operator ==(Object other) => - other is Reference && type == other.type && id == other.id; + bool operator ==(Object other) => other is Reference && type == other.type && id == other.id; } -class ModelProps { +class ModelProps() { static ModelProps fromResource(Resource res) { final props = ModelProps(); res.attributes.forEach((key, value) { @@ -92,19 +83,14 @@ class ModelProps { } /// A model of a resource. Essentially, this is the core of a resource object. -class Model extends ModelProps { - Model(this.id); - - final String id; - +class Model(final String id) extends ModelProps { Resource toResource(String type) { final res = Resource(type, id); attributes.forEach((key, value) { res.attributes[key] = value; }); one.forEach((key, value) { - res.relationships[key] = - (value == null ? ToOne.empty() : ToOne(value.toIdentifier())); + res.relationships[key] = (value == null ? ToOne.empty() : ToOne(value.toIdentifier())); }); many.forEach((key, value) { res.relationships[key] = ToMany(value.map((it) => it.toIdentifier())); diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart index cd40607d..400f4745 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -1,31 +1,21 @@ -import 'dart:convert'; - -import 'package:http_interop/extensions.dart'; import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.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'; import 'repository.dart'; -class RepositoryController implements Controller { - RepositoryController(this.repo, this.getId); - - final Repository repo; - - final IdGenerator getId; - +class RepositoryController(final Repository repo, final IdGenerator getId) implements Controller { final design = StandardUriDesign.pathOnly; @override - Future fetchCollection(Request request, Target target) async { + Future fetchCollection(Request request, CollectionTarget target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) - ..links['self'] = Link(design.collection(target.type)); + ..links['self'] = Link(design.uri(target)); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); for (final r in resources) { await for (final r in _getAllRelated(r, forest)) { @@ -38,8 +28,10 @@ class RepositoryController implements Controller { @override Future fetchResource(Request request, ResourceTarget target) async { final resource = await _fetchLinkedResource(target.type, target.id); + var type = target.type; + var id = target.id; final doc = OutboundDataDocument.resource(resource) - ..links['self'] = Link(design.resource(target.type, target.id)); + ..links['self'] = Link(design.uri(ResourceTarget(type, id))); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); await for (final r in _getAllRelated(resource, forest)) { doc.included.add(r); @@ -48,88 +40,73 @@ class RepositoryController implements Controller { } @override - Future createResource(Request request, Target target) async { - final document = await _decode(request); - final newResource = document.dataAsNewResource(); + Future createResource(Request request, Target target, NewResource newResource) async { final res = newResource.toResource(getId); - await repo.persist( - res.type, Model(res.id)..setFrom(ModelProps.fromResource(res))); + await repo.persist(res.type, Model(res.id)..setFrom(ModelProps.fromResource(res))); if (newResource.id != null) { return 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; + final type = ref.type; + final id = ref.id; + final self = Link(design.uri(ResourceTarget(type, id))); + final resource = (await _fetchResource(ref.type, ref.id))..links['self'] = self; return created( - OutboundDataDocument.resource(resource)..links['self'] = self, - self.uri.toString()); + OutboundDataDocument.resource(resource)..links['self'] = self, + self.uri.toString(), + ); } @override - Future addMany(Request request, RelationshipTarget target) async { - final many = (await _decode(request)).asRelationship(); - final refs = await repo - .addMany(target.type, target.id, target.relationship, many) - .toList(); + Future addMany(Request request, RelationshipTarget target, ToMany rel) async { + final refs = await repo.addMany(target.type, target.id, target.relationship, rel).toList(); return ok(OutboundDataDocument.many(ToMany(refs))); } @override - Future deleteResource( - Request request, ResourceTarget target) async { + Future deleteResource(Request request, ResourceTarget target) async { await repo.delete(target.type, target.id); return noContent(); } @override - Future updateResource( - Request request, ResourceTarget target) async { - await repo.update(target.type, target.id, - ModelProps.fromResource((await _decode(request)).dataAsResource())); + Future updateResource(Request request, ResourceTarget target, Resource res) async { + await repo.update(target.type, target.id, ModelProps.fromResource(res)); return noContent(); } @override Future replaceRelationship( - Request request, RelationshipTarget target) async { - final rel = (await _decode(request)).asRelationship(); + Request request, + RelationshipTarget target, + Relationship rel, + ) async { if (rel is ToOne) { final ref = rel.identifier; await repo.replaceOne(target.type, target.id, target.relationship, ref); - return ok( - OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(ref))); + return 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) - .toList(); + final ids = await repo.replaceMany(target.type, target.id, target.relationship, rel).toList(); return ok(OutboundDataDocument.many(ToMany(ids))); } throw FormatException('Incomplete relationship'); } @override - Future deleteMany( - Request request, RelationshipTarget target) async { - final rel = (await _decode(request)).asToMany(); - final ids = await repo - .deleteMany(target.type, target.id, target.relationship, rel) - .toList(); + Future deleteMany(Request request, RelationshipTarget target, ToMany rel) async { + final ids = await repo.deleteMany(target.type, target.id, target.relationship, rel).toList(); return ok(OutboundDataDocument.many(ToMany(ids))); } @override - Future fetchRelationship( - Request request, RelationshipTarget target) async { + Future fetchRelationship(Request request, RelationshipTarget target) async { final model = (await repo.fetch(target.type, target.id)); if (model.one.containsKey(target.relationship)) { - return ok(OutboundDataDocument.one( - ToOne(model.one[target.relationship]?.toIdentifier()))); + return ok(OutboundDataDocument.one(ToOne(model.one[target.relationship]?.toIdentifier()))); } - final many = - model.many[target.relationship]?.map((it) => it.toIdentifier()); + final many = model.many[target.relationship]?.map((it) => it.toIdentifier()); if (many != null) { final doc = OutboundDataDocument.many(ToMany(many)); return ok(doc); @@ -141,23 +118,20 @@ class RepositoryController implements Controller { Future fetchRelated(Request request, RelatedTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { - final related = - await nullable(_fetchRelatedResource)(model.one[target.relationship]); + final related = await nullable(_fetchRelatedResource)(model.one[target.relationship]); final doc = OutboundDataDocument.resource(related); return ok(doc); } if (model.many.containsKey(target.relationship)) { final many = model.many[target.relationship] ?? {}; - final doc = OutboundDataDocument.collection( - await _fetchRelatedCollection(many).toList()); + final doc = OutboundDataDocument.collection(await _fetchRelatedCollection(many).toList()); return ok(doc); } throw RelationshipNotFound(target.type, target.id, target.relationship); } /// Returns a stream of related resources recursively - Stream _getAllRelated( - Resource resource, Iterable nodes) async* { + Stream _getAllRelated(Resource resource, Iterable nodes) async* { for (final node in nodes) { await for (final r in _getRelated(resource, node.name)) { yield r; @@ -168,9 +142,9 @@ class RepositoryController implements Controller { /// Returns a stream of related resources Stream _getRelated(Resource resource, String relationship) async* { - for (final rel in resource.relationships[relationship] ?? - (throw RelationshipNotFound( - resource.type, resource.id, relationship))) { + for (final rel + in resource.relationships[relationship] ?? + (throw RelationshipNotFound(resource.type, resource.id, relationship))) { yield await _fetchLinkedResource(rel.type, rel.id); } } @@ -178,7 +152,7 @@ class RepositoryController implements Controller { /// Fetches and builds a resource object with a "self" link Future _fetchLinkedResource(String type, String id) async { return (await _fetchResource(type, id)) - ..links['self'] = Link(design.resource(type, id)); + ..links['self'] = Link(design.uri(ResourceTarget(type, id))); } Stream _fetchAll(String type) => @@ -199,11 +173,6 @@ class RepositoryController implements Controller { if (r != null) yield r; } } - - Future _decode(Request r) => r.body - .decode(utf8) - .then(const PayloadCodec().decode) - .then(InboundDocument.new); } typedef IdGenerator = String Function(); diff --git a/lib/client.dart b/lib/client.dart index 034266e7..15567801 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -21,7 +21,7 @@ /// representing the most common use cases of resource fetching and manipulation. /// It can conveniently construct and parse JSON:API documents and URIs. /// The [RoutingClient] should be your default choice. -library client; +library; export 'package:json_api/src/client/client.dart'; export 'package:json_api/src/client/request.dart'; diff --git a/lib/document.dart b/lib/document.dart index 04efafb7..fe589207 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,8 +1,9 @@ /// JSON:API Document model. -library document; +library; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/inbound_document.dart'; +export 'package:json_api/src/document/json_encodable.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/new_identifier.dart'; export 'package:json_api/src/document/new_relationship.dart'; @@ -10,7 +11,9 @@ export 'package:json_api/src/document/new_resource.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/payload_codec.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; +export 'package:json_api/src/document/to_json_encodable.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 9a3ced90..fbb191bd 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,69 +1,5 @@ -import 'package:http_interop/http_interop.dart'; +/// Common HTTP utilities for JSON:API clients and servers. +/// WARNING: This library is in beta stage. The API is subject to change. +library; -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; -} - -Handler loggingMiddleware(Handler handler, - {Function(Request request)? onRequest, - Function(Response response)? onResponse}) => - (Request request) async { - onRequest?.call(request); - final response = await handler(request); - onResponse?.call(response); - return response; - }; - -/// CORS middleware -Handler corsMiddleware(Handler handler) => (Request request) async { - final corsHeaders = { - 'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'], - 'Access-Control-Expose-Headers': ['Location'], - }; - - if (request.method == 'options') { - const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; - return Response( - 204, - Body(), - Headers.from({ - ...corsHeaders, - 'Access-Control-Allow-Methods': - request.headers['Access-Control-Request-Method'] ?? methods, - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? ['*'], - })); - } - final response = await handler(request); - response.headers.addAll(corsHeaders); - return response; - }; - -extension HeaderExt on Headers { - String? last(String key) { - final v = this[key]; - return (v != null && v.isNotEmpty) ? v.last : null; - } -} +export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart index 335eb69c..105a6237 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -1,7 +1,8 @@ /// A set of builders/parsers for special query parameters used in JSON:API. -library query; +library; 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'; diff --git a/lib/routing.dart b/lib/routing.dart index 4f267e51..98553ed8 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,6 +1,6 @@ /// Routing describes the design of URLs on the server. /// See https://jsonapi.org/recommendations/#urls -library routing; +library; export 'package:json_api/src/routing/standard_uri_design.dart'; export 'package:json_api/src/routing/target.dart'; diff --git a/lib/server.dart b/lib/server.dart index 1cf7a97e..ff697aac 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,13 +1,21 @@ /// JSON:API server on top of dart:io. -library server; +/// WARNING: This library is in beta stage. The API is subject to change. +library; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/controller_router.dart'; +export 'package:json_api/src/server/cors_middleware.dart'; export 'package:json_api/src/server/error_converter.dart'; +export 'package:json_api/src/server/errors/client_generated_id_not_supported.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/not_acceptable.dart'; +export 'package:json_api/src/server/errors/payload_invalid.dart'; +export 'package:json_api/src/server/errors/payload_missing.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/errors/unsupported_media_type.dart'; +export 'package:json_api/src/server/json_api_request.dart'; +export 'package:json_api/src/server/request_validator_middleware.dart'; export 'package:json_api/src/server/response.dart'; -export 'package:json_api/src/server/try_catch_middleware.dart'; +export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index c5b1f0f3..1429488c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -2,10 +2,9 @@ import 'dart:convert'; import 'package:http_interop/extensions.dart'; import 'package:http_interop/http_interop.dart' as i; -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/document/payload_codec.dart'; import 'package:json_api/src/media_type.dart'; /// A basic JSON:API client. @@ -13,43 +12,29 @@ import 'package:json_api/src/media_type.dart'; /// The JSON:API [Request] is converted to [HttpRequest] and sent downstream /// using the [_handler]. Received [HttpResponse] is then converted back to /// JSON:API [Response]. JSON conversion is performed by the [codec]. -class Client { - const Client(this._handler, {PayloadCodec codec = const PayloadCodec()}) - : _codec = codec; - - final i.Handler _handler; - final PayloadCodec _codec; - +class const Client(final i.Handler _handler, {final PayloadCodec _codec = const PayloadCodec()}) { /// Sends the [request] to the given [uri]. Future send(Uri uri, Request request) async { final json = await _encode(request.document); - final body = i.Body.text(json, utf8); + final body = i.Body.text(json); final headers = i.Headers.from({ 'Accept': [mediaType], if (json.isNotEmpty) 'Content-Type': [mediaType], - ...request.headers + ...request.headers, }); - final url = request.query.isEmpty - ? uri - : uri.replace(queryParameters: request.query.toQuery()); - final response = - await _handler(i.Request(request.method, url, body, headers)); + final url = request.query.isEmpty ? uri : uri.replace(queryParameters: request.query.toQuery()); + final response = await _handler(i.Request(request.method, url, body, headers)); final document = await _decode(response); return Response(response, document); } - Future _encode(Object? doc) async => - doc == null ? '' : await _codec.encode(doc); + Future _encode(Object? doc) async => doc == null ? '' : await _codec.encode(doc); Future _decode(i.Response response) async { final json = await response.body.decode(utf8); if (json.isNotEmpty && - response.headers - .last('Content-Type') - ?.toLowerCase() - .startsWith(mediaType) == - true) { + response.headers['Content-Type']?.last.toLowerCase().startsWith(mediaType) == true) { return await _codec.decode(json); } return null; diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 00467270..c0803b93 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -3,29 +3,27 @@ import 'package:json_api/query.dart'; import 'package:json_api/src/client/client.dart'; /// A generic JSON:API request. -class Request { +class Request( /// Creates a new instance if the request with the specified HTTP [method] /// and [document]. - Request(this.method, [this.document]); + /// 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, +]) { /// Creates a GET request. - Request.get() : this('get'); + new get() : this('GET'); /// Creates a POST request. - Request.post([Object? document]) : this('post', document); + new post([Object? document]) : this('POST', document); /// Creates a DELETE request. - Request.delete([Object? document]) : this('delete', document); + new delete([Object? document]) : this('DELETE', document); /// Creates a PATCH request. - Request.patch([Object? document]) : this('patch', document); - - /// 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; + new patch([Object? document]) : this('PATCH', document); /// Query parameters. final query = Query(); diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 20732c10..def8f252 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -2,15 +2,13 @@ import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/http.dart'; /// A generic JSON:API response. -class Response { - Response(this.httpResponse, this.document); - +class Response( /// HTTP response - final http.Response httpResponse; + final http.Response httpResponse, /// Decoded JSON document - final Map? 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 f77b7093..b0e07d8f 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -1,25 +1,20 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; -class CollectionFetched { - CollectionFetched(this.rawResponse) { - final document = InboundDocument(rawResponse.document ?? - (throw FormatException('The document must not be empty'))); +class CollectionFetched( + /// The raw JSON:API response + final Response rawResponse, +) { + this { collection.addAll(document.dataAsCollection()); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; + final document = InboundDocument( + rawResponse.document ?? (throw FormatException('The document must not be empty')), + ); /// The resource collection fetched from the server final collection = []; diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart index ab3cd609..114933cd 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -1,28 +1,23 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; /// A related resource response. /// /// https://jsonapi.org/format/#fetching-resources-responses -class RelatedResourceFetched { - RelatedResourceFetched(this.rawResponse) { - final document = InboundDocument(rawResponse.document ?? - (throw FormatException('The document must not be empty'))); +class RelatedResourceFetched( + /// The raw JSON:API response + final Response rawResponse, +) { + this { resource = document.dataAsResourceOrNull(); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; + final document = InboundDocument( + rawResponse.document ?? (throw FormatException('The document must not be empty')), + ); /// Related resource. May be null late final Resource? resource; diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index 889070f6..f66b145e 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -1,34 +1,25 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; /// A response to a relationship fetch request. -class RelationshipFetched { - RelationshipFetched(this.rawResponse, this.relationship); - +class RelationshipFetched( + /// The raw JSON:API response + final Response rawResponse, + final R relationship, +) { static RelationshipFetched many(Response response) { - final document = InboundDocument(response.document ?? - (throw FormatException('The document must not be empty'))); - return RelationshipFetched(response, document.asToMany()) - ..included.addAll(document.included()); + final document = InboundDocument( + response.document ?? (throw FormatException('The document must not be empty')), + ); + return RelationshipFetched(response, document.asToMany())..included.addAll(document.included()); } static RelationshipFetched one(Response response) { - final document = InboundDocument(response.document ?? - (throw FormatException('The document must not be empty'))); - return RelationshipFetched(response, document.asToOne()) - ..included.addAll(document.included()); + final document = InboundDocument( + response.document ?? (throw FormatException('The document must not be empty')), + ); + return RelationshipFetched(response, document.asToOne())..included.addAll(document.included()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - - final R relationship; final included = []; } diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index 9a885d8c..4b72267b 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -1,32 +1,21 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; /// A response to a relationship request. -class RelationshipUpdated { - RelationshipUpdated(this.rawResponse, this.relationship); +class RelationshipUpdated( + /// The raw JSON:API response + final Response rawResponse, + /// Updated relationship. Null if "204 No Content" is returned + final R? relationship, +) { static RelationshipUpdated many(Response response) { final json = response.document; - return RelationshipUpdated( - response, json == null ? null : InboundDocument(json).asToMany()); + return RelationshipUpdated(response, json == null ? null : InboundDocument(json).asToMany()); } static RelationshipUpdated one(Response response) { final json = response.document; - return RelationshipUpdated( - response, json == null ? null : InboundDocument(json).asToOne()); + return RelationshipUpdated(response, json == null ? null : InboundDocument(json).asToOne()); } - - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - - /// 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 1f69695d..e214cc31 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -1,10 +1,12 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; /// Thrown when the server returns a non-successful response. -class RequestFailure implements Exception { - RequestFailure(this.rawResponse) { +class RequestFailure( + /// The raw JSON:API response + final Response rawResponse, +) implements Exception { + this { final json = rawResponse.document; if (json == null) return; final document = InboundDocument(json); @@ -12,15 +14,6 @@ class RequestFailure implements Exception { meta.addAll(document.meta()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - /// Error objects returned by the server final errors = []; diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart index d67a8ec3..665211cb 100644 --- a/lib/src/client/response/resource_created.dart +++ b/lib/src/client/response/resource_created.dart @@ -1,4 +1,3 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; @@ -6,25 +5,17 @@ import 'package:json_api/src/client/response.dart'; /// This is always a "201 Created" response. /// /// https://jsonapi.org/format/#crud-creating-responses-201 -class ResourceCreated { - ResourceCreated(this.rawResponse) { - final document = InboundDocument(rawResponse.document ?? - (throw FormatException('The document must not be empty'))); +class ResourceCreated(final Response rawResponse) { + this { + final document = InboundDocument( + rawResponse.document ?? (throw FormatException('The document must not be empty')), + ); resource = document.dataAsResource(); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - /// Created resource. late final Resource resource; diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart index 2b694466..9877f5dd 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.dart @@ -1,27 +1,18 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; /// A response to fetch a primary resource request -class ResourceFetched { - ResourceFetched(this.rawResponse) { - final document = InboundDocument(rawResponse.document ?? - (throw FormatException('The document must not be empty'))); +class ResourceFetched(final Response rawResponse) { + this { + final document = InboundDocument( + rawResponse.document ?? (throw FormatException('The document must not be empty')), + ); resource = document.dataAsResource(); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - late final Resource resource; /// Top-level meta data diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart index 381ebe90..95da2f63 100644 --- a/lib/src/client/response/resource_updated.dart +++ b/lib/src/client/response/resource_updated.dart @@ -1,10 +1,8 @@ -import 'package:http_interop/http_interop.dart' as i; import 'package:json_api/document.dart'; import 'package:json_api/src/client/response.dart'; -class ResourceUpdated { - ResourceUpdated(this.rawResponse) - : resource = _resource(rawResponse.document) { +class ResourceUpdated(final Response rawResponse) { + this : resource = _resource(rawResponse.document) { final document = rawResponse.document; if (document != null) { included.addAll(InboundDocument(document).included()); @@ -21,15 +19,6 @@ class ResourceUpdated { return null; } - // coverage:ignore-start - /// The raw HTTP response - @Deprecated('Use rawResponse.httpResponse instead') - i.Response get httpResponse => rawResponse.httpResponse; - // coverage:ignore-end - - /// The raw JSON:API response - final Response rawResponse; - /// The created resource. Null for "204 No Content" responses. late final Resource? resource; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 7a11e244..3529bab3 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -14,12 +14,7 @@ import 'package:json_api/src/client/response/resource_fetched.dart'; import 'package:json_api/src/client/response/resource_updated.dart'; /// A routing JSON:API client -class RoutingClient { - RoutingClient(this._baseUri, this._client); - - final Client _client; - final UriDesign _baseUri; - +class RoutingClient(final UriBuilder _uriFor, final Client _client) { /// Adds the [identifiers] to the to-many relationship /// identified by [type], [id], [relationship]. /// @@ -32,12 +27,13 @@ class RoutingClient { List identifiers, { Map meta = const {}, Map> headers = const {}, - }) async => - RelationshipUpdated.many(await send( - _baseUri.relationship(type, id, relationship), - Request.post( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers))); + }) async => RelationshipUpdated.many( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.post(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers), + ), + ); /// Creates a new resource with the given [type] and [id] on the server. /// @@ -59,19 +55,24 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async => - ResourceUpdated(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) - ..query.mergeAll(query))); + }) async => ResourceUpdated( + await send( + _uriFor(CollectionTarget(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) + ..query.mergeAll(query), + ), + ); /// Creates a new resource in the collection of type [type]. /// The server is responsible for assigning the resource id. @@ -95,20 +96,24 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async => - ResourceCreated(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))); + }) async => ResourceCreated( + await send( + _uriFor(CollectionTarget(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), + ), + ); /// Deletes the [identifiers] from the to-many relationship /// identified by [type], [id], [relationship]. @@ -123,12 +128,13 @@ class RoutingClient { List identifiers, { Map meta = const {}, Map> headers = const {}, - }) async => - RelationshipUpdated.many(await send( - _baseUri.relationship(type, id, relationship), - Request.delete( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers))); + }) async => RelationshipUpdated.many( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.delete(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers), + ), + ); /// Fetches the primary collection of type [type]. /// @@ -139,12 +145,14 @@ class RoutingClient { String type, { Map> headers = const {}, Iterable query = const [], - }) async => - CollectionFetched(await send( - _baseUri.collection(type), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => CollectionFetched( + await send( + _uriFor(CollectionTarget(type)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the related resource collection /// identified by [type], [id], [relationship]. @@ -158,12 +166,14 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async => - CollectionFetched(await send( - _baseUri.related(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => CollectionFetched( + await send( + _uriFor(RelatedTarget(type, id, relationship)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the to-one relationship /// identified by [type], [id], [relationship]. @@ -177,12 +187,14 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async => - RelationshipFetched.one(await send( - _baseUri.relationship(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelationshipFetched.one( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the to-many relationship /// identified by [type], [id], [relationship]. @@ -196,12 +208,14 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async => - RelationshipFetched.many(await send( - _baseUri.relationship(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelationshipFetched.many( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the related resource /// identified by [type], [id], [relationship]. @@ -215,12 +229,14 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async => - RelatedResourceFetched(await send( - _baseUri.related(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelatedResourceFetched( + await send( + _uriFor(RelatedTarget(type, id, relationship)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the resource identified by [type] and [id]. /// @@ -232,12 +248,14 @@ class RoutingClient { String id, { Map> headers = const {}, Iterable query = const [], - }) async => - ResourceFetched(await send( - _baseUri.resource(type, id), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => ResourceFetched( + await send( + _uriFor(ResourceTarget(type, id)), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Updates the resource identified by [type] and [id]. /// @@ -259,19 +277,24 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async => - ResourceUpdated(await send( - _baseUri.resource(type, id), - Request.patch(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) - ..query.mergeAll(query))); + }) async => ResourceUpdated( + await send( + _uriFor(ResourceTarget(type, id)), + Request.patch( + 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) + ..query.mergeAll(query), + ), + ); /// Replaces the to-one relationship /// identified by [type], [id], and [relationship] by setting @@ -289,13 +312,14 @@ class RoutingClient { Map meta = const {}, Map> headers = const {}, Iterable query = const [], - }) async => - RelationshipUpdated.one(await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta))) - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelationshipUpdated.one( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.patch(OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta))) + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Replaces the to-many relationship /// identified by [type], [id], and [relationship] by setting @@ -313,13 +337,14 @@ class RoutingClient { Map meta = const {}, Map> headers = const {}, Iterable query = const [], - }) async => - RelationshipUpdated.many(await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelationshipUpdated.many( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.patch(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Removes the to-one relationship /// identified by [type], [id], and [relationship].. @@ -333,12 +358,14 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async => - RelationshipUpdated.one(await send( - _baseUri.relationship(type, id, relationship), - Request.patch(OutboundDataDocument.one(ToOne.empty())) - ..headers.addAll(headers) - ..query.mergeAll(query))); + }) async => RelationshipUpdated.one( + await send( + _uriFor(RelationshipTarget(type, id, relationship)), + Request.patch(OutboundDataDocument.one(ToOne.empty())) + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Deletes the resource identified by [type] and [id]. /// @@ -350,12 +377,12 @@ class RoutingClient { String id, { Map> headers = const {}, Iterable query = const [], - }) => - send( - _baseUri.resource(type, id), - Request.delete() - ..headers.addAll(headers) - ..query.mergeAll(query)); + }) => send( + _uriFor(ResourceTarget(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. diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 07ef02c9..254aa178 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,56 +1,49 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/document/error_source.dart'; -import 'package:json_api/src/document/link.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject { +class ErrorObject({ /// Creates an instance of a JSON:API Error. /// The [links] map may contain custom links. The about link /// passed through the [links['about']] argument takes precedence and will overwrite /// the `about` key in [links]. - ErrorObject( - {this.id = '', - this.status = '', - this.code = '', - this.title = '', - this.detail = '', - this.source = const ErrorSource()}); - /// A unique identifier for this particular occurrence of the problem. - final String id; + final String id = '', /// The HTTP status code applicable to this problem, expressed as a string value. - final String status; + final String status = '', /// An application-specific error code, expressed as a string value. - final String code; + final String code = '', /// A short, human-readable summary of the problem that SHOULD NOT change /// from occurrence to occurrence of the problem, except for purposes of localization. - final String title; + final String title = '', /// A human-readable explanation specific to this occurrence of the problem. /// Like title, this field’s value can be localized. - final String detail; + final String detail = '', /// Error source. - final ErrorSource source; - + final ErrorSource source = const ErrorSource(), +}) implements JsonEncodable { /// Error links. final links = {}; /// Meta data. final meta = {}; + @override Map toJson() => { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (source.isNotEmpty) 'source': source, - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (source.isNotEmpty) 'source': source, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/document/error_source.dart b/lib/src/document/error_source.dart index ae8c6cee..2eb1b8e0 100644 --- a/lib/src/document/error_source.dart +++ b/lib/src/document/error_source.dart @@ -1,19 +1,20 @@ -/// An object containing references to the source of the error. -class ErrorSource { - const ErrorSource({this.pointer = '', this.parameter = ''}); +import 'package:json_api/src/document/json_encodable.dart'; +/// An object containing references to the source of the error. +class const ErrorSource({ /// A JSON Pointer [RFC6901] to the associated entity in the request document. - final String pointer; + final String pointer = '', /// A string indicating which URI query parameter caused the error. - final String parameter; - + final String parameter = '', +}) implements JsonEncodable { bool get isEmpty => pointer.isEmpty && parameter.isEmpty; bool get isNotEmpty => !isEmpty; + @override Map toJson() => { - if (parameter.isNotEmpty) 'parameter': parameter, - if (pointer.isNotEmpty) 'pointer': pointer - }; + if (parameter.isNotEmpty) 'parameter': parameter, + if (pointer.isNotEmpty) 'pointer': pointer, + }; } diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index 4571c697..a5b48c06 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -8,26 +8,20 @@ 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 -class InboundDocument { - InboundDocument(this.json); - - static const _parse = _Parser(); - +class InboundDocument( /// Raw JSON object. - final Map json; + final Map json, +) { + static const _parse = _Parser(); bool get hasData => json.containsKey('data'); /// Included resources - Iterable included() => json - .get('included', orGet: () => []) - .whereType() - .map(_parse.resource); + Iterable included() => + json.get('included', orGet: () => []).whereType().map(_parse.resource); /// Top-level meta data. Map meta() => _parse.meta(json); @@ -36,13 +30,10 @@ class InboundDocument { Map links() => _parse.links(json); /// Errors (for an Error Document) - Iterable errors() => json - .get('errors', orGet: () => []) - .whereType() - .map(_parse.errorObject); + Iterable errors() => + json.get('errors', orGet: () => []).whereType().map(_parse.errorObject); - Iterable dataAsCollection() => - _data().whereType().map(_parse.resource); + Iterable dataAsCollection() => _data().whereType().map(_parse.resource); Resource dataAsResource() => _parse.resource(_data()); @@ -63,11 +54,8 @@ class InboundDocument { T _data() => json.get('data'); } -class _Parser { - const _Parser(); - - Map meta(Map json) => - json.get>('meta', orGet: () => {}); +class const _Parser() { + Map meta(Map json) => json.get>('meta', orGet: () => {}); Map links(Map json) { var links = json.get('links', orGet: () => {}); @@ -83,27 +71,24 @@ class _Parser { } NewRelationship newRelationship(Map json) { - final rel = - json.containsKey('data') ? _newRel(json['data']) : NewRelationship(); + 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'), - id: json.getIfDefined('id'), - lid: json.getIfDefined('lid'), - ) + 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'), + id: json.getIfDefined('id'), + lid: json.getIfDefined('lid'), + ) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getNewRelationships(json)) ..meta.addAll(meta(json)); @@ -111,8 +96,7 @@ class _Parser { /// Decodes Identifier from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. Identifier identifier(Map json) => - Identifier(json.get('type'), json.get('id')) - ..meta.addAll(meta(json)); + 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]. @@ -129,29 +113,32 @@ class _Parser { throw FormatException('Invalid JSON'); } - ErrorObject errorObject(Map json) => ErrorObject( - id: json.get('id', orGet: () => ''), - status: json.get('status', orGet: () => ''), - code: json.get('code', orGet: () => ''), - title: json.get('title', orGet: () => ''), - detail: json.get('detail', orGet: () => ''), - source: errorSource(json.get('source', orGet: () => {}))) - ..meta.addAll(meta(json)) - ..links.addAll(links(json)); + ErrorObject errorObject(Map json) => + ErrorObject( + id: json.get('id', orGet: () => ''), + status: json.get('status', orGet: () => ''), + code: json.get('code', orGet: () => ''), + title: json.get('title', orGet: () => ''), + detail: json.get('detail', orGet: () => ''), + source: errorSource(json.get('source', orGet: () => {})), + ) + ..meta.addAll(meta(json)) + ..links.addAll(links(json)); /// Decodes ErrorSource from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. ErrorSource errorSource(Map json) => ErrorSource( - pointer: json.get('pointer', orGet: () => ''), - parameter: json.get('parameter', orGet: () => '')); + pointer: json.get('pointer', orGet: () => ''), + parameter: json.get('parameter', orGet: () => ''), + ); /// Decodes Link from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. Link _link(Object json) => switch (json) { - String() => Link(Uri.parse(json)), - Map() => Link(Uri.parse(json['href']))..meta.addAll(meta(json)), - _ => throw FormatException('Invalid JSON') - }; + String() => Link(Uri.parse(json)), + Map() => Link(Uri.parse(json['href']))..meta.addAll(meta(json)), + _ => throw FormatException('Invalid JSON'), + }; Map _getAttributes(Map json) => json.get>('attributes', orGet: () => {}); @@ -164,19 +151,19 @@ class _Parser { .get('relationships', orGet: () => {}) .map((key, value) => MapEntry(key, newRelationship(value))); - Relationship _rel(data) => switch (data) { - null => ToOne.empty(), - Map() => ToOne(identifier(data)), - List() => ToMany(data.whereType().map(identifier)), - _ => throw FormatException('Invalid relationship object') - }; - - NewRelationship _newRel(data) => switch (data) { - null => NewToOne.empty(), - Map() => NewToOne(newIdentifier(data)), - List() => NewToMany(data.whereType().map(newIdentifier)), - _ => throw FormatException('Invalid relationship object') - }; + Relationship _rel(Object? data) => switch (data) { + null => ToOne.empty(), + Map() => ToOne(identifier(data)), + List() => ToMany(data.whereType().map(identifier)), + _ => throw FormatException('Invalid relationship object'), + }; + + NewRelationship _newRel(Object? data) => switch (data) { + null => NewToOne.empty(), + Map() => NewToOne(newIdentifier(data)), + List() => NewToMany(data.whereType().map(newIdentifier)), + _ => throw FormatException('Invalid relationship object'), + }; } extension _TypedGeter on Map { diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart new file mode 100644 index 00000000..ea5c4717 --- /dev/null +++ b/lib/src/document/json_encodable.dart @@ -0,0 +1,4 @@ +abstract interface class JsonEncodable { + /// Converts the object to a JSON-encodable object. + Object? toJson(); +} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 6bd34807..31c798b4 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,17 +1,17 @@ +import 'package:json_api/src/document/json_encodable.dart'; + /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri); - +class Link( /// Link URL - final Uri uri; - + final Uri uri, +) implements JsonEncodable { /// Link meta data final meta = {}; @override String toString() => uri.toString(); - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + @override + Object toJson() => meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; } diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart index 1c0653d6..0be78d8c 100644 --- a/lib/src/document/new_identifier.dart +++ b/lib/src/document/new_identifier.dart @@ -1,5 +1,7 @@ +import 'package:json_api/src/document/json_encodable.dart'; + /// A new Resource Identifier object, used when creating new resources on the server. -sealed class NewIdentifier { +sealed class NewIdentifier implements JsonEncodable { /// Resource type. String get type; @@ -12,21 +14,18 @@ sealed class NewIdentifier { /// Identifier meta-data. Map get meta; + @override Map toJson(); } /// A Resource Identifier object -class Identifier implements NewIdentifier { - Identifier(this.type, this.id); - +class Identifier( /// Resource type. - @override - final String type; + @override final String type, /// Resource id. - @override - final String id; - + @override final String id, +) implements NewIdentifier { @override final lid = null; @@ -35,35 +34,26 @@ class Identifier implements NewIdentifier { final meta = {}; @override - Map toJson() => - {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; + Map toJson() => {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; } -class LocalIdentifier implements NewIdentifier { - LocalIdentifier(this.type, this.lid); - +class LocalIdentifier( /// Resource type. - @override - final String type; + @override final String type, + /// Local Resource id. + @override final String lid, +) implements NewIdentifier { /// 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, - }; + 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 index 65de471f..194be22b 100644 --- a/lib/src/document/new_relationship.dart +++ b/lib/src/document/new_relationship.dart @@ -1,16 +1,18 @@ import 'dart:collection'; +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/new_identifier.dart'; -class NewRelationship with IterableMixin { +class NewRelationship() with IterableMixin implements JsonEncodable { final links = {}; final meta = {}; + @override Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; @override Iterator get iterator => [].iterator; diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index 3cf4be73..b810eb4b 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -1,25 +1,22 @@ +import 'package:json_api/src/document/json_encodable.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 { - NewResource(this.type, {this.id, this.lid}); - +class NewResource( /// Resource type - final String type; + final String type, { /// Resource id. - final String? id; + final String? id, /// Local resource id. - final String? lid; - + final String? lid, +}) implements JsonEncodable { /// Resource meta data. final meta = {}; @@ -33,14 +30,15 @@ class NewResource { /// See https://jsonapi.org/format/#document-resource-object-relationships final relationships = {}; + @override 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, - }; + 'type': type, + 'id': ?id, + '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) { @@ -51,8 +49,8 @@ class NewResource { return resource; } - Map _toRelationships(String id) => relationships - .map((k, v) => MapEntry(k, _toRelationship(v, id)..meta.addAll(v.meta))); + 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) { @@ -69,12 +67,11 @@ class NewResource { return _toIdentifier(identifier, id); } - Identifier _toIdentifier(NewIdentifier identifier, String id) => - switch (identifier) { - Identifier() => identifier, - LocalIdentifier() => (identifier.type == type && identifier.lid == lid) - ? identifier.toIdentifier(id) - : throw StateError( - 'Unmatched local id: "${identifier.lid}". Expected "$lid".') - }; + Identifier _toIdentifier(NewIdentifier identifier, String id) => switch (identifier) { + Identifier() => identifier, + LocalIdentifier() => + (identifier.type == type && identifier.lid == lid) + ? 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 index 7d8da3d9..0a763977 100644 --- a/lib/src/document/new_to_many.dart +++ b/lib/src/document/new_to_many.dart @@ -1,8 +1,8 @@ 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) { +class NewToMany(Iterable identifiers) extends NewRelationship { + this { _ids.addAll(identifiers); } @@ -10,9 +10,9 @@ class NewToMany extends NewRelationship { @override Map toJson() => { - 'data': [..._ids], - ...super.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 index aefcd46c..8f546ea1 100644 --- a/lib/src/document/new_to_one.dart +++ b/lib/src/document/new_to_one.dart @@ -1,16 +1,12 @@ 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); +class NewToOne(final NewIdentifier? identifier) extends NewRelationship { + new 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/outbound_document.dart b/lib/src/document/outbound_document.dart index 78192f62..f59040ea 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -1,22 +1,17 @@ -import 'package:json_api/src/document/error_object.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/new_resource.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/document.dart'; /// A sever-to-client document. -class OutboundDocument { +class OutboundDocument() implements JsonEncodable { /// The document "meta" object. final meta = {}; - /// Returns the JSON representation. + @override Map toJson() => {'meta': meta}; } /// A sever-to-client document with errors. -class OutboundErrorDocument extends OutboundDocument { - OutboundErrorDocument(Iterable errors) { +class OutboundErrorDocument(Iterable errors) extends OutboundDocument { + this { this.errors.addAll(errors); } @@ -24,32 +19,28 @@ class OutboundErrorDocument extends OutboundDocument { final errors = []; @override - Map toJson() => { - 'errors': errors, - if (meta.isNotEmpty) 'meta': meta, - }; + Map toJson() => {'errors': errors, if (meta.isNotEmpty) 'meta': meta}; } /// A sever-to-client document with data. class OutboundDataDocument extends OutboundDocument { /// Creates an instance of a document containing a single resource as the primary data. - OutboundDataDocument.resource(Resource? this.data); + new resource(Resource? this.data); /// Creates an instance of a document containing a single to-be-created resource as the primary data. Used only in client-to-server requests. - OutboundDataDocument.newResource(NewResource this.data); + new newResource(NewResource this.data); /// Creates an instance of a document containing a collection of resources as the primary data. - OutboundDataDocument.collection(Iterable collection) - : data = collection.toList(); + new collection(Iterable collection) : data = collection.toList(); /// Creates an instance of a document containing a to-one relationship. - OutboundDataDocument.one(ToOne one) : data = one.identifier { + new one(ToOne one) : data = one.identifier { meta.addAll(one.meta); links.addAll(one.links); } /// Creates an instance of a document containing a to-many relationship. - OutboundDataDocument.many(ToMany many) : data = many.toList() { + new many(ToMany many) : data = many.toList() { meta.addAll(many.meta); links.addAll(many.links); } @@ -65,9 +56,9 @@ class OutboundDataDocument extends OutboundDocument { @override Map toJson() => { - 'data': data, - if (links.isNotEmpty) 'links': links, - if (included.isNotEmpty) 'included': included, - if (meta.isNotEmpty) 'meta': meta, - }; + 'data': data, + if (links.isNotEmpty) 'links': links, + if (included.isNotEmpty) 'included': included, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/client/payload_codec.dart b/lib/src/document/payload_codec.dart similarity index 75% rename from lib/src/client/payload_codec.dart rename to lib/src/document/payload_codec.dart index e3ed74be..653c08b0 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/document/payload_codec.dart @@ -1,20 +1,20 @@ import 'dart:async'; import 'dart:convert'; +import 'package:json_api/src/document/to_json_encodable.dart'; + /// 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(); - +class const PayloadCodec() { /// Decodes a JSON string into a Map - FutureOr decode(String json) { + Future decode(String json) async { final decoded = jsonDecode(json); if (decoded is Map) return decoded; throw FormatException('Invalid JSON payload: ${decoded.runtimeType}'); } /// Encodes a JSON:API document into a JSON string. - FutureOr encode(Object document) => jsonEncode(document); + FutureOr encode(Object document) => jsonEncode(document, toEncodable: toJsonEncodable); } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 3526ffc0..a65f5d72 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,17 +1,46 @@ import 'dart:collection'; +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/new_identifier.dart'; -class Relationship with IterableMixin { +class Relationship() with IterableMixin implements JsonEncodable { final links = {}; final meta = {}; - Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; + @override + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; @override Iterator get iterator => [].iterator; } + +class ToMany(Iterable identifiers) extends Relationship { + this { + _ids.addAll(identifiers); + } + + final _ids = []; + + @override + Map toJson() => { + 'data': [..._ids], + ...super.toJson(), + }; + + @override + Iterator get iterator => _ids.iterator; +} + +class ToOne(final Identifier? identifier) extends Relationship { + new empty() : this(null); + + @override + Map toJson() => {'data': identifier, ...super.toJson()}; + + @override + Iterator get iterator => identifier == null ? super.iterator : [identifier!].iterator; +} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index b9ca5f71..61127ca4 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,18 +1,15 @@ +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.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 { - Resource(this.type, this.id); +class Resource( /// Resource type. - final String type; + final String type, /// Resource id. - final String id; - + final String id, +) implements JsonEncodable { /// Resource links final links = {}; @@ -45,12 +42,13 @@ class Resource { return null; } + @override Map toJson() => { - 'type': type, - 'id': id, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; + 'type': type, + 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/document/to_json_encodable.dart b/lib/src/document/to_json_encodable.dart new file mode 100644 index 00000000..1013149e --- /dev/null +++ b/lib/src/document/to_json_encodable.dart @@ -0,0 +1,9 @@ +import 'package:json_api/document.dart'; + +/// A helper function to be used in `toJsonEncodable` +/// parameter of `jsonEncode()`. +Object? toJsonEncodable(Object? v) => switch (v) { + JsonEncodable() => v.toJson(), + DateTime() => v.toIso8601String(), + _ => throw UnsupportedError('Cannot convert to JSON: $v'), +}; diff --git a/lib/src/document/to_many.dart b/lib/src/document/to_many.dart index 902b1ff0..8b137891 100644 --- a/lib/src/document/to_many.dart +++ b/lib/src/document/to_many.dart @@ -1,19 +1 @@ -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 index ae61979f..8b137891 100644 --- a/lib/src/document/to_one.dart +++ b/lib/src/document/to_one.dart @@ -1,17 +1 @@ -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/status_code.dart b/lib/src/http/status_code.dart new file mode 100644 index 00000000..eadcacb3 --- /dev/null +++ b/lib/src/http/status_code.dart @@ -0,0 +1,24 @@ +extension type StatusCode(int value) { + static const ok = 200; + static const created = 201; + static const accepted = 202; + static const noContent = 204; + static const badRequest = 400; + static const forbidden = 403; + static const notFound = 404; + static const methodNotAllowed = 405; + static const notAcceptable = 406; + static const conflict = 409; + static const unsupportedMediaType = 415; + static const internalServerError = 500; + + /// 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/query/fields.dart b/lib/src/query/fields.dart index a09bab7c..ab9a5128 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -4,23 +4,26 @@ 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> implements QueryEncodable { - /// The [fields] argument maps the resource type to a list of fields. - /// - /// Example: - /// ```dart - /// Fields({'articles': ['title', 'body'], 'people': ['name']}); - /// ``` - Fields([Map> fields = const {}]) { +/// The [fields] argument maps the resource type to a list of fields. +/// +/// Example: +/// ```dart +/// Fields({'articles': ['title', 'body'], 'people': ['name']}); +/// ``` +class Fields([Map> fields = const {}]) + with MapMixin> + implements QueryEncodable { + this { addAll(fields); } /// Extracts the requested fields from the [uri]. - static Fields fromUri(Uri uri) => - Fields(uri.queryParametersAll.map((k, v) => MapEntry( - _regex.firstMatch(k)?.group(1) ?? '', - v.expand((it) => it.split(',')).toList())) - ..removeWhere((k, v) => k.isEmpty)); + static Fields fromUri(Uri uri) => Fields( + uri.queryParametersAll.map( + (k, v) => + MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.expand((it) => it.split(',')).toList()), + )..removeWhere((k, v) => k.isEmpty), + ); static final _regex = RegExp(r'^fields\[(.+)\]$'); @@ -28,8 +31,7 @@ class Fields with MapMixin> implements QueryEncodable { /// Converts to a map of query parameters @override - Map> toQuery() => - _map.map((k, v) => MapEntry('fields[$k]', [v.join(',')])); + 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 new file mode 100644 index 00000000..67117993 --- /dev/null +++ b/lib/src/query/filter.dart @@ -0,0 +1,48 @@ +import 'dart:collection'; + +import 'package:json_api/src/query/query_encodable.dart'; + +/// Query parameters defining filtering. +/// @see https://jsonapi.org/format/#fetching-filtering +/// Example: +/// ```dart +/// Filter({'name': 'John'}).addTo(url); +/// ``` +/// encodes into +/// ``` +/// ?filter[name]=John +/// ``` +class Filter([Map parameters = const {}]) + with MapMixin + implements QueryEncodable { + this { + 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 + @override + Map> toQuery() => _.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 838cfc35..bcc1c47a 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -4,25 +4,27 @@ 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 implements QueryEncodable { - /// Example: - /// ```dart - /// Include(['comments', 'comments.author']); - /// ``` - Include([Iterable resources = const []]) { +/// Example: +/// ```dart +/// Include(['comments', 'comments.author']); +/// ``` +class Include([Iterable resources = const []]) + with IterableMixin + implements QueryEncodable { + this { _.addAll(resources); } - static Include fromUri(Uri uri) => Include( - uri.queryParametersAll['include']?.expand((it) => it.split(',')) ?? []); + static Include fromUri(Uri uri) => + Include(uri.queryParametersAll['include']?.expand((it) => it.split(',')) ?? []); final _ = []; /// Converts to a map of query parameters @override Map> toQuery() => { - if (isNotEmpty) 'include': [join(',')] - }; + if (isNotEmpty) 'include': [join(',')], + }; @override Iterator get iterator => _.iterator; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index e00ceea9..a186bf28 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -4,31 +4,33 @@ 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 implements QueryEncodable { - /// Example: - /// ```dart - /// Page({'limit': '10', 'offset': '20'}).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?page[limit]=10&page[offset]=20 - /// ``` - /// - Page([Map parameters = const {}]) { +/// Example: +/// ```dart +/// Page({'limit': '10', 'offset': '20'}).addTo(url); +/// ``` +/// encodes into +/// ``` +/// ?page[limit]=10&page[offset]=20 +/// ``` +/// +class Page([Map parameters = const {}]) + with MapMixin + implements QueryEncodable { + this { addAll(parameters); } - static Page fromUri(Uri uri) => Page(uri.queryParametersAll - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) - ..removeWhere((k, v) => k.isEmpty)); + static Page fromUri(Uri uri) => Page( + uri.queryParametersAll.map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty), + ); static final _regex = RegExp(r'^page\[(.+)\]$'); final _ = {}; /// Converts to a map of query parameters @override - Map> toQuery() => - _.map((k, v) => MapEntry('page[$k]', [v])); + 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 index f4356e4b..d97d8232 100644 --- a/lib/src/query/query.dart +++ b/lib/src/query/query.dart @@ -1,15 +1,15 @@ import 'package:json_api/src/query/query_encodable.dart'; /// Arbitrary query parameters. -class Query implements QueryEncodable { - Query([Map> parameters = const {}]) { +class Query([Map> parameters = const {}]) implements QueryEncodable { + this { mergeMap(parameters); } final _parameters = >{}; /// Returns true if the collection is empty. - get isEmpty => _parameters.isEmpty; + bool get isEmpty => _parameters.isEmpty; /// Adds a new [value] for the [key]. void addValue(String key, String value) { diff --git a/lib/src/query/query_encodable.dart b/lib/src/query/query_encodable.dart index 055ceade..2f2d3953 100644 --- a/lib/src/query/query_encodable.dart +++ b/lib/src/query/query_encodable.dart @@ -1,5 +1,5 @@ /// An object which cab be represented as URI query parameters. -abstract class QueryEncodable { +abstract interface class QueryEncodable { /// Returns the map representing query parameters. /// Each key may have zero or more values. /// `{'foo': ['bar', 'baz']}` represents `?foo=bar&foo=baz` diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 47fbad66..01cde6cf 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -4,27 +4,29 @@ 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 implements QueryEncodable { +class Sort([Iterable fields = const []]) + with IterableMixin + implements QueryEncodable { /// The [fields] arguments is the list of sorting criteria. /// /// Example: /// ```dart /// Sort(['-created', 'title']); /// ``` - Sort([Iterable fields = const []]) { + this { _fields.addAll(fields.map((SortField.parse))); } - static Sort fromUri(Uri uri) => Sort( - (uri.queryParametersAll['sort']?.expand((it) => it.split(',')) ?? [])); + static Sort fromUri(Uri uri) => + Sort((uri.queryParametersAll['sort']?.expand((it) => it.split(',')) ?? [])); final _fields = []; /// Converts to a map of query parameters @override Map> toQuery() => { - if (isNotEmpty) 'sort': [join(',')] - }; + if (isNotEmpty) 'sort': [join(',')], + }; @override int get length => _fields.length; @@ -33,10 +35,9 @@ class Sort with IterableMixin implements QueryEncodable { Iterator get iterator => _fields.iterator; } -abstract class SortField { - static SortField parse(String queryParam) => queryParam.startsWith('-') - ? Desc(queryParam.substring(1)) - : Asc(queryParam); +abstract interface class SortField { + static SortField parse(String queryParam) => + queryParam.startsWith('-') ? Desc(queryParam.substring(1)) : Asc(queryParam); String get name; @@ -44,12 +45,7 @@ abstract class SortField { int get factor; } -class Asc implements SortField { - const Asc(this.name); - - @override - final String name; - +class const Asc(@override final String name) implements SortField { @override final int factor = 1; @@ -57,12 +53,7 @@ class Asc implements SortField { String toString() => name; } -class Desc implements SortField { - const Desc(this.name); - - @override - final String name; - +class const Desc(@override final String name) implements SortField { @override final int factor = -1; diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart index 2b474f65..ec927063 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -2,52 +2,49 @@ import 'package:json_api/routing.dart'; /// URL Design recommended by the standard. /// See https://jsonapi.org/recommendations/#urls -class StandardUriDesign implements UriDesign { +class StandardUriDesign(Uri baseUri) implements UriDesign { /// Creates an instance of [UriDesign] recommended by JSON:API standard. /// The [base] URI will be used as a prefix for the generated URIs. - const StandardUriDesign(this.base); + this : base = baseUri.path.endsWith('/') ? baseUri : baseUri.replace(path: '${baseUri.path}/'); /// A "path only" version of the recommended URL design, e.g. /// `/books`, `/books/42`, `/books/42/authors` static final pathOnly = StandardUriDesign(Uri(path: '/')); - static Target? matchTarget(Uri uri) => switch ((uri.pathSegments)) { - [var type] => Target(type), - [var type, var id] => ResourceTarget(type, id), - [var type, var id, var rel] => RelatedTarget(type, id, rel), - [var type, var id, 'relationships', var rel] => - RelationshipTarget(type, id, rel), - _ => null - }; - - final Uri base; - - /// Returns a URL for the primary resource collection of type [type]. - /// E.g. `/books`. - @override - Uri collection(String type) => _resolve([type]); - - /// Returns a URL for the primary resource of type [type] with id [id]. - /// E.g. `/books/123`. + /// Matches a [uri] to a [Target] object. @override - Uri resource(String type, String id) => _resolve([type, id]); + Target? target(Uri uri) => + !uri.path.startsWith(base.path) || + (base.scheme.isNotEmpty && uri.scheme != base.scheme) || + (base.host.isNotEmpty && uri.host != base.host) || + (base.port != 0 && uri.port != base.port) + ? null + : switch (uri.pathSegments.sublist(base.pathSegments.where((it) => it.isNotEmpty).length)) { + [var type] => CollectionTarget(type), + [var type, var id] => ResourceTarget(type, id), + [var type, var id, var rel] => RelatedTarget(type, id, rel), + [var type, var id, 'relationships', var rel] => RelationshipTarget(type, id, rel), + _ => null, + }; - /// Returns a URL for the relationship itself. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - /// E.g. `/books/123/relationships/authors`. - @override - Uri relationship(String type, String id, String relationship) => - _resolve([type, id, 'relationships', relationship]); + final Uri base; - /// Returns a URL for the related resource or collection. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - /// E.g. `/books/123/authors`. @override - Uri related(String type, String id, String relationship) => - _resolve([type, id, relationship]); - - Uri _resolve(List pathSegments) => - base.resolveUri(Uri(pathSegments: pathSegments)); + Uri uri(Target target) => switch (target) { + CollectionTarget(type: String type) => _resolve([type]), + ResourceTarget(type: String type, id: String id) => _resolve([type, id]), + RelatedTarget(type: String type, id: String id, relationship: String rel) => _resolve([ + type, + id, + rel, + ]), + RelationshipTarget(type: String type, id: String id, relationship: String rel) => _resolve([ + type, + id, + 'relationships', + rel, + ]), + }; + + Uri _resolve(List pathSegments) => base.resolveUri(Uri(pathSegments: pathSegments)); } diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 78a59c25..040bbea6 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,35 +1,16 @@ -class Target { - const Target(this.type); - - final String type; -} - -class ResourceTarget implements Target { - const ResourceTarget(this.type, this.id); - - @override - final String type; - final String id; +sealed class Target { + String get type; } -class RelatedTarget implements ResourceTarget { - const RelatedTarget(this.type, this.id, this.relationship); +class const CollectionTarget(@override final String type) implements Target; - @override - final String type; - @override - final String id; +class const ResourceTarget(@override final String type, final String id) implements Target; - final String relationship; -} - -class RelationshipTarget implements ResourceTarget { - const RelationshipTarget(this.type, this.id, this.relationship); +class const RelatedTarget(@override final String type, final String id, final String relationship) + implements Target; - @override - final String type; - @override - final String id; - - final String relationship; -} +class const RelationshipTarget( + @override final String type, + final String id, + final String relationship, +) implements Target; diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart index 4bb1519b..be88160a 100644 --- a/lib/src/routing/uri_design.dart +++ b/lib/src/routing/uri_design.dart @@ -1,9 +1,10 @@ -abstract class UriDesign { - Uri collection(String type); +import 'package:json_api/routing.dart'; - Uri resource(String type, String id); +abstract interface class UriDesign { + Uri uri(Target target); - Uri related(String type, String id, String relationship); - - Uri relationship(String type, String id, String relationship); + Target? target(Uri uri); } + +typedef UriBuilder = Uri Function(Target target); +typedef TargetMatcher = Target? Function(Uri uri); diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 6936003d..56dabbfd 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,43 +1,44 @@ -import 'package:http_interop/http_interop.dart' as http; +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller -abstract class Controller { +abstract interface class Controller { /// Fetch a primary resource collection - Future fetchCollection(http.Request request, Target target); + Future fetchCollection(Request request, CollectionTarget target); /// Create resource - Future createResource(http.Request request, Target target); + Future createResource( + Request request, + CollectionTarget target, + NewResource newResource, + ); /// Fetch a single primary resource - Future fetchResource( - http.Request request, ResourceTarget target); + Future fetchResource(Request request, ResourceTarget target); /// Updates a primary resource - Future updateResource( - http.Request request, ResourceTarget target); + Future updateResource(Request request, ResourceTarget target, Resource resource); /// Deletes the primary resource - Future deleteResource( - http.Request request, ResourceTarget target); + Future deleteResource(Request request, ResourceTarget target); /// Fetches a relationship - Future fetchRelationship( - http.Request rq, RelationshipTarget target); + Future fetchRelationship(Request rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany( - http.Request request, RelationshipTarget target); + Future addMany(Request request, RelationshipTarget target, ToMany relationship); /// Updates the relationship - Future replaceRelationship( - http.Request request, RelationshipTarget target); + Future replaceRelationship( + Request request, + RelationshipTarget target, + Relationship relationship, + ); /// Deletes the members from the to-many relationship - Future deleteMany( - http.Request request, RelationshipTarget target); + Future deleteMany(Request request, RelationshipTarget target, ToMany relationship); /// Fetches related resource or collection - Future fetchRelated( - http.Request request, RelatedTarget target); + Future fetchRelated(Request request, RelatedTarget target); } diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart deleted file mode 100644 index 4171d1f5..00000000 --- a/lib/src/server/controller_router.dart +++ /dev/null @@ -1,65 +0,0 @@ -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/media_type.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 { - ControllerRouter(this._controller, this._matchTarget); - - final Controller _controller; - final Target? Function(Uri uri) _matchTarget; - - Future handle(Request request) async { - _validate(request); - final target = _matchTarget(request.uri); - return await switch (target) { - RelationshipTarget() => switch (request.method) { - 'get' => _controller.fetchRelationship(request, target), - 'post' => _controller.addMany(request, target), - 'patch' => _controller.replaceRelationship(request, target), - 'delete' => _controller.deleteMany(request, target), - _ => throw MethodNotAllowed(request.method) - }, - RelatedTarget() => switch (request.method) { - 'get' => _controller.fetchRelated(request, target), - _ => throw MethodNotAllowed(request.method) - }, - ResourceTarget() => switch (request.method) { - 'get' => _controller.fetchResource(request, target), - 'patch' => _controller.updateResource(request, target), - 'delete' => _controller.deleteResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - Target() => switch (request.method) { - 'get' => _controller.fetchCollection(request, target), - 'post' => _controller.createResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - _ => throw UnmatchedTarget(request.uri) - }; - } - - void _validate(Request request) { - final contentType = request.headers.last('Content-Type'); - if (contentType != null && _isInvalid(MediaType.parse(contentType))) { - throw UnsupportedMediaType(); - } - if ((request.headers['Accept'] ?? []) - .expand((it) => it.split(',')) - .map((it) => it.trim()) - .map(MediaType.parse) - .any(_isInvalid)) { - throw Unacceptable(); - } - } - - bool _isInvalid(MediaType mt) => - mt.mimeType == mediaType && - mt.parameters.isNotEmpty; // TODO: check for ext and profile -} diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart new file mode 100644 index 00000000..a062b221 --- /dev/null +++ b/lib/src/server/cors_middleware.dart @@ -0,0 +1,23 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; + +final corsMiddleware = middleware( + onRequest: (rq) async => switch (rq.method.toUpperCase()) { + 'OPTIONS' => Response( + 204, + Body.empty(), + Headers.from({ + 'Access-Control-Allow-Methods': + rq.headers['Access-Control-Request-Method'] ?? + const ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'], + 'Access-Control-Allow-Headers': rq.headers['Access-Control-Request-Headers'] ?? const ['*'], + }), + ), + _ => null, + }, + onResponse: (rs, rq) async => rs + ..headers.addAll({ + 'Access-Control-Allow-Origin': [rq.headers['origin']?.last ?? '*'], + 'Access-Control-Expose-Headers': const ['Location'], + }), +); diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index 63004082..4bde216c 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -1,70 +1,67 @@ -import 'package:http_interop/http_interop.dart' as http; +import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/src/server/errors/client_generated_id_not_supported.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/not_acceptable.dart'; +import 'package:json_api/src/server/errors/payload_invalid.dart'; +import 'package:json_api/src/server/errors/payload_missing.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) ?? methodNotAllowed(), - UnmatchedTarget() => - await onUnmatchedTarget?.call(error) ?? badRequest(), - CollectionNotFound() => await onCollectionNotFound?.call(error) ?? - notFound(OutboundErrorDocument([ - ErrorObject( - title: 'Collection Not Found', - detail: 'Type: ${error.type}', - ) - ])), - ResourceNotFound() => await onResourceNotFound?.call(error) ?? - notFound(OutboundErrorDocument([ +/// Creates a middleware that maps server exceptions to HTTP responses. +Middleware errorConverter({ + Future Function(MethodNotAllowed)? onMethodNotAllowed, + Future Function(UnmatchedTarget)? onUnmatchedTarget, + Future Function(CollectionNotFound)? onCollectionNotFound, + Future Function(ResourceNotFound)? onResourceNotFound, + Future Function(RelationshipNotFound)? onRelationshipNotFound, + Future Function(Object, StackTrace)? onError, +}) => middleware( + onError: (error, trace, _) async => switch (error) { + MethodNotAllowed() => await onMethodNotAllowed?.call(error) ?? methodNotAllowed(), + UnmatchedTarget() => await onUnmatchedTarget?.call(error) ?? badRequest(), + CollectionNotFound() => + await onCollectionNotFound?.call(error) ?? + notFound( + OutboundErrorDocument([ + ErrorObject(title: 'Collection Not Found', detail: 'Type: ${error.type}'), + ]), + ), + ResourceNotFound() => + await onResourceNotFound?.call(error) ?? + notFound( + OutboundErrorDocument([ ErrorObject( title: 'Resource Not Found', detail: 'Type: ${error.type}, id: ${error.id}', - ) - ])), - RelationshipNotFound() => await onRelationshipNotFound?.call(error) ?? - notFound(OutboundErrorDocument([ + ), + ]), + ), + RelationshipNotFound() => + await onRelationshipNotFound?.call(error) ?? + notFound( + OutboundErrorDocument([ ErrorObject( title: 'Relationship Not Found', - detail: 'Type: ${error.type}' + detail: + 'Type: ${error.type}' ', id: ${error.id}' ', relationship: ${error.relationship}', - ) - ])), - UnsupportedMediaType() => unsupportedMediaType(), - Unacceptable() => unacceptable(), - _ => await onError?.call(error, trace) ?? - response(500, - document: OutboundErrorDocument( - [ErrorObject(title: 'Internal Server Error')])) - }; -} + ), + ]), + ), + UnsupportedMediaType() => unsupportedMediaType(), + PayloadMissing() => badRequest(), + PayloadInvalid() => badRequest(), + ClientGeneratedIdNotSupported() => forbidden(), + NotAcceptable() => notAcceptable(), + _ => + await onError?.call(error, trace) ?? + internalServerError(OutboundErrorDocument([ErrorObject(title: 'Internal Server Error')])), + }, +); diff --git a/lib/src/server/errors/client_generated_id_not_supported.dart b/lib/src/server/errors/client_generated_id_not_supported.dart new file mode 100644 index 00000000..20f54924 --- /dev/null +++ b/lib/src/server/errors/client_generated_id_not_supported.dart @@ -0,0 +1 @@ +class ClientGeneratedIdNotSupported() implements Exception; diff --git a/lib/src/server/errors/collection_not_found.dart b/lib/src/server/errors/collection_not_found.dart index c6110e17..60544106 100644 --- a/lib/src/server/errors/collection_not_found.dart +++ b/lib/src/server/errors/collection_not_found.dart @@ -1,7 +1,5 @@ /// A collection is not found on the server. -class CollectionNotFound implements Exception { - CollectionNotFound(this.type); - +class CollectionNotFound( /// Collection type. - final String type; -} + final String type, +) implements Exception; diff --git a/lib/src/server/errors/method_not_allowed.dart b/lib/src/server/errors/method_not_allowed.dart index f5f27c98..047bdcc4 100644 --- a/lib/src/server/errors/method_not_allowed.dart +++ b/lib/src/server/errors/method_not_allowed.dart @@ -1,5 +1 @@ -class MethodNotAllowed implements Exception { - MethodNotAllowed(this.method); - - final String method; -} +class MethodNotAllowed(final String method) implements Exception; diff --git a/lib/src/server/errors/not_acceptable.dart b/lib/src/server/errors/not_acceptable.dart new file mode 100644 index 00000000..57034f47 --- /dev/null +++ b/lib/src/server/errors/not_acceptable.dart @@ -0,0 +1 @@ +class NotAcceptable() implements Exception {} diff --git a/lib/src/server/errors/payload_invalid.dart b/lib/src/server/errors/payload_invalid.dart new file mode 100644 index 00000000..758775a1 --- /dev/null +++ b/lib/src/server/errors/payload_invalid.dart @@ -0,0 +1 @@ +class PayloadInvalid() implements Exception; diff --git a/lib/src/server/errors/payload_missing.dart b/lib/src/server/errors/payload_missing.dart new file mode 100644 index 00000000..10523c7b --- /dev/null +++ b/lib/src/server/errors/payload_missing.dart @@ -0,0 +1 @@ +class PayloadMissing() implements Exception; diff --git a/lib/src/server/errors/relationship_not_found.dart b/lib/src/server/errors/relationship_not_found.dart index 9a2ae1d2..baa7aa9c 100644 --- a/lib/src/server/errors/relationship_not_found.dart +++ b/lib/src/server/errors/relationship_not_found.dart @@ -1,8 +1,3 @@ /// 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; -} +class RelationshipNotFound(final String type, final String id, final String relationship) + implements Exception; diff --git a/lib/src/server/errors/resource_not_found.dart b/lib/src/server/errors/resource_not_found.dart index 6848d9f5..eeb9303d 100644 --- a/lib/src/server/errors/resource_not_found.dart +++ b/lib/src/server/errors/resource_not_found.dart @@ -1,7 +1,2 @@ /// A resource is not found on the server. -class ResourceNotFound implements Exception { - ResourceNotFound(this.type, this.id); - - final String type; - final String id; -} +class ResourceNotFound(final String type, final String id) implements Exception; diff --git a/lib/src/server/errors/unacceptable.dart b/lib/src/server/errors/unacceptable.dart deleted file mode 100644 index ac6d6855..00000000 --- a/lib/src/server/errors/unacceptable.dart +++ /dev/null @@ -1 +0,0 @@ -class Unacceptable implements Exception {} diff --git a/lib/src/server/errors/unmatched_target.dart b/lib/src/server/errors/unmatched_target.dart index c3ce1fa0..f54c7bb9 100644 --- a/lib/src/server/errors/unmatched_target.dart +++ b/lib/src/server/errors/unmatched_target.dart @@ -1,5 +1 @@ -class UnmatchedTarget implements Exception { - UnmatchedTarget(this.uri); - - final Uri uri; -} +class UnmatchedTarget(final Uri uri) implements Exception; diff --git a/lib/src/server/errors/unsupported_media_type.dart b/lib/src/server/errors/unsupported_media_type.dart index 8fde40a4..8caa01ec 100644 --- a/lib/src/server/errors/unsupported_media_type.dart +++ b/lib/src/server/errors/unsupported_media_type.dart @@ -1 +1 @@ -class UnsupportedMediaType implements Exception {} +class UnsupportedMediaType() implements Exception {} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart new file mode 100644 index 00000000..63f5ee22 --- /dev/null +++ b/lib/src/server/json_api_request.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:http_interop/extensions.dart'; +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/document.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/payload_invalid.dart'; +import 'package:json_api/src/server/errors/payload_missing.dart'; + +/// A supported combination of an HTTP method and a JSON:API target. +sealed class JsonApiRequest { + /// Resolves an HTTP [request] and [target] to a supported request. + /// Throws [MethodNotAllowed] for unsupported combinations. + static Future resolve(Request request, Target target) async { + final doc = await _decode(request); + return switch ((request.method.toUpperCase(), target)) { + ('GET', CollectionTarget target) => FetchCollectionRequest(request, target), + ('POST', CollectionTarget target) => CreateResourceRequest( + request, + target, + doc ?? (throw PayloadMissing()), + ), + ('GET', ResourceTarget target) => FetchResourceRequest(request, target), + ('PATCH', ResourceTarget target) => UpdateResourceRequest( + request, + target, + doc ?? (throw PayloadMissing()), + ), + ('DELETE', ResourceTarget target) => DeleteResourceRequest(request, target), + ('GET', RelatedTarget target) => FetchRelatedRequest(request, target), + ('GET', RelationshipTarget target) => FetchRelationshipRequest(request, target), + ('POST', RelationshipTarget target) => AddManyRequest( + request, + target, + doc ?? (throw PayloadMissing()), + ), + ('PATCH', RelationshipTarget target) => ReplaceRelationshipRequest( + request, + target, + doc ?? (throw PayloadMissing()), + ), + ('DELETE', RelationshipTarget target) => DeleteManyRequest( + request, + target, + doc ?? (throw PayloadMissing()), + ), + _ => throw MethodNotAllowed(request.method), + }; + } + + Request get httpRequest; + + Target get target; + + /// Dispatches to the controller. + Future dispatch(Controller controller); +} + +final class const FetchCollectionRequest( + @override final Request httpRequest, + @override final CollectionTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => + controller.fetchCollection(httpRequest, target); +} + +final class const CreateResourceRequest( + @override final Request httpRequest, + @override final CollectionTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + NewResource get newResource => doc.dataAsNewResource(); + + @override + Future dispatch(Controller controller) => + controller.createResource(httpRequest, target, newResource); +} + +final class const FetchResourceRequest( + @override final Request httpRequest, + @override final ResourceTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchResource(httpRequest, target); +} + +final class const UpdateResourceRequest( + @override final Request httpRequest, + @override final ResourceTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + Resource get resource => doc.dataAsResource(); + + @override + Future dispatch(Controller controller) => + controller.updateResource(httpRequest, target, resource); +} + +final class const DeleteResourceRequest( + @override final Request httpRequest, + @override final ResourceTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => + controller.deleteResource(httpRequest, target); +} + +final class const FetchRelatedRequest( + @override final Request httpRequest, + @override final RelatedTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchRelated(httpRequest, target); +} + +final class const FetchRelationshipRequest( + @override final Request httpRequest, + @override final RelationshipTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => + controller.fetchRelationship(httpRequest, target); +} + +final class const AddManyRequest( + @override final Request httpRequest, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + ToMany get relationship => doc.asToMany(); + + @override + Future dispatch(Controller controller) => + controller.addMany(httpRequest, target, relationship); +} + +final class const ReplaceRelationshipRequest( + @override final Request httpRequest, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + Relationship get relationship => doc.asRelationship(); + + @override + Future dispatch(Controller controller) => + controller.replaceRelationship(httpRequest, target, relationship); +} + +final class const DeleteManyRequest( + @override final Request httpRequest, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + ToMany get relationship => doc.asToMany(); + + @override + Future dispatch(Controller controller) => + controller.deleteMany(httpRequest, target, relationship); +} + +Future _decode(Request r) async { + final body = await r.body.decode(utf8); + if (body.isEmpty) return null; + try { + return InboundDocument(await const PayloadCodec().decode(body)); + } on FormatException { + throw PayloadInvalid(); + } +} diff --git a/lib/src/server/request_validator_middleware.dart b/lib/src/server/request_validator_middleware.dart new file mode 100644 index 00000000..596897cf --- /dev/null +++ b/lib/src/server/request_validator_middleware.dart @@ -0,0 +1,26 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:json_api/src/media_type.dart'; +import 'package:json_api/src/server/errors/not_acceptable.dart'; +import 'package:json_api/src/server/errors/unsupported_media_type.dart'; + +final requestValidatorMiddleware = middleware( + onRequest: (Request request) async { + final contentType = request.headers['Content-Type']?.last; + if (contentType != null && _isInvalid(MediaType.parse(contentType))) { + throw UnsupportedMediaType(); + } + if ((request.headers['Accept'] ?? []) + .expand((it) => it.split(',')) + .map((it) => it.trim()) + .map(MediaType.parse) + .any(_isInvalid)) { + throw NotAcceptable(); + } + return null; + }, +); + +bool _isInvalid(MediaType mt) => + mt.mimeType == mediaType && mt.parameters.isNotEmpty; // TODO: check for ext and profile diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 16109f9c..80a51c00 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -4,35 +4,43 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response -Response response(int statusCode, {OutboundDocument? document}) { - final r = Response( - statusCode, document != null ? Body.json(document) : Body(), Headers()); - if (document != null) { - r.headers['Content-Type'] = [mediaType]; - } - return r; -} - -Response ok(OutboundDocument document) => - response(StatusCode.ok, document: document); +Response response(int statusCode, {OutboundDocument? document}) => + Response( + statusCode, + document != null ? Body.json(document, toEncodable: toJsonEncodable) : Body.empty(), + Headers(), + ) + ..headers.addAll({ + if (document != null) 'Content-Type': [mediaType], + }); + +Response ok(OutboundDocument document) => response(StatusCode.ok, document: document); Response noContent() => response(StatusCode.noContent); Response created(OutboundDocument document, String location) => - response(StatusCode.created, document: document) - ..headers['location'] = [location]; + response(StatusCode.created, document: document)..headers['location'] = [location]; Response notFound([OutboundErrorDocument? document]) => response(StatusCode.notFound, document: document); +Response conflict([OutboundErrorDocument? document]) => + response(StatusCode.conflict, document: document); + Response methodNotAllowed([OutboundErrorDocument? document]) => response(StatusCode.methodNotAllowed, document: document); Response badRequest([OutboundErrorDocument? document]) => response(StatusCode.badRequest, document: document); +Response forbidden([OutboundErrorDocument? document]) => + response(StatusCode.forbidden, document: document); + Response unsupportedMediaType([OutboundErrorDocument? document]) => response(StatusCode.unsupportedMediaType, document: document); -Response unacceptable([OutboundErrorDocument? document]) => - response(StatusCode.unacceptable, document: document); +Response notAcceptable([OutboundErrorDocument? document]) => + response(StatusCode.notAcceptable, document: document); + +Response internalServerError([OutboundErrorDocument? document]) => + response(StatusCode.internalServerError, document: document); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart new file mode 100644 index 00000000..be07fa8b --- /dev/null +++ b/lib/src/server/router.dart @@ -0,0 +1,18 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; + +typedef TargetResolver = Target? Function(Request); +typedef ControllerResolver = Controller? Function(Request, Target); +typedef PayloadHandler = Future Function(JsonApiRequest request, Controller controller); + +Handler router({ + required Target? Function(Request) getTarget, + required Controller? Function(Request, Target) getController, + PayloadHandler? beforeDispatch, +}) => (request) async { + final target = getTarget(request) ?? (throw UnmatchedTarget(request.uri)); + final controller = getController(request, target) ?? (throw CollectionNotFound(target.type)); + final resolved = await JsonApiRequest.resolve(request, target); + return (await beforeDispatch?.call(resolved, controller)) ?? await resolved.dispatch(controller); +}; diff --git a/lib/src/server/try_catch_middleware.dart b/lib/src/server/try_catch_middleware.dart deleted file mode 100644 index b9cba2b4..00000000 --- a/lib/src/server/try_catch_middleware.dart +++ /dev/null @@ -1,21 +0,0 @@ -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. - -Handler tryCatchMiddleware(Handler handler, - {Future Function(dynamic, StackTrace)? onError}) => - (Request request) async { - try { - return await handler(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 75619d36..05f26253 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,24 +1,25 @@ name: json_api -version: 8.1.0 +version: 9.0.0-alpha.9 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: '>=3.4.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' dependencies: http_parser: ^4.0.0 - http_interop: ^2.0.0 + http_interop: ^3.0.0 + http_interop_middleware: ^0.2.0 dev_dependencies: - lints: ^4.0.0 + lints: ^6.1.0 test: ^1.21.1 stream_channel: ^2.1.0 uuid: ^4.2.0 coverage: ^1.3.0 check_coverage: ^0.0.4 http: ^1.2.1 - http_interop_http: ^0.9.0 - http_interop_io: ^0.9.0 + http_interop_http: ^0.10.0 + http_interop_io: ^0.10.0 cider: diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index e2af2547..2a90886b 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -10,7 +10,7 @@ void main() { late RoutingClient client; setUp(() async { - client = RoutingClient(StandardUriDesign.pathOnly, Client(testHandler())); + client = RoutingClient(StandardUriDesign.pathOnly.uri, Client(testHandler())); }); group('CRUD', () { @@ -21,31 +21,34 @@ void main() { late Resource secretComment; setUp(() async { - alice = (await client.createNew('users', attributes: {'name': 'Alice'})) - .resource; - bob = (await client.createNew('users', attributes: {'name': 'Bob'})) - .resource; - post = (await client.createNew('posts', - attributes: {'title': 'Hello world'}, - one: {'author': alice.toIdentifier()}, - many: {'comments': []})) - .resource; - comment = (await client.createNew('comments', - attributes: {'text': 'Hi Alice'}, - one: {'author': bob.toIdentifier()})) - .resource; - secretComment = (await client.createNew('comments', - attributes: {'text': 'Secret comment'}, - one: {'author': bob.toIdentifier()})) - .resource; - await client - .addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); + alice = (await client.createNew('users', attributes: {'name': 'Alice'})).resource; + bob = (await client.createNew('users', attributes: {'name': 'Bob'})).resource; + post = (await client.createNew( + 'posts', + attributes: {'title': 'Hello world'}, + one: {'author': alice.toIdentifier()}, + many: {'comments': []}, + )).resource; + comment = (await client.createNew( + 'comments', + attributes: {'text': 'Hi Alice'}, + one: {'author': bob.toIdentifier()}, + )).resource; + secretComment = (await client.createNew( + 'comments', + attributes: {'text': 'Secret comment'}, + one: {'author': bob.toIdentifier()}, + )).resource; + await client.addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); }); test('Fetch a complex resource', () async { - final response = await client.fetchCollection('posts', query: [ - Include(['author', 'comments', 'comments.author']) - ]); + final response = await client.fetchCollection( + 'posts', + query: [ + Include(['author', 'comments', 'comments.author']), + ], + ); expect(response.rawResponse.httpResponse.statusCode, 200); expect(response.collection.length, 1); @@ -59,8 +62,7 @@ void main() { .single; expect(fetchedAuthor.attributes['name'], 'Alice'); - final fetchedComment = - fetchedPost.many('comments')!.findIn(response.included).single; + final fetchedComment = fetchedPost.many('comments')!.findIn(response.included).single; expect(fetchedComment.attributes['text'], 'Hi Alice'); }); @@ -72,8 +74,7 @@ void main() { }); test('Update a resource', () async { - await client.updateResource(post.type, post.id, - attributes: {'title': 'Bob was here'}); + await client.updateResource(post.type, post.id, attributes: {'title': 'Bob was here'}); await client.fetchCollection('posts').then((r) { expect(r.collection.single.attributes['title'], 'Bob was here'); }); @@ -86,9 +87,7 @@ void main() { }); test('Fetch a related collection', () async { - await client - .fetchRelatedCollection(post.type, post.id, 'comments') - .then((r) { + await client.fetchRelatedCollection(post.type, post.id, 'comments').then((r) { expect(r.collection.single.attributes['text'], 'Hi Alice'); }); }); @@ -107,57 +106,67 @@ void main() { test('Delete a to-one relationship', () async { await client.deleteToOne(post.type, post.id, 'author'); - await client.fetchResource(post.type, post.id, query: [ - Include(['author']) - ]).then((r) { - expect(r.resource.one('author'), isEmpty); - }); + 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', 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'); - }); + await client.replaceToOne(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', [comment.toIdentifier()]); + await client.deleteFromMany(post.type, post.id, 'comments', [comment.toIdentifier()]); await client.fetchResource(post.type, post.id).then((r) { expect(r.resource.many('comments'), isEmpty); }); }); test('Replace a to-many relationship', () async { - await client.replaceToMany( - post.type, post.id, 'comments', [secretComment.toIdentifier()]); - await client.fetchResource(post.type, post.id, query: [ - Include(['comments']) - ]).then((r) { - expect( - r.resource - .many('comments')! - .findIn(r.included) - .single - .attributes['text'], - 'Secret comment'); - expect( - r.resource - .many('comments')! - .findIn(r.included) - .single - .attributes['text'], - 'Secret comment'); - }); + await client.replaceToMany(post.type, post.id, 'comments', [secretComment.toIdentifier()]); + await client + .fetchResource( + post.type, + post.id, + query: [ + Include(['comments']), + ], + ) + .then((r) { + expect( + r.resource.many('comments')!.findIn(r.included).single.attributes['text'], + 'Secret comment', + ); + expect( + r.resource.many('comments')!.findIn(r.included).single.attributes['text'], + 'Secret comment', + ); + }); }); test('Incomplete relationship', () async {}); @@ -186,12 +195,11 @@ 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))); + 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; + 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 96d10f02..d8d5f8fc 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,5 +1,6 @@ import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; +import 'package:json_api/server.dart'; import 'package:test/test.dart'; import '../test_handler.dart'; @@ -8,17 +9,48 @@ void main() { late Client client; setUp(() async { - client = Client(testHandler()); + client = Client( + testHandler( + onPayload: (request, controller) async { + if (request is CreateResourceRequest && request.newResource.id != null) { + throw ClientGeneratedIdNotSupported(); + } + return null; + }, + ), + ); }); group('Errors', () { + test('Forbidden when client-generated id is not supported', () async { + final r = await client.send( + Uri.parse('/posts'), + Request.post({ + 'data': {'type': 'posts', 'id': 'client-generated-id'}, + }), + ); + expect(r.httpResponse.statusCode, 403); + }); + test('Bad request when payload is missing', () async { + final r = await testHandler()( + http.Request('POST', Uri.parse('/posts'), http.Body.empty(), http.Headers()), + ); + expect(r.statusCode, 400); + }); + for (final payload in ['{', '[]', 'null', '42', '"text"']) { + test('Bad request when payload is invalid: $payload', () async { + final r = await testHandler()( + http.Request('POST', Uri.parse('/posts'), http.Body.text(payload), http.Headers()), + ); + expect(r.statusCode, 400); + }); + } test('Method not allowed', () async { final actions = [ - () => client.send(Uri.parse('/posts'), Request('delete')), - () => client.send(Uri.parse('/posts/1'), Request('post')), - () => client.send(Uri.parse('/posts/1/author'), Request('post')), - () => client.send( - Uri.parse('/posts/1/relationships/author'), Request('head')), + () => client.send(Uri.parse('/posts'), Request('DELETE')), + () => client.send(Uri.parse('/posts/1'), Request('POST')), + () => client.send(Uri.parse('/posts/1/author'), Request('POST')), + () => client.send(Uri.parse('/posts/1/relationships/author'), Request('head')), ]; for (final action in actions) { final response = await action(); @@ -26,30 +58,37 @@ void main() { } }); test('Bad request when target can not be matched', () async { - final r = await testHandler()(http.Request( - 'get', Uri.parse('/a/long/prefix/'), http.Body(), http.Headers())); + final r = await testHandler()( + http.Request('GET', Uri.parse('/a/long/prefix/'), http.Body.empty(), http.Headers()), + ); expect(r.statusCode, 400); }); test('Unsupported extension', () async { - final r = await testHandler()(http.Request( - 'get', + final r = await testHandler()( + http.Request( + 'GET', Uri.parse('/posts/1'), - http.Body(), + http.Body.empty(), http.Headers.from({ 'Content-Type': ['application/vnd.api+json; ext=foobar'], - 'Accept': ['application/vnd.api+json'] - }))); + 'Accept': ['application/vnd.api+json'], + }), + ), + ); expect(r.statusCode, 415); }); test('Unacceptable', () async { - final r = await testHandler()(http.Request( - 'get', + final r = await testHandler()( + http.Request( + 'GET', Uri.parse('/posts/1'), - http.Body(), + http.Body.empty(), http.Headers.from({ 'Content-Type': ['application/vnd.api+json'], - 'Accept': ['application/vnd.api+json; ext=foobar'] - }))); + '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 c29991a4..864fc3e8 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -9,16 +9,14 @@ void main() { late RoutingClient client; setUp(() async { - client = RoutingClient(StandardUriDesign.pathOnly, Client(testHandler())); + client = RoutingClient(StandardUriDesign.pathOnly.uri, Client(testHandler())); }); group('Resource creation', () { test('Resource id assigned on the server', () async { - await client - .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { + await client.createNew('posts', attributes: {'title': 'Hello world'}).then((r) { expect(r.rawResponse.httpResponse.statusCode, 201); - expect(r.rawResponse.httpResponse.headers['location'], - ['/posts/${r.resource.id}']); + expect(r.rawResponse.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'); @@ -27,23 +25,25 @@ void main() { }); 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.rawResponse.httpResponse.statusCode, 201); - expect(r.rawResponse.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}'); - }); + await client + .createNew( + 'posts', + lid: 'lid', + attributes: {'title': 'Hello world'}, + one: {'self': LocalIdentifier('posts', 'lid')}, + ) + .then((r) { + expect(r.rawResponse.httpResponse.statusCode, 201); + expect(r.rawResponse.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) { + await client.create('posts', '12345', attributes: {'title': 'Hello world'}).then((r) { expect(r.rawResponse.httpResponse.statusCode, 204); expect(r.resource, isNull); expect(r.rawResponse.httpResponse.headers['location'], isNull); diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index decb6977..534868fa 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -11,8 +11,10 @@ void main() { setUpAll(() 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())).uri, + Client(oneOffHandler), + ); }); testLocationIsSet(() => client); diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index 6dc67ec4..0ba53bcd 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -12,8 +12,7 @@ Future testAllHttpMethods(RoutingClient Function() client) async { }); }); test('PATCH', () async { - await client() - .updateResource('posts', id, attributes: {'title': 'Bye world'}); + await client().updateResource('posts', id, attributes: {'title': 'Bye world'}); await client().fetchResource('posts', id).then((r) { expect(r.resource.attributes['title'], 'Bye world'); }); @@ -28,8 +27,7 @@ Future testAllHttpMethods(RoutingClient Function() client) async { void testLocationIsSet(RoutingClient Function() client) { test('Location is set', () async { - final r = await client() - .createNew('posts', attributes: {'title': 'Location test'}); + final r = await client().createNew('posts', attributes: {'title': 'Location test'}); expect(r.rawResponse.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 71121a38..1d31a308 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -6,8 +6,11 @@ import '../test_handler.dart'; void hybridMain(StreamChannel channel) async { const host = 'localhost'; const port = 8888; - final server = JsonApiServer(testHandler(onRequest: print, onResponse: print), - host: host, port: port); + final server = JsonApiServer( + testHandler(onRequest: print, onResponse: print), + host: host, + port: port, + ); await server.start(); channel.sink.add('http://$host:$port'); } diff --git a/test/e2e/one_off_handler.dart b/test/e2e/one_off_handler.dart index fe86e6d5..fb1988b3 100644 --- a/test/e2e/one_off_handler.dart +++ b/test/e2e/one_off_handler.dart @@ -4,7 +4,7 @@ import 'package:http_interop_http/http_interop_http.dart'; Future oneOffHandler(Request request) async { final client = h.Client(); - final response = await client.handleInterop(request); + final response = await client.sendInterop(request); client.close(); return response; } diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 50d1e128..89ed6d47 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -16,9 +16,9 @@ void main() { server = JsonApiServer(testHandler(), port: 8001); await server.start(); client = RoutingClient( - StandardUriDesign( - Uri(scheme: 'http', host: server.host, port: server.port)), - Client(oneOffHandler)); + StandardUriDesign(Uri(scheme: 'http', host: server.host, port: server.port)).uri, + Client(oneOffHandler), + ); }); tearDownAll(() async { diff --git a/test/test_handler.dart b/test/test_handler.dart index e9177b12..d876cace 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -1,31 +1,30 @@ import 'package:http_interop/http_interop.dart'; -import 'package:json_api/http.dart'; +import 'package:http_interop_middleware/http_interop_middleware.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'; -Handler testHandler( - {Iterable types = const ['users', 'posts', 'comments'], - Function(Request request)? onRequest, - Function(Response response)? onResponse, - Future Function(dynamic, StackTrace)? onError}) => - loggingMiddleware( - corsMiddleware(tryCatchMiddleware( - ControllerRouter( - RepositoryController( - InMemoryRepo(types), () => (_counter++).toString()), - StandardUriDesign.matchTarget) - .handle, - onError: ErrorConverter( - onError: onError ?? - (err, trace) { - print(trace); - throw err; - }) - .call)), - onRequest: onRequest, - onResponse: onResponse); +Handler testHandler({ + Iterable types = const ['users', 'posts', 'comments'], + Function(Request request)? onRequest, + Function(Response response)? onResponse, + PayloadHandler? onPayload, +}) { + final controller = RepositoryController(InMemoryRepo(types), _nextId); + return [ + corsMiddleware, + requestValidatorMiddleware, + errorConverter(onError: (e, _) => throw e), + ].combine().call( + router( + getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), + getController: (_, _) => controller, + beforeDispatch: onPayload, + ), + ); +} +String _nextId() => (_counter++).toString(); int _counter = 0; diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 1ce542a5..1a76513e 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -12,8 +12,7 @@ import 'response.dart' as mock; void main() { final mockHandler = MockHandler(); - final client = - RoutingClient(StandardUriDesign.pathOnly, Client(mockHandler.handle)); + final client = RoutingClient(StandardUriDesign.pathOnly.uri, Client(mockHandler.handle)); group('Failure', () { test('RequestFailure', () async { @@ -45,44 +44,48 @@ void main() { expect(response.collection.single.type, 'articles'); expect(response.collection.single.id, '1'); expect(response.included, isEmpty); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); }); test('Full', () async { mockHandler.response = mock.collectionFull(); - final response = await client.fetchCollection('articles', headers: { - 'foo': ['bar'] - }, query: [ - Query({ - 'foo': ['bar'] - }), - Include(['author']), - Fields({ - 'author': ['name'] - }), - Page({'limit': '10'}), - Sort(['title', '-date']) - ]); + final response = await client.fetchCollection( + 'articles', + headers: { + '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(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/articles'); expect(mockHandler.request.uri.queryParameters, { 'include': 'author', 'fields[author]': 'name', 'sort': 'title,-date', 'page[limit]': '10', - 'foo': 'bar' + 'foo': 'bar', }); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(response.meta, {'hello': 'world'}); @@ -91,47 +94,51 @@ void main() { group('Fetch Related Collection', () { test('Min', () async { mockHandler.response = mock.collectionFull(); - final response = - await client.fetchRelatedCollection('people', '1', 'articles'); + final response = await client.fetchRelatedCollection('people', '1', 'articles'); expect(response.collection.length, 1); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/people/1/articles'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); test('Full', () async { mockHandler.response = mock.collectionFull(); - final response = await client - .fetchRelatedCollection('people', '1', 'articles', headers: { - 'foo': ['bar'] - }, query: [ - Query({ - 'foo': ['bar'] - }), - Include(['author']), - Page({'limit': '10'}), - Fields({ - 'author': ['name'] - }), - Sort(['title', '-date']) - ]); + final response = await client.fetchRelatedCollection( + 'people', + '1', + 'articles', + headers: { + '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(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/people/1/articles'); expect(mockHandler.request.uri.queryParameters, { 'include': 'author', 'fields[author]': 'name', 'sort': 'title,-date', 'page[limit]': '10', - 'foo': 'bar' + 'foo': 'bar', }); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(response.meta, {'hello': 'world'}); @@ -143,35 +150,43 @@ void main() { mockHandler.response = mock.primaryResource(); final response = await client.fetchResource('articles', '1'); expect(response.resource.type, 'articles'); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.toString(), '/articles/1'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); test('Full', () async { mockHandler.response = mock.primaryResource(); - final response = await client.fetchResource('articles', '1', headers: { - 'foo': ['bar'] - }, query: [ - Query({ - 'foo': ['bar'] - }), - Include(['author']), - Fields({ - 'author': ['name'] - }) - ]); + final response = await client.fetchResource( + 'articles', + '1', + headers: { + 'foo': ['bar'], + }, + query: [ + Query({ + 'foo': ['bar'], + }), + Include(['author']), + Fields({ + 'author': ['name'], + }), + ], + ); expect(response.resource.type, 'articles'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/articles/1'); - expect(mockHandler.request.uri.queryParameters, - {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(mockHandler.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'foo': 'bar', + }); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(response.meta, {'hello': 'world'}); @@ -181,40 +196,47 @@ void main() { group('Fetch Related Resource', () { test('Min', () async { mockHandler.response = mock.primaryResource(); - final response = - await client.fetchRelatedResource('articles', '1', 'author'); + final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.toString(), '/articles/1/author'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); test('Full', () async { mockHandler.response = mock.primaryResource(); - final response = await client - .fetchRelatedResource('articles', '1', 'author', headers: { - 'foo': ['bar'] - }, query: [ - Query({ - 'foo': ['bar'] - }), - Include(['author']), - Fields({ - 'author': ['name'] - }) - ]); + final response = await client.fetchRelatedResource( + 'articles', + '1', + 'author', + headers: { + 'foo': ['bar'], + }, + query: [ + Query({ + 'foo': ['bar'], + }), + Include(['author']), + Fields({ + 'author': ['name'], + }), + ], + ); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/articles/1/author'); - expect(mockHandler.request.uri.queryParameters, - {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(mockHandler.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'foo': 'bar', + }); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(response.meta, {'hello': 'world'}); @@ -222,14 +244,13 @@ void main() { test('Missing resource', () async { mockHandler.response = mock.relatedResourceNull(); - final response = - await client.fetchRelatedResource('articles', '1', 'author'); + final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource, isNull); expect(response.included, isEmpty); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.toString(), '/articles/1/author'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); }); @@ -239,31 +260,35 @@ void main() { mockHandler.response = mock.one(); final response = await client.fetchToOne('articles', '1', 'author'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('get')); - expect(mockHandler.request.uri.toString(), - '/articles/1/relationships/author'); + expect(mockHandler.request.method, equals('GET')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); test('Full', () async { mockHandler.response = mock.one(); - final response = - await client.fetchToOne('articles', '1', 'author', headers: { - 'foo': ['bar'] - }, query: [ - Query({ - 'foo': ['bar'] - }) - ]); + final response = await client.fetchToOne( + 'articles', + '1', + 'author', + headers: { + 'foo': ['bar'], + }, + query: [ + Query({ + 'foo': ['bar'], + }), + ], + ); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.method, equals('GET')); expect(mockHandler.request.uri.path, '/articles/1/relationships/author'); expect(mockHandler.request.uri.queryParameters, {'foo': 'bar'}); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); }); }); @@ -273,45 +298,45 @@ void main() { mockHandler.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.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('post')); + expect(mockHandler.request.method, equals('POST')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'articles'} + 'data': {'type': 'articles'}, }); }); test('Full', () async { mockHandler.response = mock.primaryResource(); - final response = await client.createNew('articles', attributes: { - 'cool': true - }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) - }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] - }, meta: { - 'answer': 42 - }, documentMeta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.createNew( + 'articles', + attributes: {'cool': true}, + one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}), + }, + many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')], + }, + meta: {'answer': 42}, + documentMeta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.resource.type, 'articles'); - expect( - response.links['self'].toString(), 'http://example.com/articles/1'); + expect(response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); - expect(mockHandler.request.method, equals('post')); + expect(mockHandler.request.method, equals('POST')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { @@ -322,19 +347,19 @@ void main() { 'data': { 'type': 'people', 'id': '42', - 'meta': {'hey': 'yos'} - } + 'meta': {'hey': 'yos'}, + }, }, 'tags': { 'data': [ {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } + {'type': 'tags', 'id': '2'}, + ], + }, }, - 'meta': {'answer': 42} + 'meta': {'answer': 42}, }, - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); expect(response.meta, {'hello': 'world'}); @@ -346,14 +371,14 @@ void main() { mockHandler.response = mock.primaryResource(); final response = await client.create('articles', '1'); expect(response.resource?.type, 'articles'); - expect(mockHandler.request.method, equals('post')); + expect(mockHandler.request.method, equals('POST')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'articles', 'id': '1'} + 'data': {'type': 'articles', 'id': '1'}, }); }); @@ -361,39 +386,42 @@ void main() { mockHandler.response = mock.noContent(); final response = await client.create('articles', '1'); expect(response.resource, isNull); - expect(mockHandler.request.method, equals('post')); + expect(mockHandler.request.method, equals('POST')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'articles', 'id': '1'} + 'data': {'type': 'articles', 'id': '1'}, }); }); test('Full', () async { mockHandler.response = mock.primaryResource(); - final response = await client.create('articles', '1', attributes: { - 'cool': true - }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) - }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] - }, meta: { - 'answer': 42 - }, documentMeta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.create( + 'articles', + '1', + attributes: {'cool': true}, + one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}), + }, + many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')], + }, + meta: {'answer': 42}, + documentMeta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.resource?.type, 'articles'); - expect(mockHandler.request.method, equals('post')); + expect(mockHandler.request.method, equals('POST')); expect(mockHandler.request.uri.toString(), '/articles'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { @@ -405,19 +433,19 @@ void main() { 'data': { 'type': 'people', 'id': '42', - 'meta': {'hey': 'yos'} - } + 'meta': {'hey': 'yos'}, + }, }, 'tags': { 'data': [ {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } + {'type': 'tags', 'id': '2'}, + ], + }, }, - 'meta': {'answer': 42} + 'meta': {'answer': 42}, }, - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); expect(response.meta, {'hello': 'world'}); @@ -429,14 +457,14 @@ void main() { mockHandler.response = mock.primaryResource(); final response = await client.updateResource('articles', '1'); expect(response.resource?.type, 'articles'); - expect(mockHandler.request.method, equals('patch')); + expect(mockHandler.request.method, equals('PATCH')); expect(mockHandler.request.uri.toString(), '/articles/1'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'articles', 'id': '1'} + 'data': {'type': 'articles', 'id': '1'}, }); }); @@ -444,40 +472,42 @@ void main() { mockHandler.response = mock.noContent(); final response = await client.updateResource('articles', '1'); expect(response.resource, isNull); - expect(mockHandler.request.method, equals('patch')); + expect(mockHandler.request.method, equals('PATCH')); expect(mockHandler.request.uri.toString(), '/articles/1'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'articles', 'id': '1'} + 'data': {'type': 'articles', 'id': '1'}, }); }); test('Full', () async { mockHandler.response = mock.primaryResource(); - final response = - await client.updateResource('articles', '1', attributes: { - 'cool': true - }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) - }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] - }, meta: { - 'answer': 42 - }, documentMeta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.updateResource( + 'articles', + '1', + attributes: {'cool': true}, + one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}), + }, + many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')], + }, + meta: {'answer': 42}, + documentMeta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.resource?.type, 'articles'); - expect(mockHandler.request.method, equals('patch')); + expect(mockHandler.request.method, equals('PATCH')); expect(mockHandler.request.uri.toString(), '/articles/1'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { @@ -489,19 +519,19 @@ void main() { 'data': { 'type': 'people', 'id': '42', - 'meta': {'hey': 'yos'} - } + 'meta': {'hey': 'yos'}, + }, }, 'tags': { 'data': [ {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } + {'type': 'tags', 'id': '2'}, + ], + }, }, - 'meta': {'answer': 42} + 'meta': {'answer': 42}, }, - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); expect(response.meta, {'hello': 'world'}); @@ -512,50 +542,53 @@ void main() { test('Min', () async { mockHandler.response = mock.one(); final response = await client.replaceToOne( - 'articles', '1', 'author', Identifier('people', '42')); + 'articles', + '1', + 'author', + Identifier('people', '42'), + ); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('patch')); - expect(mockHandler.request.uri.toString(), - '/articles/1/relationships/author'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { - 'data': {'type': 'people', 'id': '42'} + 'data': {'type': 'people', 'id': '42'}, }); }); test('Full', () async { mockHandler.response = mock.one(); final response = await client.replaceToOne( - 'articles', '1', 'author', Identifier('people', '42'), - meta: { - 'hello': 'world' - }, - headers: { - 'foo': ['bar'] - }); + 'articles', + '1', + 'author', + Identifier('people', '42'), + meta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('patch')); - expect(mockHandler.request.uri.toString(), - '/articles/1/relationships/author'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'people', 'id': '42'}, - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); }); test('Throws RequestFailure', () async { mockHandler.response = mock.error422(); try { - await client.replaceToOne( - 'articles', '1', 'author', Identifier('people', '42')); + await client.replaceToOne('articles', '1', 'author', Identifier('people', '42')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.rawResponse.httpResponse.statusCode, 422); @@ -566,9 +599,9 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.many(); expect( - () => client.replaceToOne( - 'articles', '1', 'author', Identifier('people', '42')), - throwsFormatException); + () => client.replaceToOne('articles', '1', 'author', Identifier('people', '42')), + throwsFormatException, + ); }); }); @@ -578,35 +611,35 @@ void main() { final response = await client.deleteToOne('articles', '1', 'author'); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(mockHandler.request.method, equals('patch')); - expect(mockHandler.request.uri.toString(), - '/articles/1/relationships/author'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); - expect(jsonDecode(await mockHandler.request.body.decode(utf8)), - {'data': null}); + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {'data': null}); }); test('Full', () async { mockHandler.response = mock.oneEmpty(); - final response = - await client.deleteToOne('articles', '1', 'author', headers: { - 'foo': ['bar'] - }); + final response = await client.deleteToOne( + 'articles', + '1', + 'author', + headers: { + 'foo': ['bar'], + }, + ); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(mockHandler.request.method, equals('patch')); - expect(mockHandler.request.uri.toString(), - '/articles/1/relationships/author'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); - expect(jsonDecode(await mockHandler.request.body.decode(utf8)), - {'data': null}); + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {'data': null}); }); test('Throws RequestFailure', () async { @@ -622,62 +655,62 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.many(); - expect(() => client.deleteToOne('articles', '1', 'author'), - throwsFormatException); + expect(() => client.deleteToOne('articles', '1', 'author'), throwsFormatException); }); }); group('Delete Many', () { test('Min', () async { mockHandler.response = mock.many(); - final response = await client - .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); + final response = await client.deleteFromMany('articles', '1', 'tags', [ + Identifier('tags', '1'), + ]); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('delete')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('DELETE')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} - ] + {'type': 'tags', 'id': '1'}, + ], }); }); test('Full', () async { mockHandler.response = mock.many(); - final response = await client.deleteFromMany('articles', '1', 'tags', [ - Identifier('tags', '1') - ], meta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.deleteFromMany( + 'articles', + '1', + 'tags', + [Identifier('tags', '1')], + meta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('delete')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('DELETE')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} + {'type': 'tags', 'id': '1'}, ], - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); }); test('Throws RequestFailure', () async { mockHandler.response = mock.error422(); try { - await client - .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); + await client.deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.rawResponse.httpResponse.statusCode, 422); @@ -688,63 +721,64 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => client.deleteFromMany( - 'articles', '1', 'tags', [Identifier('tags', '1')]), - throwsFormatException); + () => client.deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException, + ); }); }); group('Replace Many', () { test('Min', () async { mockHandler.response = mock.many(); - final response = await client - .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); + final response = await client.replaceToMany('articles', '1', 'tags', [ + Identifier('tags', '1'), + ]); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} - ] + {'type': 'tags', 'id': '1'}, + ], }); }); test('Full', () async { mockHandler.response = mock.many(); - final response = await client.replaceToMany('articles', '1', 'tags', [ - Identifier('tags', '1') - ], meta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.replaceToMany( + 'articles', + '1', + 'tags', + [Identifier('tags', '1')], + meta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('PATCH')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} + {'type': 'tags', 'id': '1'}, ], - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); }); test('Throws RequestFailure', () async { mockHandler.response = mock.error422(); try { - await client - .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); + await client.replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.rawResponse.httpResponse.statusCode, 422); @@ -755,63 +789,62 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => client.replaceToMany( - 'articles', '1', 'tags', [Identifier('tags', '1')]), - throwsFormatException); + () => client.replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException, + ); }); }); group('Add Many', () { test('Min', () async { mockHandler.response = mock.many(); - final response = await client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); + final response = await client.addMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('post')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('POST')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], - 'Content-Type': ['application/vnd.api+json'] + 'Content-Type': ['application/vnd.api+json'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} - ] + {'type': 'tags', 'id': '1'}, + ], }); }); test('Full', () async { mockHandler.response = mock.many(); - final response = await client.addMany('articles', '1', 'tags', [ - Identifier('tags', '1') - ], meta: { - 'hello': 'world' - }, headers: { - 'foo': ['bar'] - }); + final response = await client.addMany( + 'articles', + '1', + 'tags', + [Identifier('tags', '1')], + meta: {'hello': 'world'}, + headers: { + 'foo': ['bar'], + }, + ); expect(response.relationship, isA()); - expect(mockHandler.request.method, equals('post')); - expect( - mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); + expect(mockHandler.request.method, equals('POST')); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], - 'foo': ['bar'] + 'foo': ['bar'], }); expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ - {'type': 'tags', 'id': '1'} + {'type': 'tags', 'id': '1'}, ], - 'meta': {'hello': 'world'} + 'meta': {'hello': 'world'}, }); }); test('Throws RequestFailure', () async { mockHandler.response = mock.error422(); try { - await client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); + await client.addMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.rawResponse.httpResponse.statusCode, 422); @@ -823,9 +856,9 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), - throwsFormatException); + () => client.addMany('articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException, + ); }); }); } diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart index a0ad79e8..dbe1e027 100644 --- a/test/unit/client/mock_handler.dart +++ b/test/unit/client/mock_handler.dart @@ -1,6 +1,6 @@ import 'package:http_interop/http_interop.dart'; -class MockHandler { +class MockHandler() { late Request request; late Response response; diff --git a/test/unit/client/payload_codec_test.dart b/test/unit/client/payload_codec_test.dart index a4dde408..327186ba 100644 --- a/test/unit/client/payload_codec_test.dart +++ b/test/unit/client/payload_codec_test.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/client/payload_codec.dart'; +import 'package:json_api/src/document/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 a5569c8c..e507222f 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -2,273 +2,256 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/src/media_type.dart'; final headers = Headers.from({ - 'Content-Type': [mediaType] + 'Content-Type': [mediaType], }); -collectionMin() => Response( - 200, - Body.json({ - 'data': [ - {'type': 'articles', 'id': '1'} - ] - }), - headers); +Response collectionMin() => Response( + 200, + Body.json({ + 'data': [ + {'type': 'articles', 'id': '1'}, + ], + }), + headers, +); -collectionFull() => Response( - 200, - Body.json({ - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page[offset]=2', - 'last': 'http://example.com/articles?page[offset]=10' - }, - 'meta': {'hello': 'world'}, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'relationships': { - 'author': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/author', - 'related': 'http://example.com/articles/1/author' - }, - 'data': {'type': 'people', 'id': '9'} +Response collectionFull() => Response( + 200, + Body.json({ + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10', + }, + 'meta': {'hello': 'world'}, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author', }, - 'comments': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/comments', - 'related': 'http://example.com/articles/1/comments' - }, - 'data': [ - {'type': 'comments', 'id': '5'}, - {'type': 'comments', 'id': '12'} - ] - } + 'data': {'type': 'people', 'id': '9'}, }, - 'links': {'self': 'http://example.com/articles/1'} - } - ], - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments', + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'}, + ], }, - 'links': {'self': 'http://example.com/people/9'} }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } + 'links': {'self': 'http://example.com/articles/1'}, + }, + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, + 'links': {'self': 'http://example.com/people/9'}, + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'}, }, - 'links': {'self': 'http://example.com/comments/5'} }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } + 'links': {'self': 'http://example.com/comments/5'}, + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'}, }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - }), - headers); + }, + 'links': {'self': 'http://example.com/comments/12'}, + }, + ], + }), + headers, +); -primaryResource() => Response( - 200, - Body.json({ - 'links': {'self': 'http://example.com/articles/1'}, - 'meta': {'hello': 'world'}, - 'data': { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, +Response primaryResource() => Response( + 200, + Body.json({ + 'links': {'self': 'http://example.com/articles/1'}, + 'meta': {'hello': 'world'}, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'}, + }, + }, + }, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, + 'links': {'self': 'http://example.com/people/9'}, + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, 'relationships': { 'author': { - 'links': {'related': 'http://example.com/articles/1/author'} - } - } - }, - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' + 'data': {'type': 'people', 'id': '2'}, }, - 'links': {'self': 'http://example.com/people/9'} }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } + 'links': {'self': 'http://example.com/comments/5'}, + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'}, }, - 'links': {'self': 'http://example.com/comments/5'} }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - }), - headers); + 'links': {'self': 'http://example.com/comments/12'}, + }, + ], + }), + headers, +); -relatedResourceNull() => Response( - 200, - Body.json({ - 'links': {'self': 'http://example.com/articles/1/author'}, - 'meta': {'hello': 'world'}, - 'data': null - }), - headers); +Response relatedResourceNull() => Response( + 200, + Body.json({ + 'links': {'self': 'http://example.com/articles/1/author'}, + 'meta': {'hello': 'world'}, + 'data': null, + }), + headers, +); -one() => Response( - 200, - Body.json({ - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' +Response one() => Response( + 200, + Body.json({ + 'links': {'self': '/articles/1/relationships/author', 'related': '/articles/1/author'}, + 'meta': {'hello': 'world'}, + 'data': {'type': 'people', 'id': '12'}, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, + 'links': {'self': 'http://example.com/people/9'}, }, - 'meta': {'hello': 'world'}, - 'data': {'type': 'people', 'id': '12'}, - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'}, }, - 'links': {'self': 'http://example.com/people/9'} }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } + 'links': {'self': 'http://example.com/comments/5'}, + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'}, }, - 'links': {'self': 'http://example.com/comments/5'} }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - }), - headers); + 'links': {'self': 'http://example.com/comments/12'}, + }, + ], + }), + headers, +); -oneEmpty() => Response( - 200, - Body.json({ - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' +Response oneEmpty() => Response( + 200, + Body.json({ + 'links': {'self': '/articles/1/relationships/author', 'related': '/articles/1/author'}, + 'meta': {'hello': 'world'}, + 'data': null, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, + 'links': {'self': 'http://example.com/people/9'}, }, - 'meta': {'hello': 'world'}, - 'data': null, - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'}, }, - 'links': {'self': 'http://example.com/people/9'} }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } + 'links': {'self': 'http://example.com/comments/5'}, + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'}, }, - 'links': {'self': 'http://example.com/comments/5'} }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - }), - headers); - -many() => Response( - 200, - Body.json({ - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags' + 'links': {'self': 'http://example.com/comments/12'}, }, - 'meta': {'hello': 'world'}, - 'data': [ - {'type': 'tags', 'id': '12'} - ] - }), - headers); + ], + }), + headers, +); -noContent() => Response(204, Body(), Headers()); +Response many() => Response( + 200, + Body.json({ + 'links': {'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags'}, + 'meta': {'hello': 'world'}, + 'data': [ + {'type': 'tags', 'id': '12'}, + ], + }), + headers, +); -error422() => Response( - 422, - Body.json({ - 'meta': {'hello': 'world'}, - 'errors': [ - { - 'status': '422', - 'source': {'pointer': '/data/attributes/firstName'}, - 'title': 'Invalid Attribute', - 'detail': 'First name must contain at least three characters.' - } - ] - }), - headers); +Response noContent() => Response(204, Body.empty(), Headers()); + +Response error422() => Response( + 422, + Body.json({ + 'meta': {'hello': 'world'}, + 'errors': [ + { + 'status': '422', + 'source': {'pointer': '/data/attributes/firstName'}, + 'title': 'Invalid Attribute', + 'detail': 'First name must contain at least three characters.', + }, + ], + }), + headers, +); -error500() => Response(500, Body(), Headers()); +Response error500() => Response(500, Body.empty(), Headers()); diff --git a/test/unit/client/typed_responses_test.dart b/test/unit/client/typed_responses_test.dart index 85525cd3..13c5ea2d 100644 --- a/test/unit/client/typed_responses_test.dart +++ b/test/unit/client/typed_responses_test.dart @@ -5,7 +5,7 @@ import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; void main() { - final emptyResponse = Response(i.Response(200, i.Body(), i.Headers()), null); + final emptyResponse = Response(i.Response(200, i.Body.empty(), i.Headers()), null); group('CollectionFetched', () { test('throws on empty body', () { expect(() => CollectionFetched(emptyResponse), throwsFormatException); @@ -14,20 +14,17 @@ void main() { group('RelatedResourceFetched', () { test('throws on empty body', () { - expect( - () => RelatedResourceFetched(emptyResponse), throwsFormatException); + expect(() => RelatedResourceFetched(emptyResponse), throwsFormatException); }); }); group('RelationshipFetched', () { test('.many() throws on empty body', () { - expect( - () => RelationshipFetched.many(emptyResponse), throwsFormatException); + expect(() => RelationshipFetched.many(emptyResponse), throwsFormatException); }); test('.one() throws on empty body', () { - expect( - () => RelationshipFetched.one(emptyResponse), throwsFormatException); + expect(() => RelationshipFetched.one(emptyResponse), throwsFormatException); }); }); diff --git a/test/unit/document/error_object_test.dart b/test/unit/document/error_object_test.dart index f42b2d2b..68b003a6 100644 --- a/test/unit/document/error_object_test.dart +++ b/test/unit/document/error_object_test.dart @@ -11,29 +11,29 @@ void main() { }); test('Full', () { expect( - jsonEncode(ErrorObject( + jsonEncode( + ErrorObject( id: 'test_id', status: 'test_status', code: 'test_code', title: 'test_title', detail: 'test_detail', - source: ErrorSource( - parameter: 'test_parameter', pointer: 'test_pointer')) + source: ErrorSource(parameter: 'test_parameter', pointer: 'test_pointer'), + ) ..links['foo'] = Link(Uri.parse('/bar')) - ..meta['foo'] = 42), - jsonEncode({ - 'id': 'test_id', - 'status': 'test_status', - 'code': 'test_code', - 'title': 'test_title', - 'detail': 'test_detail', - 'source': { - 'parameter': 'test_parameter', - 'pointer': 'test_pointer' - }, - 'links': {'foo': '/bar'}, - 'meta': {'foo': 42}, - })); + ..meta['foo'] = 42, + ), + jsonEncode({ + 'id': 'test_id', + 'status': 'test_status', + 'code': 'test_code', + 'title': 'test_title', + 'detail': 'test_detail', + 'source': {'parameter': 'test_parameter', 'pointer': 'test_pointer'}, + 'links': {'foo': '/bar'}, + 'meta': {'foo': 42}, + }), + ); }); }); } diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index d5b4c22a..9d94efe0 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -8,7 +8,7 @@ void main() { group('Errors', () { test('Minimal', () { final e = InboundDocument({ - 'errors': [{}] + 'errors': [{}], }).errors().first; expect(e.id, ''); expect(e.status, ''); @@ -34,7 +34,7 @@ void main() { 'meta': {'foo': 42}, }; final e = InboundDocument({ - 'errors': [error] + 'errors': [error], }).errors().first; expect(e.id, 'test_id'); @@ -52,12 +52,13 @@ void main() { test('Invalid', () { expect( - () => InboundDocument({ - 'errors': [ - {'id': []} - ] - }).errors().first, - throwsFormatException); + () => InboundDocument({ + 'errors': [ + {'id': []}, + ], + }).errors().first, + throwsFormatException, + ); }); }); @@ -65,14 +66,9 @@ void main() { test('can parse the standard example', () { final doc = InboundDocument(payload.example); expect( - doc - .dataAsCollection() - .first - .relationships['author']! - .links['self']! - .uri - .toString(), - 'http://example.com/articles/1/relationships/author'); + doc.dataAsCollection().first.relationships['author']!.links['self']!.uri.toString(), + 'http://example.com/articles/1/relationships/author', + ); expect(doc.included().first.attributes['firstName'], 'Dan'); expect(doc.links()['self'].toString(), 'http://example.com/articles'); expect(doc.meta(), isEmpty); @@ -116,8 +112,7 @@ void main() { final doc = InboundDocument(payload.relatedEmpty); expect(doc.dataAsResourceOrNull(), isNull); expect(doc.included(), isEmpty); - expect(doc.links()['self'].toString(), - 'http://example.com/articles/1/author'); + expect(doc.links()['self'].toString(), 'http://example.com/articles/1/author'); expect(doc.meta(), isEmpty); }); @@ -127,8 +122,7 @@ void main() { expect(doc.asToOne(), isNotEmpty); expect(doc.asToOne().first.type, 'people'); expect(doc.included(), isEmpty); - expect( - doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.links()['self'].toString(), '/articles/1/relationships/author'); expect(doc.meta(), isEmpty); }); @@ -137,8 +131,7 @@ void main() { expect(doc.asToOne(), isA()); expect(doc.asToOne(), isEmpty); expect(doc.included(), isEmpty); - expect( - doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.links()['self'].toString(), '/articles/1/relationships/author'); expect(doc.meta(), isEmpty); }); @@ -148,8 +141,7 @@ void main() { expect(doc.asToMany(), isNotEmpty); expect(doc.asToMany().first.type, 'tags'); expect(doc.included(), isEmpty); - expect( - doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.links()['self'].toString(), '/articles/1/relationships/tags'); expect(doc.meta(), isEmpty); }); @@ -158,49 +150,47 @@ void main() { expect(doc.asToMany(), isA()); expect(doc.asToMany(), isEmpty); expect(doc.included(), isEmpty); - expect( - doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.links()['self'].toString(), '/articles/1/relationships/tags'); expect(doc.meta(), isEmpty); }); test('throws on invalid doc', () { - expect(() => InboundDocument(payload.manyEmpty).dataAsResourceOrNull(), - throwsFormatException); - expect(() => InboundDocument(payload.newResource).dataAsResource(), - throwsFormatException); expect( - () => InboundDocument(payload.newResource).dataAsResourceOrNull(), - throwsFormatException); - expect(() => InboundDocument({}).dataAsResourceOrNull(), - throwsFormatException); - expect(() => InboundDocument({'data': 42}).asToMany(), - throwsFormatException); + () => InboundDocument(payload.manyEmpty).dataAsResourceOrNull(), + throwsFormatException, + ); + expect(() => InboundDocument(payload.newResource).dataAsResource(), throwsFormatException); + expect( + () => InboundDocument(payload.newResource).dataAsResourceOrNull(), + throwsFormatException, + ); + expect(() => InboundDocument({}).dataAsResourceOrNull(), throwsFormatException); + expect(() => InboundDocument({'data': 42}).asToMany(), throwsFormatException); expect( - () => InboundDocument({ - 'links': {'self': 42} - }).asToOne(), - throwsFormatException); + () => InboundDocument({ + 'links': {'self': 42}, + }).asToOne(), + throwsFormatException, + ); }); test('throws on invalid relationship kind', () { - expect(() => InboundDocument(payload.one).asToMany(), - throwsFormatException); - expect(() => InboundDocument(payload.many).asToOne(), - throwsFormatException); + expect(() => InboundDocument(payload.one).asToMany(), throwsFormatException); + expect(() => InboundDocument(payload.many).asToOne(), throwsFormatException); }); test('throws on invalid new relationship', () { expect( - () => InboundDocument(payload.newResourceInvalidRelationship) - .dataAsNewResource(), - throwsFormatException); + () => InboundDocument(payload.newResourceInvalidRelationship).dataAsNewResource(), + throwsFormatException, + ); }); test('throws on incomplete new relationship', () { expect( - () => InboundDocument(payload.newResourceIncompleteRelationship) - .dataAsNewResource(), - throwsFormatException); + () => InboundDocument(payload.newResourceIncompleteRelationship).dataAsNewResource(), + throwsFormatException, + ); }); }); }); diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart index 2f527485..7d97d0b1 100644 --- a/test/unit/document/link_test.dart +++ b/test/unit/document/link_test.dart @@ -11,11 +11,12 @@ void main() { }); test('Object', () { expect( - jsonEncode(Link(Uri.parse(href))..meta['foo'] = []), - jsonEncode({ - 'href': href, - 'meta': {'foo': []} - })); + jsonEncode(Link(Uri.parse(href))..meta['foo'] = []), + jsonEncode({ + 'href': href, + 'meta': {'foo': []}, + }), + ); }); }); } diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart index eba069e4..7c52dd57 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -7,17 +7,14 @@ void main() { group('NewResource', () { test('toResource throws on unmatched local id in "many"', () { final resource = NewResource('test_type', id: 'test_id', lid: 'test_lid') - ..relationships['many'] = NewToMany([ - LocalIdentifier('test_type', 'test_lid2'), - ]); + ..relationships['many'] = NewToMany([LocalIdentifier('test_type', 'test_lid2')]); expect(() => resource.toResource(() => 'my-test-id'), throwsStateError); }); test('toResource throws on unmatched local id in "one"', () { final resource = NewResource('test_type', id: 'test_id', lid: 'test_lid') - ..relationships['one'] = - NewToOne(LocalIdentifier('test_type', 'test_lid2')); + ..relationships['one'] = NewToOne(LocalIdentifier('test_type', 'test_lid2')); expect(() => resource.toResource(() => 'my-test-id'), throwsStateError); }); @@ -30,66 +27,66 @@ void main() { }); test('json encoding', () { - expect(jsonEncode(NewResource('test_type')), - jsonEncode({'type': 'test_type'})); + expect(jsonEncode(NewResource('test_type')), jsonEncode({'type': 'test_type'})); expect( - jsonEncode(NewResource('test_type', id: 'test_id', lid: 'test_lid') + jsonEncode( + NewResource('test_type', id: 'test_id', lid: 'test_lid') ..meta['foo'] = [42] ..attributes['color'] = 'green' - ..relationships['one'] = - (NewToOne(Identifier('rel', '1')..meta['rel'] = 1) - ..meta['one'] = 1) - ..relationships['self'] = (NewToOne( - LocalIdentifier('test_type', 'test_lid')..meta['rel'] = 1) + ..relationships['one'] = (NewToOne(Identifier('rel', '1')..meta['rel'] = 1) ..meta['one'] = 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': { - 'data': { + ])..meta['many'] = 1), + ), + jsonEncode({ + 'type': 'test_type', + 'id': 'test_id', + 'lid': 'test_lid', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1}, + }, + 'meta': {'one': 1}, + }, + 'self': { + 'data': { + 'type': 'test_type', + 'lid': 'test_lid', + 'meta': {'rel': 1}, + }, + 'meta': {'one': 1}, + }, + 'many': { + 'data': [ + { 'type': 'rel', 'id': '1', - 'meta': {'rel': 1} + 'meta': {'rel': 1}, }, - 'meta': {'one': 1} - }, - 'self': { - 'data': { + { 'type': 'test_type', 'lid': 'test_lid', - 'meta': {'rel': 1} + 'meta': {'rel': 1}, }, - 'meta': {'one': 1} - }, - 'many': { - 'data': [ - { - 'type': 'rel', - 'id': '1', - 'meta': {'rel': 1} - }, - { - 'type': 'test_type', - 'lid': 'test_lid', - 'meta': {'rel': 1} - }, - ], - 'meta': {'many': 1} - } + ], + 'meta': {'many': 1}, }, - 'meta': { - 'foo': [42] - } - })); + }, + 'meta': { + 'foo': [42], + }, + }), + ); }); }); } diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index 7886a665..ad3420cb 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -11,7 +11,7 @@ void main() { }); test('full', () { expect(toObject(OutboundDocument()..meta['foo'] = true), { - 'meta': {'foo': true} + 'meta': {'foo': true}, }); }); }); @@ -22,14 +22,14 @@ void main() { }); test('full', () { expect( - toObject(OutboundErrorDocument([ErrorObject(detail: 'Some issue')]) - ..meta['foo'] = 42), - { - 'errors': [ - {'detail': 'Some issue'} - ], - 'meta': {'foo': 42} - }); + toObject(OutboundErrorDocument([ErrorObject(detail: 'Some issue')])..meta['foo'] = 42), + { + 'errors': [ + {'detail': 'Some issue'}, + ], + 'meta': {'foo': 42}, + }, + ); }); }); }); @@ -40,23 +40,26 @@ void main() { group('Resource', () { test('minimal', () { expect(toObject(OutboundDataDocument.resource(book)), { - 'data': {'type': 'books', 'id': '1'} + 'data': {'type': 'books', 'id': '1'}, }); }); test('full', () { expect( - toObject(OutboundDataDocument.resource(book) + toObject( + OutboundDataDocument.resource(book) ..meta['foo'] = 42 ..included.add(author) - ..links['self'] = Link(Uri.parse('/books/1'))), - { - 'data': {'type': 'books', 'id': '1'}, - 'links': {'self': '/books/1'}, - 'included': [ - {'type': 'people', 'id': '2'} - ], - 'meta': {'foo': 42} - }); + ..links['self'] = Link(Uri.parse('/books/1')), + ), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'}, + ], + 'meta': {'foo': 42}, + }, + ); }); }); @@ -66,42 +69,48 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.collection([book]) + toObject( + OutboundDataDocument.collection([book]) ..meta['foo'] = 42 ..included.add(author) - ..links['self'] = Link(Uri.parse('/books/1'))), - { - 'data': [ - {'type': 'books', 'id': '1'} - ], - 'links': {'self': '/books/1'}, - 'included': [ - {'type': 'people', 'id': '2'} - ], - 'meta': {'foo': 42} - }); + ..links['self'] = Link(Uri.parse('/books/1')), + ), + { + 'data': [ + {'type': 'books', 'id': '1'}, + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'}, + ], + 'meta': {'foo': 42}, + }, + ); }); }); group('One', () { test('minimal', () { - expect( - toObject(OutboundDataDocument.one(ToOne.empty())), {'data': null}); + expect(toObject(OutboundDataDocument.one(ToOne.empty())), {'data': null}); }); test('full', () { expect( - toObject(OutboundDataDocument.one(ToOne(book.toIdentifier()) - ..meta['foo'] = 42 - ..links['self'] = Link(Uri.parse('/books/1'))) - ..included.add(author)), - { - 'data': {'type': 'books', 'id': '1'}, - 'links': {'self': '/books/1'}, - 'included': [ - {'type': 'people', 'id': '2'} - ], - 'meta': {'foo': 42} - }); + toObject( + OutboundDataDocument.one( + ToOne(book.toIdentifier()) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1')), + )..included.add(author), + ), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'}, + ], + 'meta': {'foo': 42}, + }, + ); }); }); @@ -111,23 +120,27 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.many(ToMany([book.toIdentifier()]) - ..meta['foo'] = 42 - ..links['self'] = Link(Uri.parse('/books/1'))) - ..included.add(author)), - { - 'data': [ - {'type': 'books', 'id': '1'} - ], - 'links': {'self': '/books/1'}, - 'included': [ - {'type': 'people', 'id': '2'} - ], - 'meta': {'foo': 42} - }); + toObject( + OutboundDataDocument.many( + ToMany([book.toIdentifier()]) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1')), + )..included.add(author), + ), + { + 'data': [ + {'type': 'books', 'id': '1'}, + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'}, + ], + 'meta': {'foo': 42}, + }, + ); }); }); }); } -Map toObject(v) => jsonDecode(jsonEncode(v)); +Map toObject(Object? v) => jsonDecode(jsonEncode(v)); diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart index 9653ac33..1e60712b 100644 --- a/test/unit/document/payload.dart +++ b/test/unit/document/payload.dart @@ -2,7 +2,7 @@ final example = { 'links': { 'self': 'http://example.com/articles', 'next': 'http://example.com/articles?page[offset]=2', - 'last': 'http://example.com/articles?page[offset]=10' + 'last': 'http://example.com/articles?page[offset]=10', }, 'data': [ { @@ -13,34 +13,30 @@ final example = { 'author': { 'links': { 'self': 'http://example.com/articles/1/relationships/author', - 'related': 'http://example.com/articles/1/author' + 'related': 'http://example.com/articles/1/author', }, - 'data': {'type': 'people', 'id': '9'} + 'data': {'type': 'people', 'id': '9'}, }, 'comments': { 'links': { 'self': 'http://example.com/articles/1/relationships/comments', - 'related': 'http://example.com/articles/1/comments' + 'related': 'http://example.com/articles/1/comments', }, 'data': [ {'type': 'comments', 'id': '5'}, - {'type': 'comments', 'id': '12'} - ] - } + {'type': 'comments', 'id': '12'}, + ], + }, }, - 'links': {'self': 'http://example.com/articles/1'} - } + 'links': {'self': 'http://example.com/articles/1'}, + }, ], 'included': [ { 'type': 'people', 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' - }, - 'links': {'self': 'http://example.com/people/9'} + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, + 'links': {'self': 'http://example.com/people/9'}, }, { 'type': 'comments', @@ -48,10 +44,10 @@ final example = { 'attributes': {'body': 'First!'}, 'relationships': { 'author': { - 'data': {'type': 'people', 'id': '2'} - } + 'data': {'type': 'people', 'id': '2'}, + }, }, - 'links': {'self': 'http://example.com/comments/5'} + 'links': {'self': 'http://example.com/comments/5'}, }, { 'type': 'comments', @@ -59,21 +55,17 @@ final example = { 'attributes': {'body': 'I like XML better'}, 'relationships': { 'author': { - 'data': {'type': 'people', 'id': '9'} - } + 'data': {'type': 'people', 'id': '9'}, + }, }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] + 'links': {'self': 'http://example.com/comments/12'}, + }, + ], }; final nullLink = { - 'links': { - 'self': 'http://example.com/articles', - 'next': null, - 'last': null, - }, - 'data': [] + 'links': {'self': 'http://example.com/articles', 'next': null, 'last': null}, + 'data': [], }; final newResource = { @@ -82,58 +74,46 @@ final newResource = { 'attributes': {'title': 'A new article'}, 'relationships': { 'author': { - 'data': {'type': 'people', 'id': '42'} - } - } - } + 'data': {'type': 'people', 'id': '42'}, + }, + }, + }, }; final many = { - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags' - }, + 'links': {'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags'}, 'data': [ {'type': 'tags', 'id': '2'}, - {'type': 'tags', 'id': '3'} - ] + {'type': 'tags', 'id': '3'}, + ], }; final manyEmpty = { - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags' - }, - 'data': [] + 'links': {'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags'}, + 'data': [], }; final one = { - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' - }, - 'data': {'type': 'people', 'id': '12'} + 'links': {'self': '/articles/1/relationships/author', 'related': '/articles/1/author'}, + 'data': {'type': 'people', 'id': '12'}, }; final oneEmpty = { - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' - }, - 'data': null + 'links': {'self': '/articles/1/relationships/author', 'related': '/articles/1/author'}, + 'data': null, }; final relatedEmpty = { 'links': {'self': 'http://example.com/articles/1/author'}, - 'data': null + 'data': null, }; final resource = { 'links': { 'self': { 'href': 'http://example.com/articles/1', - 'meta': {'answer': 42} - } + 'meta': {'answer': 42}, + }, }, 'data': { 'type': 'articles', @@ -141,11 +121,11 @@ final resource = { 'attributes': {'title': 'JSON:API paints my bikeshed!'}, 'relationships': { 'author': { - 'links': {'related': 'http://example.com/articles/1/author'} + 'links': {'related': 'http://example.com/articles/1/author'}, }, - 'reviewer': {'data': null} - } - } + 'reviewer': {'data': null}, + }, + }, }; final newResourceInvalidRelationship = { @@ -153,11 +133,9 @@ final newResourceInvalidRelationship = { 'type': 'articles', 'attributes': {'title': 'JSON:API paints my bikeshed!'}, 'relationships': { - 'author': { - 'data': true, - }, - } - } + 'author': {'data': true}, + }, + }, }; final newResourceIncompleteRelationship = { @@ -168,6 +146,6 @@ final newResourceIncompleteRelationship = { 'author': { 'data': {'type': 'person'}, }, - } - } + }, + }, }; diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 934af982..55f1df3b 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -19,22 +19,25 @@ void main() { expect(ToMany([a]), isNotEmpty); expect( - [ - ...ToMany([a]) - ].first, - a); + [ + ...ToMany([a]), + ].first, + a, + ); expect(ToMany([a, b]), isNotEmpty); expect( - [ - ...ToMany([a, b]) - ].first, - a); + [ + ...ToMany([a, b]), + ].first, + a, + ); expect( - [ - ...ToMany([a, b]) - ].last, - b); + [ + ...ToMany([a, b]), + ].last, + b, + ); }); }); diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 94aafc56..475ea375 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -6,54 +6,57 @@ import 'package:test/test.dart'; void main() { group('Resource', () { test('json encoding', () { - expect(jsonEncode(Resource('test_type', 'test_id')), - jsonEncode({'type': 'test_type', 'id': 'test_id'})); + expect( + jsonEncode(Resource('test_type', 'test_id')), + jsonEncode({'type': 'test_type', 'id': 'test_id'}), + ); expect( - jsonEncode(Resource('test_type', 'test_id') + jsonEncode( + Resource('test_type', 'test_id') ..meta['foo'] = [42] ..attributes['color'] = 'green' - ..relationships['one'] = - (ToOne(Identifier('rel', '1')..meta['rel'] = 1) - ..meta['one'] = 1) - ..relationships['many'] = - (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) - ..meta['many'] = 1) - ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1)), - jsonEncode({ - 'type': 'test_type', - 'id': 'test_id', - 'attributes': {'color': 'green'}, - 'relationships': { - 'one': { - 'data': { + ..relationships['one'] = (ToOne(Identifier('rel', '1')..meta['rel'] = 1) + ..meta['one'] = 1) + ..relationships['many'] = (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) + ..meta['many'] = 1) + ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1), + ), + jsonEncode({ + 'type': 'test_type', + 'id': 'test_id', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1}, + }, + 'meta': {'one': 1}, + }, + 'many': { + 'data': [ + { 'type': 'rel', 'id': '1', - 'meta': {'rel': 1} + 'meta': {'rel': 1}, }, - 'meta': {'one': 1} - }, - 'many': { - 'data': [ - { - 'type': 'rel', - 'id': '1', - 'meta': {'rel': 1} - }, - ], - 'meta': {'many': 1} - } + ], + 'meta': {'many': 1}, }, - 'links': { - 'self': { - 'href': '/apples/42', - 'meta': {'a': 1} - } + }, + 'links': { + 'self': { + 'href': '/apples/42', + 'meta': {'a': 1}, }, - 'meta': { - 'foo': [42] - } - })); + }, + 'meta': { + 'foo': [42], + }, + }), + ); }); test('one() return null when relationship does not exist', () { expect(Resource('books', '1').one('author'), isNull); diff --git a/test/unit/document/to_json_encodable_test.dart b/test/unit/document/to_json_encodable_test.dart new file mode 100644 index 00000000..6acf024f --- /dev/null +++ b/test/unit/document/to_json_encodable_test.dart @@ -0,0 +1,10 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('toJsonEncodable()', () { + test('throws UnsupportedError', () { + expect(() => toJsonEncodable('wow'), throwsUnsupportedError); + }); + }); +} diff --git a/test/unit/http/logging_middleware_test.dart b/test/unit/http/logging_middleware_test.dart deleted file mode 100644 index ca1faaf5..00000000 --- a/test/unit/http/logging_middleware_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; - -import 'package:http_interop/http_interop.dart'; -import 'package:json_api/http.dart'; -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -main() { - Response response = Response(200, Body.text('hello', utf8), Headers()); - - Future handler(Request rq) async => response; - - group('Logging Middleware', () { - test('Can log', () async { - Request? loggedRq; - Response? loggedRs; - - final request = Request('get', Uri(host: 'localhost'), Body(), Headers()); - final response = await loggingMiddleware(handler, - onRequest: (r) => loggedRq = r, - onResponse: (r) => loggedRs = r)(request); - expect(loggedRq, same(request)); - expect(loggedRs, same(response)); - }); - }); -} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index 965c6313..17b1e558 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -8,15 +8,17 @@ void main() { expect(Fields().isNotEmpty, isFalse); expect( - Fields({ - 'foo': ['bar'] - }).isEmpty, - isFalse); + Fields({ + 'foo': ['bar'], + }).isEmpty, + isFalse, + ); expect( - Fields({ - 'foo': ['bar'] - }).isNotEmpty, - isTrue); + Fields({ + 'foo': ['bar'], + }).isNotEmpty, + isTrue, + ); }); test('add, remove, clear', () { @@ -33,7 +35,8 @@ void main() { test('can decode url without duplicate keys', () { final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name', + ); final fields = Fields.fromUri(uri); expect(fields['articles'], ['title', 'body']); expect(fields['people'], ['name']); @@ -41,7 +44,8 @@ void main() { test('can decode url with duplicate keys', () { final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age', + ); final fields = Fields.fromUri(uri); expect(fields['articles'], ['title', 'body']); expect(fields['people'], ['name', 'age']); @@ -49,14 +53,15 @@ void main() { test('can convert to query parameters', () { expect( - Fields({ - 'articles': ['title', 'body'], - 'people': ['name'] - }).toQuery(), - { - 'fields[articles]': ['title,body'], - 'fields[people]': ['name'] - }); + Fields({ + 'articles': ['title', 'body'], + '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 new file mode 100644 index 00000000..8aa85112 --- /dev/null +++ b/test/unit/query/filter_test.dart @@ -0,0 +1,39 @@ +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[foo]=10&filter[bar]=20'); + final filter = Filter.fromUri(uri); + expect(filter['foo'], '10'); + expect(filter['bar'], '20'); + }); + + test('can convert to query parameters', () { + expect(Filter({'foo': '10', 'bar': '20'}).toQuery(), { + 'filter[foo]': ['10'], + 'filter[bar]': ['20'], + }); + }); + }); +} diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index 7f4eef5b..2520a4ba 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -18,15 +18,14 @@ void main() { }); test('Can decode url with duplicate keys', () { - final uri = - Uri.parse('/articles/1?include=author,comments.author&include=tags'); + final uri = Uri.parse('/articles/1?include=author,comments.author&include=tags'); final include = Include.fromUri(uri); expect(include, equals(['author', 'comments.author', 'tags'])); }); test('Can convert to query parameters', () { expect(Include(['author', 'comments.author']).toQuery(), { - 'include': ['author,comments.author'] + 'include': ['author,comments.author'], }); }); } diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart index facc6d78..bb581fb9 100644 --- a/test/unit/query/page_test.dart +++ b/test/unit/query/page_test.dart @@ -32,7 +32,7 @@ void main() { test('can convert to query parameters', () { expect(Page({'limit': '10', 'offset': '20'}).toQuery(), { 'page[limit]': ['10'], - 'page[offset]': ['20'] + 'page[offset]': ['20'], }); }); }); diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index 25910136..aa559f59 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -27,7 +27,7 @@ void main() { test('Can convert to query parameters', () { expect(Sort(['-created', 'title']).toQuery(), { - 'sort': ['-created,title'] + 'sort': ['-created,title'], }); }); } diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index e3781eb5..7a340dc7 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -3,33 +3,188 @@ import 'package:test/test.dart'; void main() { test('uri generation', () { - final url = StandardUriDesign.pathOnly; - expect(url.collection('books').toString(), '/books'); - expect(url.resource('books', '42').toString(), '/books/42'); - expect(url.related('books', '42', 'author').toString(), '/books/42/author'); - expect(url.relationship('books', '42', 'author').toString(), - '/books/42/relationships/author'); + final d = StandardUriDesign.pathOnly; + expect(d.base.path, equals('/')); + expect(d.uri(const CollectionTarget('books')).toString(), '/books'); + expect(d.uri(const ResourceTarget('books', '42')).toString(), '/books/42'); + expect(d.uri(const RelatedTarget('books', '42', 'author')).toString(), '/books/42/author'); + expect( + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), + '/books/42/relationships/author', + ); + }); + + test('uri generation with base, trailing slash', () { + final d = StandardUriDesign(Uri.parse('https://example.com/api/')); + expect(d.base.path, equals('/api/')); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/api/books'); + expect( + d.uri(const ResourceTarget('books', '42')).toString(), + 'https://example.com/api/books/42', + ); + expect( + d.uri(const RelatedTarget('books', '42', 'author')).toString(), + 'https://example.com/api/books/42/author', + ); + expect( + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), + 'https://example.com/api/books/42/relationships/author', + ); + }); + + test('uri generation with base, no trailing slash', () { + final d = StandardUriDesign(Uri.parse('https://example.com/api')); + expect(d.base.path, equals('/api/')); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/api/books'); + expect( + d.uri(const ResourceTarget('books', '42')).toString(), + 'https://example.com/api/books/42', + ); + expect( + d.uri(const RelatedTarget('books', '42', 'author')).toString(), + 'https://example.com/api/books/42/author', + ); + expect( + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), + 'https://example.com/api/books/42/relationships/author', + ); }); test('Authority is retained if exists in base', () { - final url = StandardUriDesign(Uri.parse('https://example.com')); - expect(url.collection('books').toString(), 'https://example.com/books'); - expect( - url.resource('books', '42').toString(), 'https://example.com/books/42'); - expect(url.related('books', '42', 'author').toString(), - 'https://example.com/books/42/author'); - expect(url.relationship('books', '42', 'author').toString(), - 'https://example.com/books/42/relationships/author'); + final d = StandardUriDesign(Uri.parse('https://example.com:8080')); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com:8080/books'); + expect( + d.uri(const ResourceTarget('books', '42')).toString(), + 'https://example.com:8080/books/42', + ); + expect( + d.uri(const RelatedTarget('books', '42', 'author')).toString(), + 'https://example.com:8080/books/42/author', + ); + expect( + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), + 'https://example.com:8080/books/42/relationships/author', + ); }); - test('Authority and path is retained if exists in base (directory path)', () { - final url = StandardUriDesign(Uri.parse('https://example.com/foo/')); - expect(url.collection('books').toString(), 'https://example.com/foo/books'); - expect(url.resource('books', '42').toString(), - 'https://example.com/foo/books/42'); - expect(url.related('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/author'); - expect(url.relationship('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/relationships/author'); + test('Host and path is retained if exists in base (directory path)', () { + final d = StandardUriDesign(Uri.parse('https://example.com/foo/')); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/foo/books'); + expect( + d.uri(const ResourceTarget('books', '42')).toString(), + 'https://example.com/foo/books/42', + ); + expect( + d.uri(const RelatedTarget('books', '42', 'author')).toString(), + 'https://example.com/foo/books/42/author', + ); + expect( + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), + 'https://example.com/foo/books/42/relationships/author', + ); + }); + group('Target matching', () { + test('Path only', () { + final d = StandardUriDesign.pathOnly; + expect(d.target(Uri.parse('/books')), isA()); + expect(d.target(Uri.parse('/books/42')), isA()); + expect(d.target(Uri.parse('/books/42/authors')), isA()); + expect(d.target(Uri.parse('/books/42/relationships/authors')), isA()); + expect(d.target(Uri.parse('/a/b/c/d')), isNull); + }); + test('Path only, full url', () { + final d = StandardUriDesign.pathOnly; + expect(d.target(Uri.parse('https://example.com/books')), isA()); + expect(d.target(Uri.parse('https://example.com/books/42')), isA()); + expect(d.target(Uri.parse('https://example.com/books/42/authors')), isA()); + expect( + d.target(Uri.parse('https://example.com/books/42/relationships/authors')), + isA(), + ); + expect(d.target(Uri.parse('https://example.com/a/b/c/d')), isNull); + }); + test('Authority', () { + final d = StandardUriDesign(Uri.parse('https://example.com:8080')); + expect(d.target(Uri.parse('https://example.com:8080/books')), isA()); + expect(d.target(Uri.parse('https://example.com:8080/books/42')), isA()); + expect( + d.target(Uri.parse('https://example.com:8080/books/42/authors')), + isA(), + ); + expect( + d.target(Uri.parse('https://example.com:8080/books/42/relationships/authors')), + isA(), + ); + + expect(d.target(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.target(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.target(Uri.parse('https://foo.net:8080/books')), isNull); + }); + + test('Authority and path', () { + final d = StandardUriDesign(Uri.parse('https://example.com:8080/api')); + expect( + d.target(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42/authors')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')) + .having((it) => it.relationship, 'relationship', equals('authors')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42/relationships/authors')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')) + .having((it) => it.relationship, 'relationship', equals('authors')), + ); + + expect(d.target(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.target(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.target(Uri.parse('https://foo.net:8080/books')), isNull); + expect(d.target(Uri.parse('https://example.com:8080/foo/books')), isNull); + }); + + test('Authority and path, trailing slash', () { + final d = StandardUriDesign(Uri.parse('https://example.com:8080/api/')); + expect( + d.target(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42/authors')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')) + .having((it) => it.relationship, 'relationship', equals('authors')), + ); + expect( + d.target(Uri.parse('https://example.com:8080/api/books/42/relationships/authors')), + isA() + .having((it) => it.type, 'type', equals('books')) + .having((it) => it.id, 'id', equals('42')) + .having((it) => it.relationship, 'relationship', equals('authors')), + ); + + expect(d.target(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.target(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.target(Uri.parse('https://foo.net:8080/books')), isNull); + expect(d.target(Uri.parse('https://example.com:8080/foo/books')), isNull); + }); }); } diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart similarity index 56% rename from test/unit/http/cors_middleware_test.dart rename to test/unit/server/cors_middleware_test.dart index 6dc2939e..52ba8a54 100644 --- a/test/unit/http/cors_middleware_test.dart +++ b/test/unit/server/cors_middleware_test.dart @@ -1,24 +1,23 @@ -import 'dart:convert'; - import 'package:http_interop/http_interop.dart'; -import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; -main() { - Response response = Response(200, Body.text('hello', utf8), Headers()); +void main() { + Response response = Response(200, Body.text('hello'), Headers()); Future handler(Request rq) async => response; group('CORS Middleware', () { test('Sets the headers', () async { final rq = Request( - 'get', - Uri(host: 'localhost'), - Body(), - Headers.from({ - 'origin': ['foo'] - })); + 'GET', + Uri(host: 'localhost'), + Body.empty(), + Headers.from({ + 'origin': ['foo'], + }), + ); final rs = await corsMiddleware(handler)(rq); expect(rs.statusCode, equals(200)); expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo'])); @@ -27,39 +26,41 @@ main() { test('Responds to OPTIONS', () async { final rq = Request( - 'options', - Uri(host: 'localhost'), - Body(), - Headers.from({ - 'origin': ['foo'] - })); + 'options', + Uri(host: 'localhost'), + Body.empty(), + Headers.from({ + 'origin': ['foo'], + }), + ); final rs = await corsMiddleware(handler)(rq); expect(rs.statusCode, equals(204)); expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo'])); expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location'])); - expect(rs.headers['Access-Control-Allow-Methods'], - equals(['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'])); + expect( + rs.headers['Access-Control-Allow-Methods'], + equals(['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']), + ); expect(rs.headers['Access-Control-Allow-Headers'], equals(['*'])); }); test('Responds to OPTIONS with custom headers', () async { final rq = Request( - 'options', - Uri(host: 'localhost'), - Body(), - Headers.from({ - 'origin': ['foo'], - 'Access-Control-Request-Method': ['PUT', 'POST'], - 'Access-Control-Request-Headers': ['foo', 'bar'], - })); + 'options', + Uri(host: 'localhost'), + Body.empty(), + Headers.from({ + 'origin': ['foo'], + 'Access-Control-Request-Method': ['PUT', 'POST'], + 'Access-Control-Request-Headers': ['foo', 'bar'], + }), + ); final rs = await corsMiddleware(handler)(rq); expect(rs.statusCode, equals(204)); expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo'])); expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location'])); - expect( - rs.headers['Access-Control-Allow-Methods'], equals(['PUT', 'POST'])); - expect( - rs.headers['Access-Control-Allow-Headers'], equals(['foo', 'bar'])); + expect(rs.headers['Access-Control-Allow-Methods'], equals(['PUT', 'POST'])); + expect(rs.headers['Access-Control-Allow-Headers'], equals(['foo', 'bar'])); }); }); } diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart index b2fbc4bc..86e40d8f 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -1,14 +1,80 @@ -import 'dart:convert'; - -import 'package:http_interop/extensions.dart'; +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; -import 'package:test/test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.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"}]}')); + final get = Request('GET', Uri(), Body.empty(), Headers()); + + group('default handlers', () { + final converter = errorConverter(); + test('can catch MethodNotAllowed', () async { + final r = await converter((_) => throw MethodNotAllowed('foo'))(get); + expect(r.statusCode, equals(StatusCode.methodNotAllowed)); + }); + test('can catch UnmatchedTarget', () async { + final r = await converter((_) => throw UnmatchedTarget(Uri()))(get); + expect(r.statusCode, equals(StatusCode.badRequest)); + }); + test('can catch CollectionNotFound', () async { + final r = await converter((_) => throw CollectionNotFound('foo'))(get); + expect(r.statusCode, equals(StatusCode.notFound)); + }); + test('can catch ResourceNotFound', () async { + final r = await converter((_) => throw ResourceNotFound('foo', 'bar'))(get); + expect(r.statusCode, equals(StatusCode.notFound)); + }); + test('can catch RelationshipNotFound', () async { + final r = await converter((_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get); + expect(r.statusCode, equals(StatusCode.notFound)); + }); + test('can catch UnsupportedMediaType', () async { + final r = await converter((_) => throw UnsupportedMediaType())(get); + expect(r.statusCode, equals(StatusCode.unsupportedMediaType)); + }); + test('can catch Unacceptable', () async { + final r = await converter((_) => throw NotAcceptable())(get); + expect(r.statusCode, equals(StatusCode.notAcceptable)); + }); + test('can catch any other error', () async { + final r = await converter((_) => throw 'foo')(get); + expect(r.statusCode, equals(StatusCode.internalServerError)); + }); + }); + + group('custom handlers', () { + final converter = errorConverter( + onMethodNotAllowed: (_) async => Response(550, Body.empty(), Headers()), + onUnmatchedTarget: (_) async => Response(551, Body.empty(), Headers()), + onCollectionNotFound: (_) async => Response(552, Body.empty(), Headers()), + onResourceNotFound: (_) async => Response(553, Body.empty(), Headers()), + onRelationshipNotFound: (_) async => Response(554, Body.empty(), Headers()), + onError: (_, _) async => Response(555, Body.empty(), Headers()), + ); + test('can catch MethodNotAllowed', () async { + final r = await converter((_) => throw MethodNotAllowed('foo'))(get); + expect(r.statusCode, equals(550)); + }); + test('can catch UnmatchedTarget', () async { + final r = await converter((_) => throw UnmatchedTarget(Uri()))(get); + expect(r.statusCode, equals(551)); + }); + test('can catch CollectionNotFound', () async { + final r = await converter((_) => throw CollectionNotFound('foo'))(get); + expect(r.statusCode, equals(552)); + }); + test('can catch ResourceNotFound', () async { + final r = await converter((_) => throw ResourceNotFound('foo', 'bar'))(get); + expect(r.statusCode, equals(553)); + }); + test('can catch RelationshipNotFound', () async { + final r = await converter((_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get); + expect(r.statusCode, equals(554)); + }); + test('can catch any other error', () async { + final r = await converter((_) => throw 'foo')(get); + expect(r.statusCode, equals(555)); + }); }); } diff --git a/test/unit/server/json_api_request_test.dart b/test/unit/server/json_api_request_test.dart new file mode 100644 index 00000000..85bb14e6 --- /dev/null +++ b/test/unit/server/json_api_request_test.dart @@ -0,0 +1,99 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:test/test.dart'; + +void main() { + const collection = CollectionTarget('books'); + const resource = ResourceTarget('books', '42'); + const related = RelatedTarget('books', '42', 'authors'); + const relationship = RelationshipTarget('books', '42', 'authors'); + + test('resolve decodes and stores the document', () async { + final httpRequest = Request( + 'POST', + Uri.parse('/books'), + Body.json({ + "data": { + "type": "books", + "attributes": {"title": "Dune"}, + }, + }), + Headers(), + ); + final request = await JsonApiRequest.resolve(httpRequest, collection) as CreateResourceRequest; + expect(request.newResource.type, 'books'); + expect(request.newResource.attributes['title'], 'Dune'); + }); + + for (final body in ['{', '[]', 'null', '42']) { + test('resolve rejects invalid payload $body', () async { + final request = Request('POST', Uri.parse('/books'), Body.text(body), Headers()); + await expectLater( + JsonApiRequest.resolve(request, collection), + throwsA(isA()), + ); + }); + } + + final cases = [ + ('GET', collection, isA()), + ('POST', collection, isA()), + ('GET', resource, isA()), + ('PATCH', resource, isA()), + ('DELETE', resource, isA()), + ('GET', related, isA()), + ('GET', relationship, isA()), + ('POST', relationship, isA()), + ('PATCH', relationship, isA()), + ('DELETE', relationship, isA()), + ]; + + for (final (method, target) in [ + ('POST', collection), + ('PATCH', resource), + ('POST', relationship), + ('PATCH', relationship), + ('DELETE', relationship), + ]) { + test('$method ${target.runtimeType} requires a document', () async { + final request = Request(method, Uri.parse('/books'), Body.empty(), Headers()); + await expectLater(JsonApiRequest.resolve(request, target), throwsA(isA())); + }); + } + + for (final (method, target, matcher) in cases) { + test('$method ${target.runtimeType} resolves to the corresponding operation', () async { + final httpRequest = Request( + method, + Uri.parse('/books'), + Body.json({'data': null}), + Headers(), + ); + final request = await JsonApiRequest.resolve(httpRequest, target); + expect(request, matcher); + expect(request.httpRequest, same(httpRequest)); + expect(request.target, same(target)); + final uppercaseRequest = Request( + method.toUpperCase(), + Uri.parse('/books'), + Body.json({'data': null}), + Headers(), + ); + expect(await JsonApiRequest.resolve(uppercaseRequest, target), matcher); + }); + } + + for (final target in [collection, resource, related, relationship]) { + for (final method in ['GET', 'POST', 'PATCH', 'DELETE', 'put', 'head', 'options']) { + if (cases.any((entry) => entry.$1 == method && identical(entry.$2, target))) continue; + test('$method ${target.runtimeType} is not allowed', () async { + final request = Request(method, Uri.parse('/books'), Body.empty(), Headers()); + await expectLater( + JsonApiRequest.resolve(request, target), + throwsA(isA().having((error) => error.method, 'method', method)), + ); + }); + } + } +} diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart new file mode 100644 index 00000000..520f6664 --- /dev/null +++ b/test/unit/server/response_test.dart @@ -0,0 +1,23 @@ +import 'package:http_interop/extensions.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:test/test.dart'; + +void main() { + group('Response', () { + test('converts DateTime to ISO-8601', () async { + final r = response(200, document: OutboundDocument()..meta['date'] = DateTime(2021)); + expect( + await r.body.decodeJson(), + equals({ + 'meta': {'date': '2021-01-01T00:00:00.000'}, + }), + ); + }); + }); + + test('conflict', () async { + final r = conflict(); + expect(r.statusCode, equals(409)); + }); +}