From a1b2f096ca737b9eea2b120187666f75ad6365bb Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 1 Jul 2024 18:20:40 -0700 Subject: [PATCH] v8 (#141) --- CHANGELOG.md | 8 + README.md | 44 ++- example/client.dart | 4 +- example/server.dart | 10 +- example/server/cors_handler.dart | 31 -- example/server/json_api_server.dart | 2 +- example/server/repository_controller.dart | 61 ++-- lib/http.dart | 54 ++-- lib/server.dart | 2 +- lib/src/client/client.dart | 12 +- lib/src/query/sort.dart | 12 +- lib/src/server/controller_router.dart | 3 +- lib/src/server/error_converter.dart | 16 +- lib/src/server/response.dart | 51 ++-- lib/src/server/try_catch_handler.dart | 26 -- lib/src/server/try_catch_middleware.dart | 21 ++ pubspec.yaml | 14 +- test/contract/crud_test.dart | 2 +- test/contract/errors_test.dart | 8 +- test/contract/resource_creation_test.dart | 2 +- test/e2e/browser_test.dart | 5 +- test/e2e/hybrid_server.dart | 9 +- test/e2e/one_off_handler.dart | 10 + test/e2e/vm_test.dart | 6 +- test/test_handler.dart | 45 ++- test/unit/client/client_test.dart | 320 ++++++++++---------- test/unit/client/mock_handler.dart | 3 +- test/unit/client/response.dart | 17 +- test/unit/http/cors_middleware_test.dart | 65 ++++ test/unit/http/logging_middleware_test.dart | 26 ++ 30 files changed, 502 insertions(+), 387 deletions(-) delete mode 100644 example/server/cors_handler.dart delete mode 100644 lib/src/server/try_catch_handler.dart create mode 100644 lib/src/server/try_catch_middleware.dart create mode 100644 test/e2e/one_off_handler.dart create mode 100644 test/unit/http/cors_middleware_test.dart create mode 100644 test/unit/http/logging_middleware_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dac7f2..42e78d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ 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). +## [8.0.0] - 2024-07-01 +### Added +- CORS middware + +### Changed +- Bump http\_interop to v2.0 + ## [7.0.1] - 2024-06-17 ### Fixed - "Accept" header with multiple values was being mishandled @@ -250,6 +257,7 @@ the Document model. ### Added - Client: fetch resources, collections, related resources and relationships +[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 [7.0.0]: https://github.com/f3ath/json-api-dart/compare/6.0.1...7.0.0 [6.0.1]: https://github.com/f3ath/json-api-dart/compare/6.0.0...6.0.1 diff --git a/README.md b/README.md index 9094870..75a866a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ TL;DR: ```dart +import 'package:http/http.dart' as http; import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; @@ -13,29 +14,52 @@ void main() async { /// Use the standard recommended URL structure or implement your own final uriDesign = StandardUriDesign(Uri.parse(baseUri)); + /// This is the Dart's standard HTTP client. + /// Do not forget to close it in the end. + final httpClient = http.Client(); + + /// This is the interface which decouples this JSON:API implementation + /// from the HTTP client. + /// Learn more: https://pub.dev/packages/http_interop + final httpHandler = httpClient.handleInterop; + + /// This is the basic JSON:API client. It is flexible but not very convenient + /// to use, because you would need to remember a lot of JSON:API protocol details. + /// We will use another wrapper on top of it. + final jsonApiClient = Client(httpHandler); + /// The [RoutingClient] is most likely the right choice. - /// It has methods covering many standard use cases. - final client = RoutingClient(uriDesign, Client(OneOffHandler())); + /// It is called routing because it routes the calls to the correct + /// URLs depending on the use case. Take a look at its methods, they cover + /// all the standard scenarios specified by the JSON:API standard. + final client = RoutingClient(uriDesign, jsonApiClient); try { /// Fetch the collection. /// See other methods to query and manipulate resources. final response = await client.fetchCollection('colors'); - final resources = response.collection; - resources.map((resource) => resource.attributes).forEach((attr) { - final name = attr['name']; - final red = attr['red']; - final green = attr['green']; - final blue = attr['blue']; + /// The fetched collection allows us to iterate over the resources + /// and to look into their attributes + for (final resource in response.collection) { + final { + 'name': name, + 'red': red, + 'green': green, + 'blue': blue, + } = resource.attributes; + print('${resource.type}:${resource.id}'); print('$name - $red:$green:$blue'); - }); + } } on RequestFailure catch (e) { /// Catch error response - for (var error in e.errors) { + for (final error in e.errors) { print(error.title); } } + + /// Free up the resources before exit. + httpClient.close(); } ``` This is a work-in-progress. You can help it by submitting a PR with a feature or documentation improvements. diff --git a/example/client.dart b/example/client.dart index a9b2387..e2e6f67 100644 --- a/example/client.dart +++ b/example/client.dart @@ -14,10 +14,10 @@ void main() async { /// Do not forget to close it in the end. final httpClient = http.Client(); - /// This is the adapter which decouples this JSON:API implementation + /// This is the interface which decouples this JSON:API implementation /// from the HTTP client. /// Learn more: https://pub.dev/packages/http_interop - final httpHandler = ClientWrapper(httpClient); + final httpHandler = httpClient.handleInterop; /// 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. diff --git a/example/server.dart b/example/server.dart index 9235e38..a8173b4 100644 --- a/example/server.dart +++ b/example/server.dart @@ -19,16 +19,18 @@ Future main() async { await initRepo(repo); final controller = RepositoryController(repo, Uuid().v4); interop.Handler handler = - ControllerRouter(controller, StandardUriDesign.matchTarget); - handler = TryCatchHandler(handler, + ControllerRouter(controller, StandardUriDesign.matchTarget).handle; + + handler = tryCatchMiddleware(handler, onError: ErrorConverter(onError: (e, stack) async { stderr.writeln(e); stderr.writeln(stack); - return Response(500, + return response(500, document: OutboundErrorDocument( [ErrorObject(title: 'Internal Server Error')])); }).call); - handler = LoggingHandler(handler, + + handler = loggingMiddleware(handler, onRequest: (r) => print('${r.method} ${r.uri}'), onResponse: (r) => print('${r.statusCode}')); final server = JsonApiServer(handler, host: host, port: port); diff --git a/example/server/cors_handler.dart b/example/server/cors_handler.dart deleted file mode 100644 index 073ee0d..0000000 --- a/example/server/cors_handler.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:http_interop/http_interop.dart'; -import 'package:json_api/http.dart'; - -class CorsHandler implements Handler { - CorsHandler(this._inner); - - final Handler _inner; - - @override - Future handle(Request request) async { - final headers = { - '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({ - ...headers, - 'Access-Control-Allow-Methods': - request.headers['Access-Control-Request-Method'] ?? methods, - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? ['*'], - })); - } - return await _inner.handle(request..headers.addAll(headers)); - } -} diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart index 1d8510d..9430821 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -41,7 +41,7 @@ class JsonApiServer { Future _createServer() async { final server = await HttpServer.bind(host, port); - server.listen(listener(_handler)); + server.listenInterop(_handler); return server; } } diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart index c1f9dd9..cd40607 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:http_interop/extensions.dart'; -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/query.dart'; import 'package:json_api/routing.dart'; @@ -22,7 +22,7 @@ class RepositoryController implements Controller { final design = StandardUriDesign.pathOnly; @override - Future fetchCollection(http.Request request, Target target) async { + Future fetchCollection(Request request, Target target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) ..links['self'] = Link(design.collection(target.type)); @@ -32,12 +32,11 @@ class RepositoryController implements Controller { doc.included.add(r); } } - return Response.ok(doc); + return ok(doc); } @override - Future fetchResource( - http.Request request, ResourceTarget target) async { + Future fetchResource(Request request, ResourceTarget target) async { final resource = await _fetchLinkedResource(target.type, target.id); final doc = OutboundDataDocument.resource(resource) ..links['self'] = Link(design.resource(target.type, target.id)); @@ -45,115 +44,113 @@ class RepositoryController implements Controller { await for (final r in _getAllRelated(resource, forest)) { doc.included.add(r); } - return Response.ok(doc); + return ok(doc); } @override - Future createResource(http.Request request, Target target) async { + Future createResource(Request request, Target target) async { final document = await _decode(request); final newResource = document.dataAsNewResource(); final res = newResource.toResource(getId); await repo.persist( res.type, Model(res.id)..setFrom(ModelProps.fromResource(res))); if (newResource.id != null) { - return Response.noContent(); + 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; - return Response.created( + return created( OutboundDataDocument.resource(resource)..links['self'] = self, self.uri.toString()); } @override - Future addMany( - http.Request request, RelationshipTarget target) async { + 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(); - return Response.ok(OutboundDataDocument.many(ToMany(refs))); + return ok(OutboundDataDocument.many(ToMany(refs))); } @override Future deleteResource( - http.Request request, ResourceTarget target) async { + Request request, ResourceTarget target) async { await repo.delete(target.type, target.id); - return Response.noContent(); + return noContent(); } @override Future updateResource( - http.Request request, ResourceTarget target) async { + Request request, ResourceTarget target) async { await repo.update(target.type, target.id, ModelProps.fromResource((await _decode(request)).dataAsResource())); - return Response.noContent(); + return noContent(); } @override Future replaceRelationship( - http.Request request, RelationshipTarget target) async { + Request request, RelationshipTarget target) async { final rel = (await _decode(request)).asRelationship(); if (rel is ToOne) { final ref = rel.identifier; await repo.replaceOne(target.type, target.id, target.relationship, ref); - return Response.ok( + 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(); - return Response.ok(OutboundDataDocument.many(ToMany(ids))); + return ok(OutboundDataDocument.many(ToMany(ids))); } throw FormatException('Incomplete relationship'); } @override Future deleteMany( - http.Request request, RelationshipTarget target) async { + Request request, RelationshipTarget target) async { final rel = (await _decode(request)).asToMany(); final ids = await repo .deleteMany(target.type, target.id, target.relationship, rel) .toList(); - return Response.ok(OutboundDataDocument.many(ToMany(ids))); + return ok(OutboundDataDocument.many(ToMany(ids))); } @override Future fetchRelationship( - http.Request request, RelationshipTarget target) async { + Request request, RelationshipTarget target) async { final model = (await repo.fetch(target.type, target.id)); if (model.one.containsKey(target.relationship)) { - return Response.ok(OutboundDataDocument.one( + return ok(OutboundDataDocument.one( ToOne(model.one[target.relationship]?.toIdentifier()))); } final many = model.many[target.relationship]?.map((it) => it.toIdentifier()); if (many != null) { final doc = OutboundDataDocument.many(ToMany(many)); - return Response.ok(doc); + return ok(doc); } throw RelationshipNotFound(target.type, target.id, target.relationship); } @override - Future fetchRelated( - http.Request request, RelatedTarget target) async { + 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 doc = OutboundDataDocument.resource(related); - return Response.ok(doc); + return ok(doc); } if (model.many.containsKey(target.relationship)) { final many = model.many[target.relationship] ?? {}; final doc = OutboundDataDocument.collection( await _fetchRelatedCollection(many).toList()); - return Response.ok(doc); + return ok(doc); } throw RelationshipNotFound(target.type, target.id, target.relationship); } @@ -171,10 +168,10 @@ class RepositoryController implements Controller { /// Returns a stream of related resources Stream _getRelated(Resource resource, String relationship) async* { - for (final _ in resource.relationships[relationship] ?? + for (final rel in resource.relationships[relationship] ?? (throw RelationshipNotFound( resource.type, resource.id, relationship))) { - yield await _fetchLinkedResource(_.type, _.id); + yield await _fetchLinkedResource(rel.type, rel.id); } } @@ -185,7 +182,7 @@ class RepositoryController implements Controller { } Stream _fetchAll(String type) => - repo.fetchCollection(type).map((_) => _.toResource(type)); + repo.fetchCollection(type).map((model) => model.toResource(type)); /// Fetches and builds a resource object Future _fetchResource(String type, String id) async { @@ -203,7 +200,7 @@ class RepositoryController implements Controller { } } - Future _decode(http.Request r) => r.body + Future _decode(Request r) => r.body .decode(utf8) .then(const PayloadCodec().decode) .then(InboundDocument.new); diff --git a/lib/http.dart b/lib/http.dart index 5a190c6..9a3ced9 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -26,32 +26,44 @@ class StatusCode { bool get isFailed => !isSuccessful && !isPending; } -class Json extends Body { - Json(Map super.object) : super.json(); -} - -class LoggingHandler implements Handler { - LoggingHandler(this.handler, {this.onRequest, this.onResponse}); +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; + }; - final Handler handler; - final Function(Request request)? onRequest; - final Function(Response response)? onResponse; +/// CORS middleware +Handler corsMiddleware(Handler handler) => (Request request) async { + final corsHeaders = { + 'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'], + 'Access-Control-Expose-Headers': ['Location'], + }; - @override - Future handle(Request request) async { - onRequest?.call(request); - final response = await handler.handle(request); - onResponse?.call(response); - return response; - } -} + 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]; - if (v != null && v.isNotEmpty) { - return v.last; - } - return null; + return (v != null && v.isNotEmpty) ? v.last : null; } } diff --git a/lib/server.dart b/lib/server.dart index 08f79a3..1cf7a97 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -10,4 +10,4 @@ export 'package:json_api/src/server/errors/relationship_not_found.dart'; export 'package:json_api/src/server/errors/resource_not_found.dart'; export 'package:json_api/src/server/errors/unmatched_target.dart'; export 'package:json_api/src/server/response.dart'; -export 'package:json_api/src/server/try_catch_handler.dart'; +export 'package:json_api/src/server/try_catch_middleware.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 405267e..c5b1f0f 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:http_interop/extensions.dart'; -import 'package:http_interop/http_interop.dart' as http; +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'; @@ -17,14 +17,14 @@ class Client { const Client(this._handler, {PayloadCodec codec = const PayloadCodec()}) : _codec = codec; - final http.Handler _handler; + final i.Handler _handler; final PayloadCodec _codec; /// Sends the [request] to the given [uri]. Future send(Uri uri, Request request) async { final json = await _encode(request.document); - final body = http.Body.text(json, utf8); - final headers = http.Headers.from({ + final body = i.Body.text(json, utf8); + final headers = i.Headers.from({ 'Accept': [mediaType], if (json.isNotEmpty) 'Content-Type': [mediaType], ...request.headers @@ -33,7 +33,7 @@ class Client { ? uri : uri.replace(queryParameters: request.query.toQuery()); final response = - await _handler.handle(http.Request(request.method, url, body, headers)); + await _handler(i.Request(request.method, url, body, headers)); final document = await _decode(response); return Response(response, document); @@ -42,7 +42,7 @@ class Client { Future _encode(Object? doc) async => doc == null ? '' : await _codec.encode(doc); - Future _decode(http.Response response) async { + Future _decode(i.Response response) async { final json = await response.body.decode(utf8); if (json.isNotEmpty && response.headers diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index e75f631..47fbad6 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -12,13 +12,13 @@ class Sort with IterableMixin implements QueryEncodable { /// Sort(['-created', 'title']); /// ``` Sort([Iterable fields = const []]) { - _.addAll(fields.map((SortField.parse))); + _fields.addAll(fields.map((SortField.parse))); } - static Sort fromUri(Uri uri) => - Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? [])); + static Sort fromUri(Uri uri) => Sort( + (uri.queryParametersAll['sort']?.expand((it) => it.split(',')) ?? [])); - final _ = []; + final _fields = []; /// Converts to a map of query parameters @override @@ -27,10 +27,10 @@ class Sort with IterableMixin implements QueryEncodable { }; @override - int get length => _.length; + int get length => _fields.length; @override - Iterator get iterator => _.iterator; + Iterator get iterator => _fields.iterator; } abstract class SortField { diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart index e10d519..4171d1f 100644 --- a/lib/src/server/controller_router.dart +++ b/lib/src/server/controller_router.dart @@ -9,13 +9,12 @@ import 'package:json_api/src/server/errors/unacceptable.dart'; import 'package:json_api/src/server/errors/unmatched_target.dart'; import 'package:json_api/src/server/errors/unsupported_media_type.dart'; -class ControllerRouter implements Handler { +class ControllerRouter { ControllerRouter(this._controller, this._matchTarget); final Controller _controller; final Target? Function(Uri uri) _matchTarget; - @override Future handle(Request request) async { _validate(request); final target = _matchTarget(request.uri); diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index b1d9126..6300408 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -34,25 +34,25 @@ class ErrorConverter { Future call(Object? error, StackTrace trace) async => switch (error) { MethodNotAllowed() => - await onMethodNotAllowed?.call(error) ?? Response.methodNotAllowed(), + await onMethodNotAllowed?.call(error) ?? methodNotAllowed(), UnmatchedTarget() => - await onUnmatchedTarget?.call(error) ?? Response.badRequest(), + await onUnmatchedTarget?.call(error) ?? badRequest(), CollectionNotFound() => await onCollectionNotFound?.call(error) ?? - Response.notFound(OutboundErrorDocument([ + notFound(OutboundErrorDocument([ ErrorObject( title: 'Collection Not Found', detail: 'Type: ${error.type}', ) ])), ResourceNotFound() => await onResourceNotFound?.call(error) ?? - Response.notFound(OutboundErrorDocument([ + notFound(OutboundErrorDocument([ ErrorObject( title: 'Resource Not Found', detail: 'Type: ${error.type}, id: ${error.id}', ) ])), RelationshipNotFound() => await onRelationshipNotFound?.call(error) ?? - Response.notFound(OutboundErrorDocument([ + notFound(OutboundErrorDocument([ ErrorObject( title: 'Relationship Not Found', detail: 'Type: ${error.type}' @@ -60,10 +60,10 @@ class ErrorConverter { ', relationship: ${error.relationship}', ) ])), - UnsupportedMediaType() => Response.unsupportedMediaType(), - Unacceptable() => Response.unacceptable(), + UnsupportedMediaType() => unsupportedMediaType(), + Unacceptable() => unacceptable(), _ => await onError?.call(error, trace) ?? - Response(500, + response(500, document: OutboundErrorDocument( [ErrorObject(title: 'Internal Server Error')])) }; diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index d8cabaa..16109f9 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,41 +1,38 @@ -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/http.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response -class Response extends http.Response { - Response(int statusCode, {D? document}) - : super( - statusCode, - document != null ? http.Body.json(document) : http.Body(), - http.Headers()) { - if (document != null) { - headers['Content-Type'] = [mediaType]; - } +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; +} - static Response ok(OutboundDocument document) => - Response(StatusCode.ok, document: document); +Response ok(OutboundDocument document) => + response(StatusCode.ok, document: document); - static Response noContent() => Response(StatusCode.noContent); +Response noContent() => response(StatusCode.noContent); - static Response created(OutboundDocument document, String location) => - Response(StatusCode.created, document: document) - ..headers['location'] = [location]; +Response created(OutboundDocument document, String location) => + response(StatusCode.created, document: document) + ..headers['location'] = [location]; - static Response notFound([OutboundErrorDocument? document]) => - Response(StatusCode.notFound, document: document); +Response notFound([OutboundErrorDocument? document]) => + response(StatusCode.notFound, document: document); - static Response methodNotAllowed([OutboundErrorDocument? document]) => - Response(StatusCode.methodNotAllowed, document: document); +Response methodNotAllowed([OutboundErrorDocument? document]) => + response(StatusCode.methodNotAllowed, document: document); - static Response badRequest([OutboundErrorDocument? document]) => - Response(StatusCode.badRequest, document: document); +Response badRequest([OutboundErrorDocument? document]) => + response(StatusCode.badRequest, document: document); - static Response unsupportedMediaType([OutboundErrorDocument? document]) => - Response(StatusCode.unsupportedMediaType, document: document); +Response unsupportedMediaType([OutboundErrorDocument? document]) => + response(StatusCode.unsupportedMediaType, document: document); - static Response unacceptable([OutboundErrorDocument? document]) => - Response(StatusCode.unacceptable, document: document); -} +Response unacceptable([OutboundErrorDocument? document]) => + response(StatusCode.unacceptable, document: document); diff --git a/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart deleted file mode 100644 index 312d1ea..0000000 --- a/lib/src/server/try_catch_handler.dart +++ /dev/null @@ -1,26 +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. -class TryCatchHandler implements Handler { - TryCatchHandler(this.wrapped, {this.onError}); - - final Handler wrapped; - final Future Function(dynamic, StackTrace)? onError; - - @override - Future handle(Request request) async { - try { - return await wrapped.handle(request); - } on Response catch (response) { - return response; - } catch (error, stacktrace) { - return await onError?.call(error, stacktrace) ?? (throw error); - } - } -} diff --git a/lib/src/server/try_catch_middleware.dart b/lib/src/server/try_catch_middleware.dart new file mode 100644 index 0000000..b9cba2b --- /dev/null +++ b/lib/src/server/try_catch_middleware.dart @@ -0,0 +1,21 @@ +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 eab220f..d378e2d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,24 +1,24 @@ name: json_api -version: 7.0.1 +version: 8.0.0 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) environment: - sdk: '>=3.1.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' dependencies: http_parser: ^4.0.0 - http_interop: ^1.0.0 + http_interop: ^2.0.0 dev_dependencies: - lints: ^3.0.0 + lints: ^4.0.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.1.0 - http_interop_http: ^0.7.0 - http_interop_io: ^0.7.0 + http: ^1.2.1 + http_interop_http: ^0.9.0 + http_interop_io: ^0.9.0 cider: diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index 4d3e32b..41520f5 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, Client(testHandler())); }); group('CRUD', () { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index edccf8b..96d10f0 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -8,7 +8,7 @@ void main() { late Client client; setUp(() async { - client = Client(TestHandler()); + client = Client(testHandler()); }); group('Errors', () { @@ -26,12 +26,12 @@ void main() { } }); test('Bad request when target can not be matched', () async { - final r = await TestHandler().handle(http.Request( + final r = await testHandler()(http.Request( 'get', Uri.parse('/a/long/prefix/'), http.Body(), http.Headers())); expect(r.statusCode, 400); }); test('Unsupported extension', () async { - final r = await TestHandler().handle(http.Request( + final r = await testHandler()(http.Request( 'get', Uri.parse('/posts/1'), http.Body(), @@ -42,7 +42,7 @@ void main() { expect(r.statusCode, 415); }); test('Unacceptable', () async { - final r = await TestHandler().handle(http.Request( + final r = await testHandler()(http.Request( 'get', Uri.parse('/posts/1'), http.Body(), diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index a48d2b9..06b215b 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -9,7 +9,7 @@ void main() { late RoutingClient client; setUp(() async { - client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler())); + client = RoutingClient(StandardUriDesign.pathOnly, Client(testHandler())); }); group('Resource creation', () { diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 21de2ef..decb697 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,9 +1,9 @@ -import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import 'e2e_test_set.dart'; +import 'one_off_handler.dart'; void main() { late RoutingClient client; @@ -11,9 +11,8 @@ 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(oneOffHandler)); }); testLocationIsSet(() => client); diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index ff36393..71121a3 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -3,10 +3,11 @@ import 'package:stream_channel/stream_channel.dart'; import '../../example/server/json_api_server.dart'; import '../test_handler.dart'; -void hybridMain(StreamChannel channel, Object message) async { - final host = 'localhost'; - final port = 8000; - final server = JsonApiServer(TestHandler(), host: host, port: port); +void hybridMain(StreamChannel channel) async { + const host = 'localhost'; + const port = 8888; + 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 new file mode 100644 index 0000000..fe86e6d --- /dev/null +++ b/test/e2e/one_off_handler.dart @@ -0,0 +1,10 @@ +import 'package:http/http.dart' as h; +import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_http/http_interop_http.dart'; + +Future oneOffHandler(Request request) async { + final client = h.Client(); + final response = await client.handleInterop(request); + client.close(); + return response; +} diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index d644f35..50d1e12 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -1,4 +1,3 @@ -import 'package:http_interop_http/http_interop_http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -6,6 +5,7 @@ import 'package:test/test.dart'; import '../../example/server/json_api_server.dart'; import '../test_handler.dart'; import 'e2e_test_set.dart'; +import 'one_off_handler.dart'; void main() { late RoutingClient client; @@ -13,12 +13,12 @@ void main() { group('On VM', () { setUpAll(() async { - server = JsonApiServer(TestHandler(), port: 8001); + server = JsonApiServer(testHandler(), port: 8001); await server.start(); client = RoutingClient( StandardUriDesign( Uri(scheme: 'http', host: server.host, port: server.port)), - Client(OneOffHandler())); + Client(oneOffHandler)); }); tearDownAll(() async { diff --git a/test/test_handler.dart b/test/test_handler.dart index 3b29817..e9177b1 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -1,4 +1,4 @@ -import 'package:http_interop/http_interop.dart' as http; +import 'package:http_interop/http_interop.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; @@ -6,27 +6,26 @@ import 'package:json_api/server.dart'; import '../example/server/in_memory_repo.dart'; import '../example/server/repository_controller.dart'; -class TestHandler extends LoggingHandler { - TestHandler( - {Iterable types = const ['users', 'posts', 'comments'], - Function(http.Request request)? onRequest, - Function(http.Response response)? onResponse, - Future Function(dynamic, StackTrace)? onError}) - : super( - TryCatchHandler( - ControllerRouter(RepositoryController(InMemoryRepo(types), _id), - StandardUriDesign.matchTarget), - onError: ErrorConverter( - onError: onError ?? - (err, trace) { - print(trace); - throw err; - }) - .call), - onRequest: onRequest, - onResponse: onResponse); -} +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); int _counter = 0; - -String _id() => (_counter++).toString(); diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index c617682..eaf38e0 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -11,12 +11,13 @@ import 'mock_handler.dart'; import 'response.dart' as mock; void main() { - final http = MockHandler(); - final client = RoutingClient(StandardUriDesign.pathOnly, Client(http)); + final mockHandler = MockHandler(); + final client = + RoutingClient(StandardUriDesign.pathOnly, Client(mockHandler.handle)); group('Failure', () { test('RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client.fetchCollection('articles'); fail('Exception expected'); @@ -27,7 +28,7 @@ void main() { } }); test('ServerError', () async { - http.response = mock.error500(); + mockHandler.response = mock.error500(); try { await client.fetchCollection('articles'); fail('Exception expected'); @@ -39,21 +40,21 @@ void main() { group('Fetch Collection', () { test('Min', () async { - http.response = mock.collectionMin(); + mockHandler.response = mock.collectionMin(); final response = await client.fetchCollection('articles'); expect(response.collection.single.type, 'articles'); expect(response.collection.single.id, '1'); expect(response.included, isEmpty); - expect(http.request.method, equals('get')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.toString(), '/articles'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); }); test('Full', () async { - http.response = mock.collectionFull(); + mockHandler.response = mock.collectionFull(); final response = await client.fetchCollection('articles', headers: { 'foo': ['bar'] }, query: [ @@ -70,16 +71,16 @@ void main() { expect(response.collection.length, 1); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/articles'); - expect(http.request.uri.queryParameters, { + 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' }); - expect(http.request.headers, { + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'foo': ['bar'] }); @@ -89,19 +90,19 @@ void main() { group('Fetch Related Collection', () { test('Min', () async { - http.response = mock.collectionFull(); + mockHandler.response = mock.collectionFull(); final response = await client.fetchRelatedCollection('people', '1', 'articles'); expect(response.collection.length, 1); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/people/1/articles'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.path, '/people/1/articles'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); test('Full', () async { - http.response = mock.collectionFull(); + mockHandler.response = mock.collectionFull(); final response = await client .fetchRelatedCollection('people', '1', 'articles', headers: { 'foo': ['bar'] @@ -119,16 +120,16 @@ void main() { expect(response.collection.length, 1); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/people/1/articles'); - expect(http.request.uri.queryParameters, { + 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' }); - expect(http.request.headers, { + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'foo': ['bar'] }); @@ -139,18 +140,18 @@ void main() { group('Fetch Primary Resource', () { test('Min', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.fetchResource('articles', '1'); expect(response.resource.type, 'articles'); - expect(http.request.method, equals('get')); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.toString(), '/articles/1'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); test('Full', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.fetchResource('articles', '1', headers: { 'foo': ['bar'] }, query: [ @@ -164,11 +165,11 @@ void main() { ]); expect(response.resource.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/articles/1'); - expect(http.request.uri.queryParameters, + 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(http.request.headers, { + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'foo': ['bar'] }); @@ -179,20 +180,20 @@ void main() { group('Fetch Related Resource', () { test('Min', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.toString(), '/articles/1/author'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); test('Full', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client .fetchRelatedResource('articles', '1', 'author', headers: { 'foo': ['bar'] @@ -207,11 +208,11 @@ void main() { ]); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/articles/1/author'); - expect(http.request.uri.queryParameters, + 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(http.request.headers, { + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'foo': ['bar'] }); @@ -220,14 +221,14 @@ void main() { }); test('Missing resource', () async { - http.response = mock.relatedResourceNull(); + mockHandler.response = mock.relatedResourceNull(); final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource, isNull); expect(response.included, isEmpty); - expect(http.request.method, equals('get')); - expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.toString(), '/articles/1/author'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); @@ -235,18 +236,19 @@ void main() { group('Fetch Relationship', () { test('Min', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); final response = await client.fetchToOne('articles', '1', 'author'); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { + expect(mockHandler.request.method, equals('get')); + expect(mockHandler.request.uri.toString(), + '/articles/1/relationships/author'); + expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'] }); }); test('Full', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); final response = await client.fetchToOne('articles', '1', 'author', headers: { 'foo': ['bar'] @@ -256,10 +258,10 @@ void main() { }) ]); expect(response.included.length, 3); - expect(http.request.method, equals('get')); - expect(http.request.uri.path, '/articles/1/relationships/author'); - expect(http.request.uri.queryParameters, {'foo': 'bar'}); - expect(http.request.headers, { + 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'] }); @@ -268,25 +270,25 @@ void main() { group('Create New Resource', () { test('Min', () async { - http.response = mock.primaryResource(); + 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.included.length, 3); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'articles'} }); }); test('Full', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.createNew('articles', attributes: { 'cool': true }, one: { @@ -304,14 +306,14 @@ void main() { expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'attributes': {'cool': true}, @@ -341,37 +343,37 @@ void main() { group('Create Resource', () { test('Min', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.create('articles', '1'); expect(response.resource?.type, 'articles'); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Min with 204 No Content', () async { - http.response = mock.noContent(); + mockHandler.response = mock.noContent(); final response = await client.create('articles', '1'); expect(response.resource, isNull); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Full', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.create('articles', '1', attributes: { 'cool': true }, one: { @@ -386,14 +388,14 @@ void main() { 'foo': ['bar'] }); expect(response.resource?.type, 'articles'); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'id': '1', @@ -424,37 +426,37 @@ void main() { group('Update Resource', () { test('Min', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.updateResource('articles', '1'); expect(response.resource?.type, 'articles'); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Min with 204 No Content', () async { - http.response = mock.noContent(); + mockHandler.response = mock.noContent(); final response = await client.updateResource('articles', '1'); expect(response.resource, isNull); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'articles', 'id': '1'} }); }); test('Full', () async { - http.response = mock.primaryResource(); + mockHandler.response = mock.primaryResource(); final response = await client.updateResource('articles', '1', attributes: { 'cool': true @@ -470,14 +472,14 @@ void main() { 'foo': ['bar'] }); expect(response.resource?.type, 'articles'); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': { 'type': 'articles', 'id': '1', @@ -508,23 +510,24 @@ void main() { group('Replace One', () { test('Min', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); final response = await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')); expect(response.relationship, isA()); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'people', 'id': '42'} }); }); test('Full', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); final response = await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42'), meta: { @@ -534,21 +537,22 @@ void main() { 'foo': ['bar'] }); expect(response.relationship, isA()); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': {'type': 'people', 'id': '42'}, 'meta': {'hello': 'world'} }); }); test('Throws RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')); @@ -560,7 +564,7 @@ void main() { }); test('Throws FormatException', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); expect( () => client.replaceToOne( 'articles', '1', 'author', Identifier('people', '42')), @@ -570,39 +574,43 @@ void main() { group('Delete One', () { test('Min', () async { - http.response = mock.oneEmpty(); + mockHandler.response = mock.oneEmpty(); final response = await client.deleteToOne('articles', '1', 'author'); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null}); + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), + {'data': null}); }); test('Full', () async { - http.response = mock.oneEmpty(); + mockHandler.response = mock.oneEmpty(); final response = await client.deleteToOne('articles', '1', 'author', headers: { 'foo': ['bar'] }); expect(response.relationship, isA()); expect(response.relationship!.identifier, isNull); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null}); + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), + {'data': null}); }); test('Throws RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client.deleteToOne('articles', '1', 'author'); fail('Exception expected'); @@ -613,7 +621,7 @@ void main() { }); test('Throws FormatException', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); expect(() => client.deleteToOne('articles', '1', 'author'), throwsFormatException); }); @@ -621,17 +629,18 @@ void main() { group('Delete Many', () { test('Min', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, equals('delete')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -639,7 +648,7 @@ void main() { }); test('Full', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client.deleteFromMany('articles', '1', 'tags', [ Identifier('tags', '1') ], meta: { @@ -648,14 +657,15 @@ void main() { 'foo': ['bar'] }); expect(response.relationship, isA()); - expect(http.request.method, equals('delete')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -664,7 +674,7 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); @@ -676,7 +686,7 @@ void main() { }); test('Throws FormatException', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); expect( () => client.deleteFromMany( 'articles', '1', 'tags', [Identifier('tags', '1')]), @@ -686,17 +696,18 @@ void main() { group('Replace Many', () { test('Min', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -704,7 +715,7 @@ void main() { }); test('Full', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client.replaceToMany('articles', '1', 'tags', [ Identifier('tags', '1') ], meta: { @@ -713,14 +724,15 @@ void main() { 'foo': ['bar'] }); expect(response.relationship, isA()); - expect(http.request.method, equals('patch')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -729,7 +741,7 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); @@ -741,7 +753,7 @@ void main() { }); test('Throws FormatException', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); expect( () => client.replaceToMany( 'articles', '1', 'tags', [Identifier('tags', '1')]), @@ -751,17 +763,18 @@ void main() { group('Add Many', () { test('Min', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ] @@ -769,7 +782,7 @@ void main() { }); test('Full', () async { - http.response = mock.many(); + mockHandler.response = mock.many(); final response = await client.addMany('articles', '1', 'tags', [ Identifier('tags', '1') ], meta: { @@ -778,14 +791,15 @@ void main() { 'foo': ['bar'] }); expect(response.relationship, isA()); - expect(http.request.method, equals('post')); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { + 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'] }); - expect(jsonDecode(await http.request.body.decode(utf8)), { + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), { 'data': [ {'type': 'tags', 'id': '1'} ], @@ -794,7 +808,7 @@ void main() { }); test('Throws RequestFailure', () async { - http.response = mock.error422(); + mockHandler.response = mock.error422(); try { await client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); @@ -807,7 +821,7 @@ void main() { }); test('Throws FormatException', () async { - http.response = mock.one(); + mockHandler.response = mock.one(); expect( () => client .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart index d25b8a7..a0ad79e 100644 --- a/test/unit/client/mock_handler.dart +++ b/test/unit/client/mock_handler.dart @@ -1,10 +1,9 @@ import 'package:http_interop/http_interop.dart'; -class MockHandler implements Handler { +class MockHandler { late Request request; late Response response; - @override Future handle(Request request) async { this.request = request; return response; diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart index 1a64603..a5569c8 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -1,5 +1,4 @@ import 'package:http_interop/http_interop.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/src/media_type.dart'; final headers = Headers.from({ @@ -8,7 +7,7 @@ final headers = Headers.from({ collectionMin() => Response( 200, - Json({ + Body.json({ 'data': [ {'type': 'articles', 'id': '1'} ] @@ -17,7 +16,7 @@ collectionMin() => Response( collectionFull() => Response( 200, - Json({ + Body.json({ 'links': { 'self': 'http://example.com/articles', 'next': 'http://example.com/articles?page[offset]=2', @@ -90,7 +89,7 @@ collectionFull() => Response( primaryResource() => Response( 200, - Json({ + Body.json({ 'links': {'self': 'http://example.com/articles/1'}, 'meta': {'hello': 'world'}, 'data': { @@ -142,7 +141,7 @@ primaryResource() => Response( relatedResourceNull() => Response( 200, - Json({ + Body.json({ 'links': {'self': 'http://example.com/articles/1/author'}, 'meta': {'hello': 'world'}, 'data': null @@ -151,7 +150,7 @@ relatedResourceNull() => Response( one() => Response( 200, - Json({ + Body.json({ 'links': { 'self': '/articles/1/relationships/author', 'related': '/articles/1/author' @@ -197,7 +196,7 @@ one() => Response( oneEmpty() => Response( 200, - Json({ + Body.json({ 'links': { 'self': '/articles/1/relationships/author', 'related': '/articles/1/author' @@ -243,7 +242,7 @@ oneEmpty() => Response( many() => Response( 200, - Json({ + Body.json({ 'links': { 'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags' @@ -259,7 +258,7 @@ noContent() => Response(204, Body(), Headers()); error422() => Response( 422, - Json({ + Body.json({ 'meta': {'hello': 'world'}, 'errors': [ { diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/http/cors_middleware_test.dart new file mode 100644 index 0000000..6dc2939 --- /dev/null +++ b/test/unit/http/cors_middleware_test.dart @@ -0,0 +1,65 @@ +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('CORS Middleware', () { + test('Sets the headers', () async { + final rq = Request( + 'get', + Uri(host: 'localhost'), + Body(), + Headers.from({ + 'origin': ['foo'] + })); + final rs = await corsMiddleware(handler)(rq); + expect(rs.statusCode, equals(200)); + expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo'])); + expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location'])); + }); + + test('Responds to OPTIONS', () async { + final rq = Request( + 'options', + Uri(host: 'localhost'), + Body(), + 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-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'], + })); + 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'])); + }); + }); +} diff --git a/test/unit/http/logging_middleware_test.dart b/test/unit/http/logging_middleware_test.dart new file mode 100644 index 0000000..ca1faaf --- /dev/null +++ b/test/unit/http/logging_middleware_test.dart @@ -0,0 +1,26 @@ +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)); + }); + }); +}