From 78251b1730bf86a3e0b007c1cbf9a889bc24ff4e Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 15 Jul 2024 18:16:43 -0700 Subject: [PATCH 01/43] wip --- CHANGELOG.md | 2 +- example/server.dart | 24 ++--- lib/http.dart | 70 +------------ lib/server.dart | 4 +- lib/src/client/client.dart | 6 +- lib/src/http/status_code.dart | 21 ++++ lib/src/server/controller_router.dart | 65 +++++++++--- lib/src/server/cors_middleware.dart | 24 +++++ lib/src/server/error_converter.dart | 109 +++++++++----------- lib/src/server/logger_middleware.dart | 20 ++++ lib/src/server/try_catch_middleware.dart | 21 ---- pubspec.yaml | 1 + test/test_handler.dart | 25 ++--- test/unit/http/cors_middleware_test.dart | 2 +- test/unit/http/logging_middleware_test.dart | 26 ----- test/unit/server/error_converter_test.dart | 14 --- 16 files changed, 183 insertions(+), 251 deletions(-) create mode 100644 lib/src/http/status_code.dart create mode 100644 lib/src/server/cors_middleware.dart create mode 100644 lib/src/server/logger_middleware.dart delete mode 100644 lib/src/server/try_catch_middleware.dart delete mode 100644 test/unit/http/logging_middleware_test.dart delete mode 100644 test/unit/server/error_converter_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e78d19..6b87ea30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,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 diff --git a/example/server.dart b/example/server.dart index a8173b4e..550c28e1 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,8 +1,6 @@ import 'dart:io'; -import 'package:http_interop/http_interop.dart' as interop; -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:uuid/uuid.dart'; @@ -18,21 +16,11 @@ 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() + .add(loggerMiddleware) + .call(routingHandler(controller, StandardUriDesign.matchTarget)); + final server = JsonApiServer(handler, host: host, port: port); ProcessSignal.sigint.watch().listen((event) async { diff --git a/lib/http.dart b/lib/http.dart index 9a3ced90..9adb5b20 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,69 +1 @@ -import 'package:http_interop/http_interop.dart'; - -class StatusCode { - const StatusCode(this.value); - - static const ok = 200; - static const created = 201; - static const accepted = 202; - static const noContent = 204; - static const badRequest = 400; - static const notFound = 404; - static const methodNotAllowed = 405; - static const unacceptable = 406; - static const unsupportedMediaType = 415; - - final int value; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => value == accepted; - - /// True for successfully processed requests - bool get isSuccessful => value >= ok && value < 300 && !isPending; - - /// True for failed requests (i.e. neither successful nor pending) - bool get isFailed => !isSuccessful && !isPending; -} - -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/server.dart b/lib/server.dart index 1cf7a97e..4a9666a6 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,13 +1,15 @@ /// JSON:API server on top of dart:io. +/// WARNING: This library is in beta stage. The API is subject to change. library server; export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/controller_router.dart'; +export 'package:json_api/src/server/cors_middleware.dart'; export 'package:json_api/src/server/error_converter.dart'; export 'package:json_api/src/server/errors/collection_not_found.dart'; export 'package:json_api/src/server/errors/method_not_allowed.dart'; export 'package:json_api/src/server/errors/relationship_not_found.dart'; export 'package:json_api/src/server/errors/resource_not_found.dart'; export 'package:json_api/src/server/errors/unmatched_target.dart'; +export 'package:json_api/src/server/logger_middleware.dart'; export 'package:json_api/src/server/response.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 c5b1f0f3..f3f32cc7 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -2,7 +2,6 @@ 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'; @@ -45,9 +44,8 @@ class Client { Future _decode(i.Response response) async { final json = await response.body.decode(utf8); if (json.isNotEmpty && - response.headers - .last('Content-Type') - ?.toLowerCase() + response.headers['Content-Type']?.last + .toLowerCase() .startsWith(mediaType) == true) { return await _codec.decode(json); diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart new file mode 100644 index 00000000..2b70a25a --- /dev/null +++ b/lib/src/http/status_code.dart @@ -0,0 +1,21 @@ +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 notFound = 404; + static const methodNotAllowed = 405; + static const unacceptable = 406; + static const unsupportedMediaType = 415; + + /// 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/server/controller_router.dart b/lib/src/server/controller_router.dart index 4171d1f5..fc05fd65 100644 --- a/lib/src/server/controller_router.dart +++ b/lib/src/server/controller_router.dart @@ -1,6 +1,5 @@ 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'; @@ -44,22 +43,54 @@ class ControllerRouter { _ => 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(); - } +void _validate(Request request) { + 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 Unacceptable(); } - - bool _isInvalid(MediaType mt) => - mt.mimeType == mediaType && - mt.parameters.isNotEmpty; // TODO: check for ext and profile } + +bool _isInvalid(MediaType mt) => + mt.mimeType == mediaType && + mt.parameters.isNotEmpty; // TODO: check for ext and profile + +Handler routingHandler( + Controller controller, Target? Function(Uri uri) matchTarget) => + (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) + }; + }; diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart new file mode 100644 index 00000000..f14d2366 --- /dev/null +++ b/lib/src/server/cors_middleware.dart @@ -0,0 +1,24 @@ +// CORS middleware +import 'package:http_interop/http_interop.dart'; + +Handler corsMiddleware(Handler handler) => + (Request request) async => switch (request.method) { + 'options' => Response( + 204, + Body(), + Headers.from({ + 'Access-Control-Allow-Methods': + request.headers['Access-Control-Request-Method'] ?? + const ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'], + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? + const ['*'], + })), + _ => await handler(request) + } + ..headers.addAll({ + 'Access-Control-Allow-Origin': [ + request.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..95ab1b84 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -1,4 +1,5 @@ -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/collection_not_found.dart'; import 'package:json_api/src/server/errors/method_not_allowed.dart'; @@ -9,62 +10,50 @@ 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([ - ErrorObject( - title: 'Resource Not Found', - detail: 'Type: ${error.type}, id: ${error.id}', - ) - ])), - RelationshipNotFound() => await onRelationshipNotFound?.call(error) ?? - notFound(OutboundErrorDocument([ - ErrorObject( - title: 'Relationship Not Found', - 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')])) - }; -} +/// 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) { + Response() => 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([ + ErrorObject( + title: 'Relationship Not Found', + 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')])) + }); diff --git a/lib/src/server/logger_middleware.dart b/lib/src/server/logger_middleware.dart new file mode 100644 index 00000000..86e08277 --- /dev/null +++ b/lib/src/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/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 d378e2d2..9d9a8c93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: http_parser: ^4.0.0 http_interop: ^2.0.0 + http_interop_middleware: ^0.1.0 dev_dependencies: lints: ^4.0.0 diff --git a/test/test_handler.dart b/test/test_handler.dart index e9177b12..608733b0 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -1,5 +1,5 @@ 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'; @@ -9,23 +9,10 @@ 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); + Function(Response response)? onResponse}) => + errorConverter().add(corsMiddleware).call(routingHandler( + RepositoryController( + InMemoryRepo(types), () => (_counter++).toString()), + StandardUriDesign.matchTarget)); int _counter = 0; diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/http/cors_middleware_test.dart index 6dc2939e..761bba87 100644 --- a/test/unit/http/cors_middleware_test.dart +++ b/test/unit/http/cors_middleware_test.dart @@ -1,7 +1,7 @@ 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'; 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/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart deleted file mode 100644 index b2fbc4bc..00000000 --- a/test/unit/server/error_converter_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:convert'; - -import 'package:http_interop/extensions.dart'; -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; - -void main() { - test('500', () async { - final r = await ErrorConverter().call('Foo', StackTrace.current); - expect(r.statusCode, equals(500)); - expect(await r.body.decode(utf8), - equals('{"errors":[{"title":"Internal Server Error"}]}')); - }); -} From fc56c601c538c6e8d03edb0cf1577d62d6a27058 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 16 Jul 2024 16:59:09 -0700 Subject: [PATCH 02/43] WIP --- example/server.dart | 3 +- .../server/logger_middleware.dart | 0 lib/server.dart | 4 +- lib/src/server/controller_router.dart | 96 ------------------- lib/src/server/cors_middleware.dart | 1 - lib/src/server/request_validator.dart | 25 +++++ lib/src/server/router.dart | 32 +++++++ test/test_handler.dart | 8 +- .../cors_middleware_test.dart | 0 9 files changed, 65 insertions(+), 104 deletions(-) rename {lib/src => example}/server/logger_middleware.dart (100%) delete mode 100644 lib/src/server/controller_router.dart create mode 100644 lib/src/server/request_validator.dart create mode 100644 lib/src/server/router.dart rename test/unit/{http => server}/cors_middleware_test.dart (100%) diff --git a/example/server.dart b/example/server.dart index 550c28e1..ab13f033 100644 --- a/example/server.dart +++ b/example/server.dart @@ -7,6 +7,7 @@ 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'; @@ -19,7 +20,7 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(routingHandler(controller, StandardUriDesign.matchTarget)); + .call(router(controller, StandardUriDesign.matchTarget)); final server = JsonApiServer(handler, host: host, port: port); diff --git a/lib/src/server/logger_middleware.dart b/example/server/logger_middleware.dart similarity index 100% rename from lib/src/server/logger_middleware.dart rename to example/server/logger_middleware.dart diff --git a/lib/server.dart b/lib/server.dart index 4a9666a6..d090ab0e 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,7 +3,6 @@ library server; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/controller_router.dart'; export 'package:json_api/src/server/cors_middleware.dart'; export 'package:json_api/src/server/error_converter.dart'; export 'package:json_api/src/server/errors/collection_not_found.dart'; @@ -11,5 +10,6 @@ export 'package:json_api/src/server/errors/method_not_allowed.dart'; export 'package:json_api/src/server/errors/relationship_not_found.dart'; export 'package:json_api/src/server/errors/resource_not_found.dart'; export 'package:json_api/src/server/errors/unmatched_target.dart'; -export 'package:json_api/src/server/logger_middleware.dart'; +export 'package:json_api/src/server/request_validator.dart'; export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart deleted file mode 100644 index fc05fd65..00000000 --- a/lib/src/server/controller_router.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:http_interop/http_interop.dart'; -import 'package:http_parser/http_parser.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['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 Unacceptable(); - } -} - -bool _isInvalid(MediaType mt) => - mt.mimeType == mediaType && - mt.parameters.isNotEmpty; // TODO: check for ext and profile - -Handler routingHandler( - Controller controller, Target? Function(Uri uri) matchTarget) => - (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) - }; - }; diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart index f14d2366..3c288aa9 100644 --- a/lib/src/server/cors_middleware.dart +++ b/lib/src/server/cors_middleware.dart @@ -1,4 +1,3 @@ -// CORS middleware import 'package:http_interop/http_interop.dart'; Handler corsMiddleware(Handler handler) => diff --git a/lib/src/server/request_validator.dart b/lib/src/server/request_validator.dart new file mode 100644 index 00000000..c4e97d02 --- /dev/null +++ b/lib/src/server/request_validator.dart @@ -0,0 +1,25 @@ +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/unacceptable.dart'; +import 'package:json_api/src/server/errors/unsupported_media_type.dart'; + +final requestValidator = 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 Unacceptable(); + } + return null; +}); + +bool _isInvalid(MediaType mt) => + mt.mimeType == mediaType && + mt.parameters.isNotEmpty; // TODO: check for ext and profile diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart new file mode 100644 index 00000000..8f985e2d --- /dev/null +++ b/lib/src/server/router.dart @@ -0,0 +1,32 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/unmatched_target.dart'; + +Handler router(Controller controller, Target? Function(Uri uri) matchTarget) => + (request) => switch (matchTarget(request.uri)) { + RelationshipTarget target => 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 target => switch (request.method) { + 'get' => controller.fetchRelated(request, target), + _ => throw MethodNotAllowed(request.method) + }, + ResourceTarget target => switch (request.method) { + 'get' => controller.fetchResource(request, target), + 'patch' => controller.updateResource(request, target), + 'delete' => controller.deleteResource(request, target), + _ => throw MethodNotAllowed(request.method) + }, + Target target => switch (request.method) { + 'get' => controller.fetchCollection(request, target), + 'post' => controller.createResource(request, target), + _ => throw MethodNotAllowed(request.method) + }, + _ => throw UnmatchedTarget(request.uri) + }; diff --git a/test/test_handler.dart b/test/test_handler.dart index 608733b0..da8ffe75 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -10,9 +10,9 @@ Handler testHandler( {Iterable types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, Function(Response response)? onResponse}) => - errorConverter().add(corsMiddleware).call(routingHandler( - RepositoryController( - InMemoryRepo(types), () => (_counter++).toString()), - StandardUriDesign.matchTarget)); + corsMiddleware.add(requestValidator).add(errorConverter()).call( + router(RepositoryController(InMemoryRepo(types), _nextId), + StandardUriDesign.matchTarget)); +String _nextId() => (_counter++).toString(); int _counter = 0; diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart similarity index 100% rename from test/unit/http/cors_middleware_test.dart rename to test/unit/server/cors_middleware_test.dart From 47575fc0fb28ce81abf50d42e084240a7b4c9a03 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 16 Jul 2024 18:53:31 -0700 Subject: [PATCH 03/43] WIP --- lib/http.dart | 3 + lib/server.dart | 2 + lib/src/http/status_code.dart | 3 +- lib/src/server/error_converter.dart | 10 ++- lib/src/server/errors/not_acceptable.dart | 1 + lib/src/server/errors/unacceptable.dart | 1 - lib/src/server/request_validator.dart | 4 +- lib/src/server/response.dart | 7 +- test/unit/server/error_converter_test.dart | 84 ++++++++++++++++++++++ 9 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 lib/src/server/errors/not_acceptable.dart delete mode 100644 lib/src/server/errors/unacceptable.dart create mode 100644 test/unit/server/error_converter_test.dart diff --git a/lib/http.dart b/lib/http.dart index 9adb5b20..a1fee2b4 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1 +1,4 @@ +/// Common HTTP utilities for JSON:API clients and servers. +/// WARNING: This library is in beta stage. The API is subject to change. +library http; export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/server.dart b/lib/server.dart index d090ab0e..198266c7 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -7,9 +7,11 @@ 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/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/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/request_validator.dart'; export 'package:json_api/src/server/response.dart'; export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart index 2b70a25a..e3c9eba1 100644 --- a/lib/src/http/status_code.dart +++ b/lib/src/http/status_code.dart @@ -6,8 +6,9 @@ extension type StatusCode(int value) { static const badRequest = 400; static const notFound = 404; static const methodNotAllowed = 405; - static const unacceptable = 406; + static const notAcceptable = 406; static const unsupportedMediaType = 415; + static const internalServerError = 500; /// True for the requests processed asynchronously. /// @see https://jsonapi.org/recommendations/#asynchronous-processing). diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index 95ab1b84..7b3e91e3 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -3,9 +3,9 @@ import 'package:http_interop_middleware/http_interop_middleware.dart'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/errors/collection_not_found.dart'; import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/not_acceptable.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'; @@ -21,7 +21,6 @@ Middleware errorConverter({ }) => middleware( onError: (error, trace, _) async => switch (error) { - Response() => error, MethodNotAllowed() => await onMethodNotAllowed?.call(error) ?? methodNotAllowed(), UnmatchedTarget() => @@ -51,9 +50,8 @@ Middleware errorConverter({ ) ])), UnsupportedMediaType() => unsupportedMediaType(), - Unacceptable() => unacceptable(), + NotAcceptable() => notAcceptable(), _ => await onError?.call(error, trace) ?? - response(500, - document: OutboundErrorDocument( - [ErrorObject(title: 'Internal Server Error')])) + internalServerError(OutboundErrorDocument( + [ErrorObject(title: 'Internal Server Error')])) }); diff --git a/lib/src/server/errors/not_acceptable.dart b/lib/src/server/errors/not_acceptable.dart new file mode 100644 index 00000000..fbd94437 --- /dev/null +++ b/lib/src/server/errors/not_acceptable.dart @@ -0,0 +1 @@ +class NotAcceptable 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/request_validator.dart b/lib/src/server/request_validator.dart index c4e97d02..3f2b9a8e 100644 --- a/lib/src/server/request_validator.dart +++ b/lib/src/server/request_validator.dart @@ -2,7 +2,7 @@ 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/unacceptable.dart'; +import 'package:json_api/src/server/errors/not_acceptable.dart'; import 'package:json_api/src/server/errors/unsupported_media_type.dart'; final requestValidator = middleware(onRequest: (Request request) async { @@ -15,7 +15,7 @@ final requestValidator = middleware(onRequest: (Request request) async { .map((it) => it.trim()) .map(MediaType.parse) .any(_isInvalid)) { - throw Unacceptable(); + throw NotAcceptable(); } return null; }); diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 16109f9c..0ec571bc 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -34,5 +34,8 @@ Response badRequest([OutboundErrorDocument? 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); \ No newline at end of file diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart new file mode 100644 index 00000000..4bb17dfe --- /dev/null +++ b/test/unit/server/error_converter_test.dart @@ -0,0 +1,84 @@ +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'; + +void main() { + final get = Request('get', Uri(), Body(), 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(), Headers()), + onUnmatchedTarget: (_) async => Response(551, Body(), Headers()), + onCollectionNotFound: (_) async => Response(552, Body(), Headers()), + onResourceNotFound: (_) async => Response(553, Body(), Headers()), + onRelationshipNotFound: (_) async => Response(554, Body(), Headers()), + onError: (_, __) async => Response(555, Body(), 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)); + }); + }); +} From ae8fb36a46f90887083b05a9f47930cfd27b91e3 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 16 Jul 2024 18:56:07 -0700 Subject: [PATCH 04/43] Format --- lib/http.dart | 1 + lib/src/server/response.dart | 2 +- pubspec.yaml | 2 +- test/test_handler.dart | 6 +++--- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/http.dart b/lib/http.dart index a1fee2b4..888d57d0 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,4 +1,5 @@ /// Common HTTP utilities for JSON:API clients and servers. /// WARNING: This library is in beta stage. The API is subject to change. library http; + export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 0ec571bc..226335ed 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -38,4 +38,4 @@ Response notAcceptable([OutboundErrorDocument? document]) => response(StatusCode.notAcceptable, document: document); Response internalServerError([OutboundErrorDocument? document]) => - response(StatusCode.internalServerError, document: document); \ No newline at end of file + response(StatusCode.internalServerError, document: document); diff --git a/pubspec.yaml b/pubspec.yaml index 9d9a8c93..5fa5af27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 8.0.0 +version: 9.0.0-alpha.1 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: diff --git a/test/test_handler.dart b/test/test_handler.dart index da8ffe75..8153faf9 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -10,9 +10,9 @@ Handler testHandler( {Iterable types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, Function(Response response)? onResponse}) => - corsMiddleware.add(requestValidator).add(errorConverter()).call( - router(RepositoryController(InMemoryRepo(types), _nextId), - StandardUriDesign.matchTarget)); + corsMiddleware.add(requestValidator).add(errorConverter()).call(router( + RepositoryController(InMemoryRepo(types), _nextId), + StandardUriDesign.matchTarget)); String _nextId() => (_counter++).toString(); int _counter = 0; From f46e34cc41d2fb759070d2acb7bb492510631fd9 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Aug 2024 15:07:05 -0700 Subject: [PATCH 05/43] alpha.2 --- CHANGELOG.md | 5 + example/server.dart | 2 +- lib/src/routing/standard_uri_design.dart | 22 +++-- pubspec.yaml | 4 +- test/test_handler.dart | 2 +- test/unit/routing/url_test.dart | 117 +++++++++++++++++++---- 6 files changed, 121 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b87ea30..44a2b099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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] +### Changed +- URL Design matching now respects the base URL + ## [8.0.0] - 2024-07-01 ### Added - CORS middleware @@ -257,6 +261,7 @@ the Document model. ### Added - Client: fetch resources, collections, related resources and relationships +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/8.0.0...HEAD [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 diff --git a/example/server.dart b/example/server.dart index ab13f033..e6734341 100644 --- a/example/server.dart +++ b/example/server.dart @@ -20,7 +20,7 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(router(controller, StandardUriDesign.matchTarget)); + .call(router(controller, StandardUriDesign.pathOnly.matchTarget)); final server = JsonApiServer(handler, host: host, port: port); diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart index 2b474f65..f26bc736 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -11,14 +11,20 @@ class StandardUriDesign implements UriDesign { /// `/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 - }; + /// Matches a [uri] to a [Target] object. + Target? matchTarget(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.length)) { + [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; diff --git a/pubspec.yaml b/pubspec.yaml index 5fa5af27..695d8532 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,9 @@ name: json_api -version: 9.0.0-alpha.1 +version: 9.0.0-alpha.2 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.5.0 <4.0.0' dependencies: http_parser: ^4.0.0 diff --git a/test/test_handler.dart b/test/test_handler.dart index 8153faf9..4b4288b7 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -12,7 +12,7 @@ Handler testHandler( Function(Response response)? onResponse}) => corsMiddleware.add(requestValidator).add(errorConverter()).call(router( RepositoryController(InMemoryRepo(types), _nextId), - StandardUriDesign.matchTarget)); + StandardUriDesign.pathOnly.matchTarget)); String _nextId() => (_counter++).toString(); int _counter = 0; diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index e3781eb5..b534f98b 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -3,33 +3,112 @@ 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(), + final d = StandardUriDesign.pathOnly; + expect(d.collection('books').toString(), '/books'); + expect(d.resource('books', '42').toString(), '/books/42'); + expect(d.related('books', '42', 'author').toString(), '/books/42/author'); + expect(d.relationship('books', '42', 'author').toString(), '/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.collection('books').toString(), 'https://example.com:8080/books'); + expect(d.resource('books', '42').toString(), + 'https://example.com:8080/books/42'); + expect(d.related('books', '42', 'author').toString(), + 'https://example.com:8080/books/42/author'); + expect(d.relationship('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(), + test('Host and path is retained if exists in base (directory path)', () { + final d = StandardUriDesign(Uri.parse('https://example.com/foo/')); + expect(d.collection('books').toString(), 'https://example.com/foo/books'); + expect(d.resource('books', '42').toString(), 'https://example.com/foo/books/42'); - expect(url.related('books', '42', 'author').toString(), + expect(d.related('books', '42', 'author').toString(), 'https://example.com/foo/books/42/author'); - expect(url.relationship('books', '42', 'author').toString(), + expect(d.relationship('books', '42', 'author').toString(), 'https://example.com/foo/books/42/relationships/author'); }); + group('Target matching', () { + test('Path only', () { + final d = StandardUriDesign.pathOnly; + expect(d.matchTarget(Uri.parse('/books')), isA()); + expect(d.matchTarget(Uri.parse('/books/42')), isA()); + expect( + d.matchTarget(Uri.parse('/books/42/authors')), isA()); + expect(d.matchTarget(Uri.parse('/books/42/relationships/authors')), + isA()); + expect(d.matchTarget(Uri.parse('/a/b/c/d')), isNull); + }); + test('Path only, full url', () { + final d = StandardUriDesign.pathOnly; + expect( + d.matchTarget(Uri.parse('https://example.com/books')), isA()); + expect(d.matchTarget(Uri.parse('https://example.com/books/42')), + isA()); + expect(d.matchTarget(Uri.parse('https://example.com/books/42/authors')), + isA()); + expect( + d.matchTarget( + Uri.parse('https://example.com/books/42/relationships/authors')), + isA()); + expect(d.matchTarget(Uri.parse('https://example.com/a/b/c/d')), isNull); + }); + test('Authority', () { + final d = StandardUriDesign(Uri.parse('https://example.com:8080')); + expect(d.matchTarget(Uri.parse('https://example.com:8080/books')), + isA()); + expect(d.matchTarget(Uri.parse('https://example.com:8080/books/42')), + isA()); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')), + isA()); + expect( + d.matchTarget(Uri.parse( + 'https://example.com:8080/books/42/relationships/authors')), + isA()); + + expect( + d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); + }); + + test('Authority and path', () { + final d = StandardUriDesign(Uri.parse('https://example.com:8080/api')); + expect(d.matchTarget(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books'))); + expect( + d.matchTarget(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.matchTarget( + 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.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); + expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), + isNull); + }); + }); } From 9a7c6afa85f3025c36ec6945af4a42334022d123 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Aug 2024 20:37:42 -0700 Subject: [PATCH 06/43] alpha.3 --- CHANGELOG.md | 1 + lib/src/server/error_converter.dart | 12 ++++++------ pubspec.yaml | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a2b099..748d7801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed - URL Design matching now respects the base URL +- Allo null to be returned by error interceptors ## [8.0.0] - 2024-07-01 ### Added diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index 7b3e91e3..45af2add 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -12,12 +12,12 @@ import 'package:json_api/src/server/response.dart'; /// 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, + 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) { diff --git a/pubspec.yaml b/pubspec.yaml index 695d8532..a15a4642 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.2 +version: 9.0.0-alpha.3 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: From 13a14dfe74dde08d32395845bb7a5033aee5a002 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 18 Aug 2024 13:32:26 -0700 Subject: [PATCH 07/43] alpha.4 --- CHANGELOG.md | 3 ++ lib/src/routing/standard_uri_design.dart | 8 +++- pubspec.yaml | 2 +- test/unit/routing/url_test.dart | 59 ++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 748d7801..b6d88198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - URL Design matching now respects the base URL - Allo null to be returned by error interceptors +### Fixed +- StandardUriDesign working incosistently depending on the trailing slash in the path + ## [8.0.0] - 2024-07-01 ### Added - CORS middleware diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart index f26bc736..cc563947 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -5,7 +5,10 @@ import 'package:json_api/routing.dart'; class StandardUriDesign 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); + StandardUriDesign(Uri base) + : base = base.path.endsWith('/') + ? base + : base.replace(path: '${base.path}/'); /// A "path only" version of the recommended URL design, e.g. /// `/books`, `/books/42`, `/books/42/authors` @@ -17,7 +20,8 @@ class StandardUriDesign implements UriDesign { (base.host.isNotEmpty && uri.host != base.host) || (base.port != 0 && uri.port != base.port) ? null - : switch (uri.pathSegments.sublist(base.pathSegments.length)) { + : switch (uri.pathSegments + .sublist(base.pathSegments.where((it) => it.isNotEmpty).length)) { [var type] => Target(type), [var type, var id] => ResourceTarget(type, id), [var type, var id, var rel] => RelatedTarget(type, id, rel), diff --git a/pubspec.yaml b/pubspec.yaml index a15a4642..7bf0add3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.3 +version: 9.0.0-alpha.4 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: diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index b534f98b..4a8c0260 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -4,6 +4,7 @@ import 'package:test/test.dart'; void main() { test('uri generation', () { final d = StandardUriDesign.pathOnly; + expect(d.base.path, equals('/')); expect(d.collection('books').toString(), '/books'); expect(d.resource('books', '42').toString(), '/books/42'); expect(d.related('books', '42', 'author').toString(), '/books/42/author'); @@ -11,6 +12,30 @@ void main() { '/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.collection('books').toString(), 'https://example.com/api/books'); + expect(d.resource('books', '42').toString(), + 'https://example.com/api/books/42'); + expect(d.related('books', '42', 'author').toString(), + 'https://example.com/api/books/42/author'); + expect(d.relationship('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.collection('books').toString(), 'https://example.com/api/books'); + expect(d.resource('books', '42').toString(), + 'https://example.com/api/books/42'); + expect(d.related('books', '42', 'author').toString(), + 'https://example.com/api/books/42/author'); + expect(d.relationship('books', '42', 'author').toString(), + 'https://example.com/api/books/42/relationships/author'); + }); + test('Authority is retained if exists in base', () { final d = StandardUriDesign(Uri.parse('https://example.com:8080')); expect(d.collection('books').toString(), 'https://example.com:8080/books'); @@ -110,5 +135,39 @@ void main() { expect(d.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books'))); + expect( + d.matchTarget(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.matchTarget( + 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.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); + expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); + expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), + isNull); + }); }); } From e16deccff2bd52a30c64492117e40a15fb4eb8f6 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 18 Aug 2024 21:35:33 -0700 Subject: [PATCH 08/43] alpha.5 --- CHANGELOG.md | 3 +++ lib/src/client/payload_codec.dart | 5 ++++- lib/src/document/document_encoder.dart | 7 ++++++ lib/src/server/controller.dart | 31 ++++++++++---------------- lib/src/server/response.dart | 18 ++++++++------- pubspec.yaml | 4 ++-- test/unit/server/response_test.dart | 18 +++++++++++++++ 7 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 lib/src/document/document_encoder.dart create mode 100644 test/unit/server/response_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d88198..198aab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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 + ### Changed - URL Design matching now respects the base URL - Allo null to be returned by error interceptors diff --git a/lib/src/client/payload_codec.dart b/lib/src/client/payload_codec.dart index e3ed74be..7004a642 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/client/payload_codec.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:json_api/src/document/document_encoder.dart'; + /// Encodes/decodes JSON payload. /// /// The methods are designed to be asynchronous to allow for conversion to be @@ -16,5 +18,6 @@ class PayloadCodec { } /// Encodes a JSON:API document into a JSON string. - FutureOr encode(Object document) => jsonEncode(document); + FutureOr encode(Object document) => + jsonEncode(document, toEncodable: documentEncoder); } diff --git a/lib/src/document/document_encoder.dart b/lib/src/document/document_encoder.dart new file mode 100644 index 00000000..c61bc9fe --- /dev/null +++ b/lib/src/document/document_encoder.dart @@ -0,0 +1,7 @@ +import 'package:json_api/document.dart'; + +Object? documentEncoder(Object? v) => switch (v) { + OutboundDocument() => v.toJson(), + DateTime() => v.toIso8601String(), + _ => throw UnsupportedError('Cannot convert to JSON: $v'), + }; diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 6936003d..b3d97d60 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,43 +1,36 @@ -import 'package:http_interop/http_interop.dart' as http; +import 'package:http_interop/http_interop.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller abstract class Controller { /// Fetch a primary resource collection - Future fetchCollection(http.Request request, Target target); + Future fetchCollection(Request request, Target target); /// Create resource - Future createResource(http.Request request, Target target); + Future createResource(Request request, Target target); /// 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); /// 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); /// Updates the relationship - Future replaceRelationship( - http.Request request, RelationshipTarget target); + Future replaceRelationship( + Request request, RelationshipTarget target); /// Deletes the members from the to-many relationship - Future deleteMany( - http.Request request, RelationshipTarget target); + Future deleteMany(Request request, RelationshipTarget target); /// Fetches related resource or collection - Future fetchRelated( - http.Request request, RelatedTarget target); + Future fetchRelated(Request request, RelatedTarget target); } diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 226335ed..5dadd158 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,17 +1,19 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/document/document_encoder.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 response(int statusCode, {OutboundDocument? document}) => Response( + statusCode, + document != null + ? Body.json(document, toEncodable: documentEncoder) + : Body(), + Headers()) + ..headers.addAll({ + if (document != null) 'Content-Type': [mediaType] + }); Response ok(OutboundDocument document) => response(StatusCode.ok, document: document); diff --git a/pubspec.yaml b/pubspec.yaml index 7bf0add3..be428d4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.4 +version: 9.0.0-alpha.5 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: @@ -7,7 +7,7 @@ environment: dependencies: http_parser: ^4.0.0 - http_interop: ^2.0.0 + http_interop: ^2.1.0 http_interop_middleware: ^0.1.0 dev_dependencies: diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart new file mode 100644 index 00000000..80729efb --- /dev/null +++ b/test/unit/server/response_test.dart @@ -0,0 +1,18 @@ +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'} + })); + }); + }); +} From f267771d2837d8108ad7a7f3bf926a7f00668aac Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 18 Aug 2024 22:11:25 -0700 Subject: [PATCH 09/43] alpha.5 --- lib/document.dart | 1 + lib/src/client/payload_codec.dart | 4 ++-- lib/src/document/document_encoder.dart | 7 ------- lib/src/document/error_object.dart | 5 +++-- lib/src/document/error_source.dart | 5 ++++- lib/src/document/json_encodable.dart | 4 ++++ lib/src/document/link.dart | 5 ++++- lib/src/document/new_identifier.dart | 5 ++++- lib/src/document/new_relationship.dart | 6 +++++- lib/src/document/new_resource.dart | 4 +++- lib/src/document/outbound_document.dart | 5 +++-- lib/src/document/relationship.dart | 6 ++++-- lib/src/document/resource.dart | 4 +++- lib/src/document/to_json_encodable.dart | 9 +++++++++ lib/src/document/to_many.dart | 4 ++-- lib/src/document/to_one.dart | 2 +- lib/src/server/response.dart | 4 ++-- 17 files changed, 54 insertions(+), 26 deletions(-) delete mode 100644 lib/src/document/document_encoder.dart create mode 100644 lib/src/document/json_encodable.dart create mode 100644 lib/src/document/to_json_encodable.dart diff --git a/lib/document.dart b/lib/document.dart index 04efafb7..c8ab18f0 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -3,6 +3,7 @@ library document; 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'; diff --git a/lib/src/client/payload_codec.dart b/lib/src/client/payload_codec.dart index 7004a642..0d9d5172 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/client/payload_codec.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'package:json_api/src/document/document_encoder.dart'; +import 'package:json_api/src/document/to_json_encodable.dart'; /// Encodes/decodes JSON payload. /// @@ -19,5 +19,5 @@ class PayloadCodec { /// Encodes a JSON:API document into a JSON string. FutureOr encode(Object document) => - jsonEncode(document, toEncodable: documentEncoder); + jsonEncode(document, toEncodable: toJsonEncodable); } diff --git a/lib/src/document/document_encoder.dart b/lib/src/document/document_encoder.dart deleted file mode 100644 index c61bc9fe..00000000 --- a/lib/src/document/document_encoder.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:json_api/document.dart'; - -Object? documentEncoder(Object? v) => switch (v) { - OutboundDocument() => v.toJson(), - DateTime() => v.toIso8601String(), - _ => throw UnsupportedError('Cannot convert to JSON: $v'), - }; diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 07ef02c9..3202ea9b 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,10 +1,10 @@ +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 implements JsonEncodable { /// 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 @@ -43,6 +43,7 @@ class ErrorObject { /// Meta data. final meta = {}; + @override Map toJson() => { if (id.isNotEmpty) 'id': id, if (status.isNotEmpty) 'status': status, diff --git a/lib/src/document/error_source.dart b/lib/src/document/error_source.dart index ae8c6cee..66b6c36b 100644 --- a/lib/src/document/error_source.dart +++ b/lib/src/document/error_source.dart @@ -1,5 +1,7 @@ +import 'package:json_api/src/document/json_encodable.dart'; + /// An object containing references to the source of the error. -class ErrorSource { +class ErrorSource implements JsonEncodable { const ErrorSource({this.pointer = '', this.parameter = ''}); /// A JSON Pointer [RFC6901] to the associated entity in the request document. @@ -12,6 +14,7 @@ class ErrorSource { bool get isNotEmpty => !isEmpty; + @override Map toJson() => { if (parameter.isNotEmpty) 'parameter': parameter, if (pointer.isNotEmpty) 'pointer': pointer 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..b2bcebe3 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,6 +1,8 @@ +import 'package:json_api/src/document/json_encodable.dart'; + /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link { +class Link implements JsonEncodable { Link(this.uri); /// Link URL @@ -12,6 +14,7 @@ class Link { @override String toString() => uri.toString(); + @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..82eeea94 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,6 +14,7 @@ sealed class NewIdentifier { /// Identifier meta-data. Map get meta; + @override Map toJson(); } diff --git a/lib/src/document/new_relationship.dart b/lib/src/document/new_relationship.dart index 65de471f..6e107978 100644 --- a/lib/src/document/new_relationship.dart +++ b/lib/src/document/new_relationship.dart @@ -1,12 +1,16 @@ 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, diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index 3cf4be73..51f6ddf7 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -1,3 +1,4 @@ +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'; @@ -8,7 +9,7 @@ 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 { +class NewResource implements JsonEncodable { NewResource(this.type, {this.id, this.lid}); /// Resource type @@ -33,6 +34,7 @@ class NewResource { /// See https://jsonapi.org/format/#document-resource-object-relationships final relationships = {}; + @override Map toJson() => { 'type': type, if (id != null) 'id': id!, diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index 78192f62..332f9e73 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -1,4 +1,5 @@ import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api/src/document/json_encodable.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'; @@ -6,11 +7,11 @@ import 'package:json_api/src/document/to_many.dart'; import 'package:json_api/src/document/to_one.dart'; /// A sever-to-client document. -class OutboundDocument { +class OutboundDocument implements JsonEncodable { /// The document "meta" object. final meta = {}; - /// Returns the JSON representation. + @override Map toJson() => {'meta': meta}; } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 3526ffc0..d5a7dcb3 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,13 +1,15 @@ 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() => { + @override + Map toJson() => { if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index b9ca5f71..fecd2cb5 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,10 +1,11 @@ +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 { +class Resource implements JsonEncodable { Resource(this.type, this.id); /// Resource type. @@ -45,6 +46,7 @@ class Resource { return null; } + @override Map toJson() => { 'type': type, 'id': id, diff --git a/lib/src/document/to_json_encodable.dart b/lib/src/document/to_json_encodable.dart new file mode 100644 index 00000000..e275bbe6 --- /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..6bb5d52a 100644 --- a/lib/src/document/to_many.dart +++ b/lib/src/document/to_many.dart @@ -9,9 +9,9 @@ class ToMany extends Relationship { final _ids = []; @override - Map toJson() => { + Map toJson() => { 'data': [..._ids], - ...super.toJson() + ...super.toJson(), }; @override diff --git a/lib/src/document/to_one.dart b/lib/src/document/to_one.dart index ae61979f..d5a402be 100644 --- a/lib/src/document/to_one.dart +++ b/lib/src/document/to_one.dart @@ -7,7 +7,7 @@ class ToOne extends Relationship { ToOne.empty() : this(null); @override - Map toJson() => {'data': identifier, ...super.toJson()}; + Map toJson() => {'data': identifier, ...super.toJson()}; final Identifier? identifier; diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 5dadd158..e4fbccb5 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,14 +1,14 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/document/document_encoder.dart'; +import 'package:json_api/src/document/to_json_encodable.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response Response response(int statusCode, {OutboundDocument? document}) => Response( statusCode, document != null - ? Body.json(document, toEncodable: documentEncoder) + ? Body.json(document, toEncodable: toJsonEncodable) : Body(), Headers()) ..headers.addAll({ From 47929a539601f9ad5db4b3368b712228b7876936 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 18 Aug 2024 22:16:22 -0700 Subject: [PATCH 10/43] alpha.5 --- lib/document.dart | 1 + test/unit/document/to_json_encodable_test.dart | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 test/unit/document/to_json_encodable_test.dart diff --git a/lib/document.dart b/lib/document.dart index c8ab18f0..ab17adaa 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -13,5 +13,6 @@ export 'package:json_api/src/document/new_to_one.dart'; export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; +export 'package:json_api/src/document/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/test/unit/document/to_json_encodable_test.dart b/test/unit/document/to_json_encodable_test.dart new file mode 100644 index 00000000..967c58af --- /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); + }); + }); +} \ No newline at end of file From d88151b1869718b882f00fc4ee70fa99f08bad67 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 18 Aug 2024 22:17:16 -0700 Subject: [PATCH 11/43] alpha.5 --- lib/src/server/response.dart | 1 - test/unit/document/to_json_encodable_test.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index e4fbccb5..c878de7c 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,7 +1,6 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/document/to_json_encodable.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response diff --git a/test/unit/document/to_json_encodable_test.dart b/test/unit/document/to_json_encodable_test.dart index 967c58af..6acf024f 100644 --- a/test/unit/document/to_json_encodable_test.dart +++ b/test/unit/document/to_json_encodable_test.dart @@ -7,4 +7,4 @@ void main() { expect(() => toJsonEncodable('wow'), throwsUnsupportedError); }); }); -} \ No newline at end of file +} From 468d9dccd28b2f57c7ab0c4497c2b219092414b8 Mon Sep 17 00:00:00 2001 From: Alexey Date: Thu, 29 Aug 2024 20:21:56 -0700 Subject: [PATCH 12/43] Add rawResponse (#144) --- .../client/response/collection_fetched.dart | 17 +- .../response/related_resource_fetched.dart | 21 +- .../client/response/relationship_fetched.dart | 33 ++- .../client/response/relationship_updated.dart | 30 +- lib/src/client/response/request_failure.dart | 25 +- lib/src/client/response/resource_created.dart | 26 +- lib/src/client/response/resource_fetched.dart | 27 +- lib/src/client/response/resource_updated.dart | 28 +- lib/src/client/routing_client.dart | 258 ++++++++---------- test/contract/crud_test.dart | 4 +- test/contract/resource_creation_test.dart | 14 +- test/e2e/e2e_test_set.dart | 2 +- test/unit/client/client_test.dart | 14 +- test/unit/client/typed_responses_test.dart | 45 +++ 14 files changed, 317 insertions(+), 227 deletions(-) create mode 100644 test/unit/client/typed_responses_test.dart diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart index d643258b..f77b7093 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -1,16 +1,25 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, Map json) { - final document = InboundDocument(json); + CollectionFetched(this.rawResponse) { + final document = InboundDocument(rawResponse.document ?? + (throw FormatException('The document must not be empty'))); collection.addAll(document.dataAsCollection()); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - final Response httpResponse; + // 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 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 39a71a73..ab3cd609 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -1,22 +1,31 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, Map json) - : resource = InboundDocument(json).dataAsResourceOrNull() { - final document = InboundDocument(json); + RelatedResourceFetched(this.rawResponse) { + final document = InboundDocument(rawResponse.document ?? + (throw FormatException('The document must not be empty'))); + resource = document.dataAsResourceOrNull(); included.addAll(document.included()); meta.addAll(document.meta()); links.addAll(document.links()); } - final Response httpResponse; + // 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; /// Related resource. May be null - final Resource? resource; + late final Resource? resource; /// Included resources final included = []; diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index a99c78eb..889070f6 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -1,19 +1,34 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, this.relationship); + RelationshipFetched(this.rawResponse, this.relationship); - static RelationshipFetched many(Response httpResponse, Map json) => - RelationshipFetched(httpResponse, InboundDocument(json).asToMany()) - ..included.addAll(InboundDocument(json).included()); + 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()); + } - static RelationshipFetched one(Response httpResponse, Map json) => - RelationshipFetched(httpResponse, InboundDocument(json).asToOne()) - ..included.addAll(InboundDocument(json).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()); + } + + // 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 Response httpResponse; final R relationship; final included = []; } diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index 2adb7421..9a885d8c 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -1,19 +1,31 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, this.relationship); + RelationshipUpdated(this.rawResponse, this.relationship); - static RelationshipUpdated many(Response httpResponse, Map? json) => - RelationshipUpdated( - httpResponse, json == null ? null : InboundDocument(json).asToMany()); + static RelationshipUpdated many(Response response) { + final json = response.document; + return RelationshipUpdated( + response, json == null ? null : InboundDocument(json).asToMany()); + } - static RelationshipUpdated one(Response httpResponse, Map? json) => - RelationshipUpdated( - httpResponse, json == null ? null : InboundDocument(json).asToOne()); + static RelationshipUpdated one(Response response) { + final json = response.document; + return RelationshipUpdated( + response, json == null ? null : InboundDocument(json).asToOne()); + } - final Response httpResponse; + // 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 096a84ca..1f69695d 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -1,16 +1,25 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, Map? document) { - if (document != null) { - errors.addAll(InboundDocument(document).errors()); - meta.addAll(InboundDocument(document).meta()); - } + RequestFailure(this.rawResponse) { + final json = rawResponse.document; + if (json == null) return; + final document = InboundDocument(json); + errors.addAll(document.errors()); + meta.addAll(document.meta()); } - final Response httpResponse; + // 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 = []; @@ -20,5 +29,5 @@ class RequestFailure implements Exception { @override String toString() => - 'JSON:API request failed with HTTP status ${httpResponse.statusCode}.'; + 'JSON:API request failed with HTTP status ${rawResponse.httpResponse.statusCode}.'; } diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart index 3b157810..d67a8ec3 100644 --- a/lib/src/client/response/resource_created.dart +++ b/lib/src/client/response/resource_created.dart @@ -1,22 +1,32 @@ -import 'package:http_interop/http_interop.dart'; +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 new resource creation request. /// This is always a "201 Created" response. /// /// https://jsonapi.org/format/#crud-creating-responses-201 class ResourceCreated { - ResourceCreated(this.httpResponse, Map json) - : resource = InboundDocument(json).dataAsResource() { - meta.addAll(InboundDocument(json).meta()); - links.addAll(InboundDocument(json).links()); - included.addAll(InboundDocument(json).included()); + ResourceCreated(this.rawResponse) { + 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()); } - final Response httpResponse; + // 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. - final Resource resource; + late final Resource resource; /// Top-level meta data final meta = {}; diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart index 3e628298..2b694466 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.dart @@ -1,17 +1,28 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, Map json) - : resource = InboundDocument(json).dataAsResource() { - included.addAll(InboundDocument(json).included()); - meta.addAll(InboundDocument(json).meta()); - links.addAll(InboundDocument(json).links()); + ResourceFetched(this.rawResponse) { + 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()); } - final Response httpResponse; - final Resource resource; + // 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 final meta = {}; diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart index ca9b9198..381ebe90 100644 --- a/lib/src/client/response/resource_updated.dart +++ b/lib/src/client/response/resource_updated.dart @@ -1,26 +1,34 @@ -import 'package:http_interop/http_interop.dart'; +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.httpResponse, Map? json) : resource = _resource(json) { - if (json != null) { - included.addAll(InboundDocument(json).included()); - meta.addAll(InboundDocument(json).meta()); - links.addAll(InboundDocument(json).links()); + ResourceUpdated(this.rawResponse) + : resource = _resource(rawResponse.document) { + final document = rawResponse.document; + if (document != null) { + included.addAll(InboundDocument(document).included()); + meta.addAll(InboundDocument(document).meta()); + links.addAll(InboundDocument(document).links()); } } static Resource? _resource(Map? json) { if (json != null) { final doc = InboundDocument(json); - if (doc.hasData) { - return doc.dataAsResource(); - } + if (doc.hasData) return doc.dataAsResource(); } return null; } - final Response httpResponse; + // 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 9a250d07..7a11e244 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -32,14 +32,12 @@ class RoutingClient { List identifiers, { Map meta = const {}, Map> headers = const {}, - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.post( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers)); - return RelationshipUpdated.many(response.httpResponse, response.document); - } + }) async => + RelationshipUpdated.many(await send( + _baseUri.relationship(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. /// @@ -61,21 +59,19 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.collection(type), - Request.post(OutboundDataDocument.resource(Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, ToOne(value))), - ...many.map((key, value) => MapEntry(key, ToMany(value))), - }) - ..meta.addAll(meta)) - ..meta.addAll(documentMeta)) - ..headers.addAll(headers) - ..query.mergeAll(query)); - return ResourceUpdated(response.httpResponse, response.document); - } + }) 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))); /// Creates a new resource in the collection of type [type]. /// The server is responsible for assigning the resource id. @@ -99,24 +95,20 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.collection(type), - Request.post( - OutboundDataDocument.newResource(NewResource(type, lid: lid) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, NewToOne(value))), - ...many.map((key, value) => MapEntry(key, NewToMany(value))), - }) - ..meta.addAll(meta)) - ..meta.addAll(documentMeta)) - ..headers.addAll(headers) - ..query.mergeAll(query)); - - return ResourceCreated( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) 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))); /// Deletes the [identifiers] from the to-many relationship /// identified by [type], [id], [relationship]. @@ -131,15 +123,12 @@ class RoutingClient { List identifiers, { Map meta = const {}, Map> headers = const {}, - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.delete( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers)); - - return RelationshipUpdated.many(response.httpResponse, response.document); - } + }) async => + RelationshipUpdated.many(await send( + _baseUri.relationship(type, id, relationship), + Request.delete( + OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers))); /// Fetches the primary collection of type [type]. /// @@ -150,15 +139,12 @@ class RoutingClient { String type, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.collection(type), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - return CollectionFetched( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + CollectionFetched(await send( + _baseUri.collection(type), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Fetches the related resource collection /// identified by [type], [id], [relationship]. @@ -172,15 +158,12 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.related(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - return CollectionFetched( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + CollectionFetched(await send( + _baseUri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Fetches the to-one relationship /// identified by [type], [id], [relationship]. @@ -194,15 +177,12 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelationshipFetched.one( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + RelationshipFetched.one(await send( + _baseUri.relationship(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Fetches the to-many relationship /// identified by [type], [id], [relationship]. @@ -216,15 +196,12 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelationshipFetched.many( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + RelationshipFetched.many(await send( + _baseUri.relationship(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Fetches the related resource /// identified by [type], [id], [relationship]. @@ -238,15 +215,12 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.related(type, id, relationship), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelatedResourceFetched( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + RelatedResourceFetched(await send( + _baseUri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Fetches the resource identified by [type] and [id]. /// @@ -258,16 +232,12 @@ class RoutingClient { String id, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.resource(type, id), - Request.get() - ..headers.addAll(headers) - ..query.mergeAll(query)); - - return ResourceFetched( - response.httpResponse, response.document ?? (throw FormatException())); - } + }) async => + ResourceFetched(await send( + _baseUri.resource(type, id), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Updates the resource identified by [type] and [id]. /// @@ -289,21 +259,19 @@ class RoutingClient { Map documentMeta = const {}, Map> headers = const {}, Iterable query = const [], - }) async { - final response = 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)); - return ResourceUpdated(response.httpResponse, response.document); - } + }) 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))); /// Replaces the to-one relationship /// identified by [type], [id], and [relationship] by setting @@ -321,15 +289,13 @@ class RoutingClient { Map meta = const {}, Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta))) - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelationshipUpdated.one(response.httpResponse, response.document); - } + }) 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))); /// Replaces the to-many relationship /// identified by [type], [id], and [relationship] by setting @@ -347,15 +313,13 @@ class RoutingClient { Map meta = const {}, Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelationshipUpdated.many(response.httpResponse, response.document); - } + }) 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))); /// Removes the to-one relationship /// identified by [type], [id], and [relationship].. @@ -369,14 +333,12 @@ class RoutingClient { String relationship, { Map> headers = const {}, Iterable query = const [], - }) async { - final response = await send( - _baseUri.relationship(type, id, relationship), - Request.patch(OutboundDataDocument.one(ToOne.empty())) - ..headers.addAll(headers) - ..query.mergeAll(query)); - return RelationshipUpdated.one(response.httpResponse, response.document); - } + }) async => + RelationshipUpdated.one(await send( + _baseUri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.one(ToOne.empty())) + ..headers.addAll(headers) + ..query.mergeAll(query))); /// Deletes the resource identified by [type] and [id]. /// @@ -399,9 +361,7 @@ class RoutingClient { /// This method can be used to send any non-standard requests. Future send(Uri uri, Request request) async { final response = await _client.send(uri, request); - if (response.isFailed) { - throw RequestFailure(response.httpResponse, response.document); - } + if (response.isFailed) throw RequestFailure(response); return response; } } diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index 41520f55..e2af2547 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -47,7 +47,7 @@ void main() { Include(['author', 'comments', 'comments.author']) ]); - expect(response.httpResponse.statusCode, 200); + expect(response.rawResponse.httpResponse.statusCode, 200); expect(response.collection.length, 1); expect(response.included.length, 3); @@ -175,7 +175,7 @@ void main() { await action(); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 404); + expect(e.rawResponse.httpResponse.statusCode, 404); } } }); diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 06b215b7..c29991a4 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -16,8 +16,9 @@ void main() { test('Resource id assigned on the server', () async { await client .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { - expect(r.httpResponse.statusCode, 201); - expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']); + 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'); @@ -30,8 +31,9 @@ void main() { lid: 'lid', attributes: {'title': 'Hello world'}, one: {'self': LocalIdentifier('posts', 'lid')}).then((r) { - expect(r.httpResponse.statusCode, 201); - expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']); + expect(r.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'); @@ -42,9 +44,9 @@ void main() { test('Resource id assigned on the client', () async { await client.create('posts', '12345', attributes: {'title': 'Hello world'}).then((r) { - expect(r.httpResponse.statusCode, 204); + expect(r.rawResponse.httpResponse.statusCode, 204); expect(r.resource, isNull); - expect(r.httpResponse.headers['location'], isNull); + expect(r.rawResponse.httpResponse.headers['location'], isNull); }); }); }); diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index 4d7a3fd3..6dc67ec4 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -30,7 +30,7 @@ void testLocationIsSet(RoutingClient Function() client) { test('Location is set', () async { final r = await client() .createNew('posts', attributes: {'title': 'Location test'}); - expect(r.httpResponse.headers['Location'], isNotEmpty); + expect(r.rawResponse.httpResponse.headers['Location'], isNotEmpty); await client().deleteResource('posts', r.resource.id); }); } diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index eaf38e06..1ce542a5 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -22,7 +22,7 @@ void main() { await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); expect(e.errors.first.title, 'Invalid Attribute'); } @@ -33,7 +33,7 @@ void main() { await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 500); + expect(e.rawResponse.httpResponse.statusCode, 500); } }); }); @@ -558,7 +558,7 @@ void main() { 'articles', '1', 'author', Identifier('people', '42')); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); @@ -615,7 +615,7 @@ void main() { await client.deleteToOne('articles', '1', 'author'); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); @@ -680,7 +680,7 @@ void main() { .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); @@ -747,7 +747,7 @@ void main() { .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); } }); @@ -814,7 +814,7 @@ void main() { .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { - expect(e.httpResponse.statusCode, 422); + expect(e.rawResponse.httpResponse.statusCode, 422); expect(e.errors.first.status, '422'); expect(e.toString(), contains('422')); } diff --git a/test/unit/client/typed_responses_test.dart b/test/unit/client/typed_responses_test.dart new file mode 100644 index 00000000..85525cd3 --- /dev/null +++ b/test/unit/client/typed_responses_test.dart @@ -0,0 +1,45 @@ +import 'package:http_interop/http_interop.dart' as i; +import 'package:json_api/client.dart'; +import 'package:json_api/src/client/response.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + final emptyResponse = Response(i.Response(200, i.Body(), i.Headers()), null); + group('CollectionFetched', () { + test('throws on empty body', () { + expect(() => CollectionFetched(emptyResponse), throwsFormatException); + }); + }); + + group('RelatedResourceFetched', () { + test('throws on empty body', () { + expect( + () => RelatedResourceFetched(emptyResponse), throwsFormatException); + }); + }); + + group('RelationshipFetched', () { + test('.many() throws on empty body', () { + expect( + () => RelationshipFetched.many(emptyResponse), throwsFormatException); + }); + + test('.one() throws on empty body', () { + expect( + () => RelationshipFetched.one(emptyResponse), throwsFormatException); + }); + }); + + group('ResourceCreated', () { + test('throws on empty body', () { + expect(() => ResourceCreated(emptyResponse), throwsFormatException); + }); + }); + + group('ResourceFetched', () { + test('throws on empty body', () { + expect(() => ResourceFetched(emptyResponse), throwsFormatException); + }); + }); +} From 6560949862dc2c389f060ae80529049e1a83d350 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 29 Aug 2024 20:24:47 -0700 Subject: [PATCH 13/43] Remove deprecations --- lib/src/client/response/collection_fetched.dart | 7 ------- lib/src/client/response/related_resource_fetched.dart | 7 ------- lib/src/client/response/relationship_fetched.dart | 7 ------- lib/src/client/response/relationship_updated.dart | 7 ------- lib/src/client/response/request_failure.dart | 7 ------- lib/src/client/response/resource_created.dart | 7 ------- lib/src/client/response/resource_fetched.dart | 7 ------- lib/src/client/response/resource_updated.dart | 7 ------- 8 files changed, 56 deletions(-) diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart index f77b7093..08f0f60b 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.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'; @@ -12,12 +11,6 @@ class CollectionFetched { 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; diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart index ab3cd609..7b942870 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.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'; @@ -15,12 +14,6 @@ class RelatedResourceFetched { 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; diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index 889070f6..6f408912 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.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'; @@ -20,12 +19,6 @@ class RelationshipFetched { ..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; diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index 9a885d8c..da77fc96 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.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'; @@ -18,12 +17,6 @@ class 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; diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart index 1f69695d..c6e01590 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.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'; @@ -12,12 +11,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; diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart index d67a8ec3..b83eebf3 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'; @@ -16,12 +15,6 @@ class ResourceCreated { 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; diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart index 2b694466..7054ae46 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.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'; @@ -13,12 +12,6 @@ class ResourceFetched { 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; diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart index 381ebe90..e9a1bc82 100644 --- a/lib/src/client/response/resource_updated.dart +++ b/lib/src/client/response/resource_updated.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'; @@ -21,12 +20,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; From 3ab6ed17bf854a423eaf74849a08d558fda81c19 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 28 Sep 2024 19:54:05 -0700 Subject: [PATCH 14/43] update cors --- CHANGELOG.md | 4 ++-- lib/client.dart | 2 +- lib/document.dart | 2 +- lib/http.dart | 2 +- lib/query.dart | 2 +- lib/routing.dart | 2 +- lib/server.dart | 2 +- lib/src/server/cors_middleware.dart | 25 ++++++++++++------------- pubspec.yaml | 2 +- 9 files changed, 21 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63db2587..a3ab397e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - URL Design matching now respects the base URL -- Allo null to be returned by error interceptors +- Allow null to be returned by error interceptors ### Fixed -- StandardUriDesign working incosistently depending on the trailing slash in the path +- StandardUriDesign working inconsistently, depending on the trailing slash in the path ## [8.1.0] - 2024-08-29 ### Added 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 ab17adaa..843207a2 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,5 +1,5 @@ /// 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'; diff --git a/lib/http.dart b/lib/http.dart index 888d57d0..fbb191bd 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,5 +1,5 @@ /// Common HTTP utilities for JSON:API clients and servers. /// WARNING: This library is in beta stage. The API is subject to change. -library http; +library; export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart index 335eb69c..f0311113 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -1,5 +1,5 @@ /// 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/include.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 198266c7..e6e60e1b 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,6 +1,6 @@ /// JSON:API server on top of dart:io. /// WARNING: This library is in beta stage. The API is subject to change. -library server; +library; export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/cors_middleware.dart'; diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart index 3c288aa9..d174de5e 100644 --- a/lib/src/server/cors_middleware.dart +++ b/lib/src/server/cors_middleware.dart @@ -1,23 +1,22 @@ import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; -Handler corsMiddleware(Handler handler) => - (Request request) async => switch (request.method) { +final corsMiddleware = middleware( + onRequest: (rq) async => switch (rq.method) { 'options' => Response( 204, Body(), Headers.from({ 'Access-Control-Allow-Methods': - request.headers['Access-Control-Request-Method'] ?? + rq.headers['Access-Control-Request-Method'] ?? const ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'], 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? - const ['*'], + rq.headers['Access-Control-Request-Headers'] ?? const ['*'], })), - _ => await handler(request) - } - ..headers.addAll({ - 'Access-Control-Allow-Origin': [ - request.headers['origin']?.last ?? '*' - ], - 'Access-Control-Expose-Headers': const ['Location'], - }); + _ => 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/pubspec.yaml b/pubspec.yaml index 8d4e0bb0..524b3094 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.5 +version: 9.0.0-alpha.6 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: From 28eff82d74a6247e9e5bd893fe1f2de7614fa3d1 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 28 Sep 2024 19:54:51 -0700 Subject: [PATCH 15/43] update cors --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 524b3094..2c4ceeee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.6 +version: 9.0.0-alpha.7 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: From da9e03347f058e654a6b3dfe83c33efb02b86a03 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 29 Sep 2024 19:42:15 -0700 Subject: [PATCH 16/43] WIP --- CHANGELOG.md | 1 + lib/src/http/status_code.dart | 1 + lib/src/routing/uri_design.dart | 2 +- lib/src/server/response.dart | 3 +++ pubspec.yaml | 2 +- 5 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ab397e..176957ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added - Automatically encode DateTime objects as an ISO-8601 string +- The "conflict" server response ### Changed - URL Design matching now respects the base URL diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart index e3c9eba1..6f5e8b43 100644 --- a/lib/src/http/status_code.dart +++ b/lib/src/http/status_code.dart @@ -7,6 +7,7 @@ extension type StatusCode(int value) { static const notFound = 404; static const methodNotAllowed = 405; static const notAcceptable = 406; + static const conflict = 406; static const unsupportedMediaType = 415; static const internalServerError = 500; diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart index 4bb1519b..199b410f 100644 --- a/lib/src/routing/uri_design.dart +++ b/lib/src/routing/uri_design.dart @@ -1,4 +1,4 @@ -abstract class UriDesign { +abstract interface class UriDesign { Uri collection(String type); Uri resource(String type, String id); diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index c878de7c..b523e76f 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -26,6 +26,9 @@ Response created(OutboundDocument document, String 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); diff --git a/pubspec.yaml b/pubspec.yaml index 2c4ceeee..0ac3f160 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.7 +version: 9.0.0-alpha.8 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: From 18bc8874359e9092468d61a2b9775d95ca370aae Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 29 Sep 2024 19:45:16 -0700 Subject: [PATCH 17/43] fix --- lib/src/http/status_code.dart | 2 +- test/unit/server/response_test.dart | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart index 6f5e8b43..fc723cd4 100644 --- a/lib/src/http/status_code.dart +++ b/lib/src/http/status_code.dart @@ -7,7 +7,7 @@ extension type StatusCode(int value) { static const notFound = 404; static const methodNotAllowed = 405; static const notAcceptable = 406; - static const conflict = 406; + static const conflict = 409; static const unsupportedMediaType = 415; static const internalServerError = 500; diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart index 80729efb..756c56fb 100644 --- a/test/unit/server/response_test.dart +++ b/test/unit/server/response_test.dart @@ -15,4 +15,9 @@ void main() { })); }); }); + + test('conflict', () async { + final r = conflict(); + expect(r.statusCode, equals(409)); + }); } From 622034f7f18a8bb6e118005a734381c88559d11f Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 29 Sep 2024 19:45:32 -0700 Subject: [PATCH 18/43] fix --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0ac3f160..00afda30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 9.0.0-alpha.8 +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: From 4f44073ea0a7a76240bfaf3d3614c502510e88fd Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 3 Oct 2026 17:55:11 -0700 Subject: [PATCH 19/43] Dart 3.13 --- lib/src/client/client.dart | 23 +- lib/src/client/payload_codec.dart | 4 +- lib/src/client/request.dart | 16 +- lib/src/client/response.dart | 10 +- .../client/response/collection_fetched.dart | 16 +- .../response/related_resource_fetched.dart | 16 +- .../client/response/relationship_fetched.dart | 24 +- .../client/response/relationship_updated.dart | 23 +- lib/src/client/response/request_failure.dart | 10 +- lib/src/client/response/resource_created.dart | 13 +- lib/src/client/response/resource_fetched.dart | 13 +- lib/src/client/response/resource_updated.dart | 8 +- lib/src/client/routing_client.dart | 273 ++++++++++-------- lib/src/document/error_object.dart | 42 ++- lib/src/document/error_source.dart | 16 +- lib/src/document/inbound_document.dart | 85 +++--- lib/src/document/link.dart | 8 +- lib/src/document/new_identifier.dart | 41 ++- lib/src/document/new_relationship.dart | 6 +- lib/src/document/new_resource.dart | 41 +-- lib/src/document/new_to_many.dart | 10 +- lib/src/document/new_to_one.dart | 6 +- lib/src/document/outbound_document.dart | 23 +- lib/src/document/relationship.dart | 6 +- lib/src/document/resource.dart | 24 +- lib/src/document/to_json_encodable.dart | 8 +- lib/src/document/to_many.dart | 10 +- lib/src/document/to_one.dart | 6 +- lib/src/query/fields.dart | 19 +- lib/src/query/include.dart | 13 +- lib/src/query/page.dart | 14 +- lib/src/query/query.dart | 5 +- lib/src/query/sort.dart | 27 +- lib/src/routing/standard_uri_design.dart | 27 +- lib/src/routing/target.dart | 51 +--- lib/src/server/controller.dart | 4 +- lib/src/server/cors_middleware.dart | 38 +-- lib/src/server/error_converter.dart | 86 +++--- .../server/errors/collection_not_found.dart | 8 +- lib/src/server/errors/method_not_allowed.dart | 6 +- .../server/errors/relationship_not_found.dart | 12 +- lib/src/server/errors/resource_not_found.dart | 7 +- lib/src/server/errors/unmatched_target.dart | 6 +- lib/src/server/request_validator.dart | 30 +- lib/src/server/response.dart | 20 +- lib/src/server/router.dart | 48 +-- pubspec.yaml | 4 +- 47 files changed, 602 insertions(+), 604 deletions(-) diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index f3f32cc7..92add045 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -12,12 +12,10 @@ 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 { @@ -26,13 +24,14 @@ class Client { 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 response = await _handler( + i.Request(request.method, url, body, headers), + ); final document = await _decode(response); return Response(response, document); @@ -44,9 +43,9 @@ class Client { Future _decode(i.Response response) async { final json = await response.body.decode(utf8); if (json.isNotEmpty && - response.headers['Content-Type']?.last - .toLowerCase() - .startsWith(mediaType) == + response.headers['Content-Type']?.last.toLowerCase().startsWith( + mediaType, + ) == true) { return await _codec.decode(json); } diff --git a/lib/src/client/payload_codec.dart b/lib/src/client/payload_codec.dart index 0d9d5172..a5b3c9f0 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/client/payload_codec.dart @@ -7,9 +7,7 @@ import 'package:json_api/src/document/to_json_encodable.dart'; /// /// 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) { final decoded = jsonDecode(json); diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 00467270..1f7dca31 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -3,11 +3,16 @@ 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'); @@ -20,13 +25,6 @@ class Request { /// 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; - /// 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 08f0f60b..35991f2f 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -1,18 +1,20 @@ 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()); } - - /// 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 7b942870..ba923ced 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -4,18 +4,20 @@ 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()); } - - /// 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 6f408912..e7561d3c 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -2,26 +2,28 @@ 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'))); + 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'))); + final document = InboundDocument( + response.document ?? + (throw FormatException('The document must not be empty')), + ); return RelationshipFetched(response, document.asToOne()) ..included.addAll(document.included()); } - /// 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 da77fc96..5d6fccfb 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -2,24 +2,25 @@ 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()); + 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()); + response, + json == null ? null : InboundDocument(json).asToOne(), + ); } - - /// 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 c6e01590..e214cc31 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -2,8 +2,11 @@ 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); @@ -11,9 +14,6 @@ class RequestFailure implements Exception { meta.addAll(document.meta()); } - /// 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 b83eebf3..3e5cd4d1 100644 --- a/lib/src/client/response/resource_created.dart +++ b/lib/src/client/response/resource_created.dart @@ -5,19 +5,18 @@ 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()); } - /// 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 7054ae46..cd338434 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.dart @@ -2,19 +2,18 @@ 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()); } - /// 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 e9a1bc82..95da2f63 100644 --- a/lib/src/client/response/resource_updated.dart +++ b/lib/src/client/response/resource_updated.dart @@ -1,9 +1,8 @@ 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()); @@ -20,9 +19,6 @@ class ResourceUpdated { return null; } - /// 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..f9741ace 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 UriDesign _baseUri, final Client _client) { /// Adds the [identifiers] to the to-many relationship /// identified by [type], [id], [relationship]. /// @@ -32,12 +27,14 @@ 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( + _baseUri.relationship(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 +56,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( + _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), + ), + ); /// Creates a new resource in the collection of type [type]. /// The server is responsible for assigning the resource id. @@ -95,20 +97,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( + _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), + ), + ); /// Deletes the [identifiers] from the to-many relationship /// identified by [type], [id], [relationship]. @@ -123,12 +129,14 @@ 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( + _baseUri.relationship(type, id, relationship), + Request.delete( + OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)), + )..headers.addAll(headers), + ), + ); /// Fetches the primary collection of type [type]. /// @@ -139,12 +147,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( + _baseUri.collection(type), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the related resource collection /// identified by [type], [id], [relationship]. @@ -158,12 +168,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( + _baseUri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the to-one relationship /// identified by [type], [id], [relationship]. @@ -177,12 +189,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( + _baseUri.relationship(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the to-many relationship /// identified by [type], [id], [relationship]. @@ -196,12 +210,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( + _baseUri.relationship(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the related resource /// identified by [type], [id], [relationship]. @@ -215,12 +231,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( + _baseUri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Fetches the resource identified by [type] and [id]. /// @@ -232,12 +250,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( + _baseUri.resource(type, id), + Request.get() + ..headers.addAll(headers) + ..query.mergeAll(query), + ), + ); /// Updates the resource identified by [type] and [id]. /// @@ -259,19 +279,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( + _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), + ), + ); /// Replaces the to-one relationship /// identified by [type], [id], and [relationship] by setting @@ -289,13 +314,16 @@ 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( + _baseUri.relationship(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 +341,16 @@ 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( + _baseUri.relationship(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 +364,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( + _baseUri.relationship(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 +383,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( + _baseUri.resource(type, id), + Request.delete() + ..headers.addAll(headers) + ..query.mergeAll(query), + ); /// Sends the [request] to the [uri] on the server. /// This method can be used to send any non-standard requests. diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 3202ea9b..254aa178 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -4,39 +4,31 @@ import 'package:json_api/src/document/error_source.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject implements JsonEncodable { +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 = {}; @@ -45,13 +37,13 @@ class ErrorObject implements JsonEncodable { @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 66b6c36b..2eb1b8e0 100644 --- a/lib/src/document/error_source.dart +++ b/lib/src/document/error_source.dart @@ -1,22 +1,20 @@ import 'package:json_api/src/document/json_encodable.dart'; /// An object containing references to the source of the error. -class ErrorSource implements JsonEncodable { - const ErrorSource({this.pointer = '', this.parameter = ''}); - +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..eb4b34bf 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -13,13 +13,11 @@ 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'); @@ -83,27 +81,27 @@ 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'), - ) + 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'), - ) + 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)); @@ -129,29 +127,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: () => {}); @@ -165,18 +166,18 @@ class _Parser { .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') - }; + 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') - }; + 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/link.dart b/lib/src/document/link.dart index b2bcebe3..a3ff917f 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -2,12 +2,10 @@ import 'package:json_api/src/document/json_encodable.dart'; /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link implements JsonEncodable { - Link(this.uri); - +class Link( /// Link URL - final Uri uri; - + final Uri uri, +) implements JsonEncodable { /// Link meta data final meta = {}; diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart index 82eeea94..8f8466e7 100644 --- a/lib/src/document/new_identifier.dart +++ b/lib/src/document/new_identifier.dart @@ -19,17 +19,13 @@ sealed class NewIdentifier implements JsonEncodable { } /// 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; @@ -38,35 +34,34 @@ 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, - }; + '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 6e107978..30ac547d 100644 --- a/lib/src/document/new_relationship.dart +++ b/lib/src/document/new_relationship.dart @@ -12,9 +12,9 @@ class NewRelationship @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 51f6ddf7..cbebaa20 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -9,18 +9,16 @@ 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 implements JsonEncodable { - 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 = {}; @@ -36,13 +34,13 @@ class NewResource implements JsonEncodable { @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, + if (id != null) 'id': id!, + if (lid != null) 'lid': lid!, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; /// Converts this to a real [Resource] object, assigning the id if necessary. Resource toResource(String Function() getId) { @@ -53,8 +51,9 @@ class NewResource implements JsonEncodable { 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) { @@ -74,9 +73,11 @@ class NewResource implements JsonEncodable { 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".') + 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..2aa13a88 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); - +class NewToOne(final NewIdentifier? identifier) extends NewRelationship { NewToOne.empty() : this(null); @override Map toJson() => {'data': identifier, ...super.toJson()}; - final NewIdentifier? identifier; - @override Iterator get iterator => identifier == null ? super.iterator : [identifier!].iterator; diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index 332f9e73..36037a33 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -16,8 +16,9 @@ class OutboundDocument implements JsonEncodable { } /// 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); } @@ -26,9 +27,9 @@ class OutboundErrorDocument extends OutboundDocument { @override Map toJson() => { - 'errors': errors, - if (meta.isNotEmpty) 'meta': meta, - }; + 'errors': errors, + if (meta.isNotEmpty) 'meta': meta, + }; } /// A sever-to-client document with data. @@ -41,7 +42,7 @@ class OutboundDataDocument extends OutboundDocument { /// Creates an instance of a document containing a collection of resources as the primary data. OutboundDataDocument.collection(Iterable collection) - : data = collection.toList(); + : data = collection.toList(); /// Creates an instance of a document containing a to-one relationship. OutboundDataDocument.one(ToOne one) : data = one.identifier { @@ -66,9 +67,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/document/relationship.dart b/lib/src/document/relationship.dart index d5a7dcb3..199cd332 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -10,9 +10,9 @@ class Relationship with IterableMixin implements JsonEncodable { @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/resource.dart b/lib/src/document/resource.dart index fecd2cb5..fe7cbfb8 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -5,15 +5,13 @@ 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 implements JsonEncodable { - 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 = {}; @@ -48,11 +46,11 @@ class Resource implements JsonEncodable { @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 index e275bbe6..1013149e 100644 --- a/lib/src/document/to_json_encodable.dart +++ b/lib/src/document/to_json_encodable.dart @@ -3,7 +3,7 @@ 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'), - }; + 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 6bb5d52a..b6b4abd4 100644 --- a/lib/src/document/to_many.dart +++ b/lib/src/document/to_many.dart @@ -1,8 +1,8 @@ import 'package:json_api/src/document/new_identifier.dart'; import 'package:json_api/src/document/relationship.dart'; -class ToMany extends Relationship { - ToMany(Iterable identifiers) { +class ToMany(Iterable identifiers) extends Relationship { + this { _ids.addAll(identifiers); } @@ -10,9 +10,9 @@ class ToMany extends Relationship { @override Map toJson() => { - 'data': [..._ids], - ...super.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 d5a402be..60fbec38 100644 --- a/lib/src/document/to_one.dart +++ b/lib/src/document/to_one.dart @@ -1,16 +1,12 @@ import 'package:json_api/src/document/new_identifier.dart'; import 'package:json_api/src/document/relationship.dart'; -class ToOne extends Relationship { - ToOne(this.identifier); - +class ToOne(final Identifier? identifier) extends Relationship { 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/query/fields.dart b/lib/src/query/fields.dart index a09bab7c..4bb2ff83 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -4,23 +4,28 @@ 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 { +class Fields([Map> fields = const {}]) + 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 {}]) { + 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\[(.+)\]$'); diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index 838cfc35..e7664f2e 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -4,25 +4,28 @@ 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 { +class Include([Iterable resources = const []]) + with IterableMixin + implements QueryEncodable { /// Example: /// ```dart /// Include(['comments', 'comments.author']); /// ``` - Include([Iterable resources = const []]) { + this { _.addAll(resources); } static Include fromUri(Uri uri) => Include( - uri.queryParametersAll['include']?.expand((it) => it.split(',')) ?? []); + 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..857fed85 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -4,7 +4,9 @@ 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 { +class Page([Map parameters = const {}]) + with MapMixin + implements QueryEncodable { /// Example: /// ```dart /// Page({'limit': '10', 'offset': '20'}).addTo(url); @@ -14,13 +16,15 @@ class Page with MapMixin implements QueryEncodable { /// ?page[limit]=10&page[offset]=20 /// ``` /// - Page([Map parameters = const {}]) { + 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 _ = {}; diff --git a/lib/src/query/query.dart b/lib/src/query/query.dart index f4356e4b..bae53305 100644 --- a/lib/src/query/query.dart +++ b/lib/src/query/query.dart @@ -1,8 +1,9 @@ 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); } diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 47fbad66..393fb751 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -4,27 +4,30 @@ 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(',')) ?? [])); + (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; @@ -44,12 +47,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 +55,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 cc563947..5f454be1 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -2,32 +2,37 @@ 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. - StandardUriDesign(Uri base) - : base = base.path.endsWith('/') - ? base - : base.replace(path: '${base.path}/'); + 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: '/')); /// Matches a [uri] to a [Target] object. - Target? matchTarget(Uri uri) => !uri.path.startsWith(base.path) || + Target? matchTarget(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)) { + : switch (uri.pathSegments.sublist( + base.pathSegments.where((it) => it.isNotEmpty).length, + )) { [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 + [var type, var id, 'relationships', var rel] => RelationshipTarget( + type, + id, + rel, + ), + _ => null, }; final Uri base; diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 78a59c25..4f12eec4 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; -} - -class RelatedTarget implements ResourceTarget { - const RelatedTarget(this.type, this.id, this.relationship); - - @override - final String type; - @override - final String id; - - final String relationship; -} - -class RelationshipTarget implements ResourceTarget { - const RelationshipTarget(this.type, this.id, this.relationship); - - @override - final String type; - @override - final String id; - - final String relationship; -} +class const Target(final String type); + +class const ResourceTarget(@override final String type, final String id) + implements Target; + +class const RelatedTarget( + @override final String type, + @override final String id, + final String relationship, +) implements ResourceTarget; + +class const RelationshipTarget( + @override final String type, + @override final String id, + final String relationship, +) implements ResourceTarget; diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index b3d97d60..ce2b0c2b 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -26,7 +26,9 @@ abstract class Controller { /// Updates the relationship Future replaceRelationship( - Request request, RelationshipTarget target); + Request request, + RelationshipTarget target, + ); /// Deletes the members from the to-many relationship Future deleteMany(Request request, RelationshipTarget target); diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart index d174de5e..a625d551 100644 --- a/lib/src/server/cors_middleware.dart +++ b/lib/src/server/cors_middleware.dart @@ -2,21 +2,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) { - 'options' => Response( - 204, - Body(), - 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'], - })); + onRequest: (rq) async => switch (rq.method) { + 'options' => Response( + 204, + Body(), + 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 45af2add..c7004196 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -18,40 +18,52 @@ Middleware errorConverter({ 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([ - ErrorObject( - title: 'Relationship Not Found', - detail: 'Type: ${error.type}' - ', id: ${error.id}' - ', relationship: ${error.relationship}', - ) - ])), - UnsupportedMediaType() => unsupportedMediaType(), - NotAcceptable() => notAcceptable(), - _ => await onError?.call(error, trace) ?? - internalServerError(OutboundErrorDocument( - [ErrorObject(title: 'Internal Server Error')])) - }); +}) => 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([ + ErrorObject( + title: 'Relationship Not Found', + detail: + 'Type: ${error.type}' + ', id: ${error.id}' + ', relationship: ${error.relationship}', + ), + ]), + ), + UnsupportedMediaType() => unsupportedMediaType(), + NotAcceptable() => notAcceptable(), + _ => + await onError?.call(error, trace) ?? + internalServerError( + OutboundErrorDocument([ + ErrorObject(title: 'Internal Server Error'), + ]), + ), + }, +); diff --git a/lib/src/server/errors/collection_not_found.dart b/lib/src/server/errors/collection_not_found.dart 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/relationship_not_found.dart b/lib/src/server/errors/relationship_not_found.dart index 9a2ae1d2..3a4a37c9 100644 --- a/lib/src/server/errors/relationship_not_found.dart +++ b/lib/src/server/errors/relationship_not_found.dart @@ -1,8 +1,6 @@ /// 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/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/request_validator.dart b/lib/src/server/request_validator.dart index 3f2b9a8e..76f1535a 100644 --- a/lib/src/server/request_validator.dart +++ b/lib/src/server/request_validator.dart @@ -5,20 +5,22 @@ 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 requestValidator = 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; -}); +final requestValidator = 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 && diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index b523e76f..e7a6a82a 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -4,15 +4,17 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response -Response response(int statusCode, {OutboundDocument? document}) => Response( - statusCode, - document != null - ? Body.json(document, toEncodable: toJsonEncodable) - : Body(), - Headers()) - ..headers.addAll({ - if (document != null) 'Content-Type': [mediaType] - }); +Response response(int statusCode, {OutboundDocument? document}) => + Response( + statusCode, + document != null + ? Body.json(document, toEncodable: toJsonEncodable) + : Body(), + Headers(), + ) + ..headers.addAll({ + if (document != null) 'Content-Type': [mediaType], + }); Response ok(OutboundDocument document) => response(StatusCode.ok, document: document); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 8f985e2d..ceff1911 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -6,27 +6,27 @@ import 'package:json_api/src/server/errors/unmatched_target.dart'; Handler router(Controller controller, Target? Function(Uri uri) matchTarget) => (request) => switch (matchTarget(request.uri)) { - RelationshipTarget target => 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 target => switch (request.method) { - 'get' => controller.fetchRelated(request, target), - _ => throw MethodNotAllowed(request.method) - }, - ResourceTarget target => switch (request.method) { - 'get' => controller.fetchResource(request, target), - 'patch' => controller.updateResource(request, target), - 'delete' => controller.deleteResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - Target target => switch (request.method) { - 'get' => controller.fetchCollection(request, target), - 'post' => controller.createResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - _ => throw UnmatchedTarget(request.uri) - }; + RelationshipTarget target => 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 target => switch (request.method) { + 'get' => controller.fetchRelated(request, target), + _ => throw MethodNotAllowed(request.method), + }, + ResourceTarget target => switch (request.method) { + 'get' => controller.fetchResource(request, target), + 'patch' => controller.updateResource(request, target), + 'delete' => controller.deleteResource(request, target), + _ => throw MethodNotAllowed(request.method), + }, + Target target => switch (request.method) { + 'get' => controller.fetchCollection(request, target), + 'post' => controller.createResource(request, target), + _ => throw MethodNotAllowed(request.method), + }, + _ => throw UnmatchedTarget(request.uri), + }; diff --git a/pubspec.yaml b/pubspec.yaml index 00afda30..11a16e4a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ 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.5.0 <4.0.0' + sdk: '>=3.13.0 <4.0.0' dependencies: http_parser: ^4.0.0 @@ -11,7 +11,7 @@ dependencies: http_interop_middleware: ^0.1.0 dev_dependencies: - lints: ^5.0.0 + lints: ^6.1.0 test: ^1.21.1 stream_channel: ^2.1.0 uuid: ^4.2.0 From 186e7d8221f277cc5141ef1468f307e21342f0d4 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 3 Oct 2026 18:02:50 -0700 Subject: [PATCH 20/43] format --- analysis_options.yaml | 4 - example/client.dart | 8 +- example/server.dart | 23 +- example/server/in_memory_repo.dart | 29 +- example/server/json_api_server.dart | 6 +- example/server/repository.dart | 23 +- example/server/repository_controller.dart | 73 +- lib/src/client/client.dart | 1 - .../client/response/relationship_fetched.dart | 2 +- .../client/response/relationship_updated.dart | 5 +- test/contract/crud_test.dart | 180 +++-- test/contract/errors_test.dart | 32 +- test/contract/resource_creation_test.dart | 68 +- test/e2e/browser_test.dart | 6 +- test/e2e/e2e_test_set.dart | 13 +- test/e2e/hybrid_server.dart | 7 +- test/e2e/vm_test.dart | 8 +- test/test_handler.dart | 18 +- test/unit/client/client_test.dart | 643 +++++++++++------- test/unit/client/response.dart | 460 +++++++------ test/unit/client/typed_responses_test.dart | 12 +- test/unit/document/error_object_test.dart | 35 +- test/unit/document/inbound_document_test.dart | 122 ++-- test/unit/document/link_test.dart | 11 +- test/unit/document/new_resource_test.dart | 99 +-- .../unit/document/outbound_document_test.dart | 138 ++-- test/unit/document/payload.dart | 98 ++- test/unit/document/relationship_test.dart | 27 +- test/unit/document/resource_test.dart | 83 +-- test/unit/query/fields_test.dart | 41 +- test/unit/query/include_test.dart | 7 +- test/unit/query/page_test.dart | 2 +- test/unit/query/sort_test.dart | 2 +- test/unit/routing/url_test.dart | 267 +++++--- test/unit/server/cors_middleware_test.dart | 57 +- test/unit/server/error_converter_test.dart | 16 +- test/unit/server/response_test.dart | 15 +- 37 files changed, 1530 insertions(+), 1111 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 1ccb251c..572dd239 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,5 +1 @@ include: package:lints/recommended.yaml -linter: - rules: - - sort_constructors_first - - sort_unnamed_constructors_first diff --git a/example/client.dart b/example/client.dart index e2e6f672..39928dcc 100644 --- a/example/client.dart +++ b/example/client.dart @@ -38,12 +38,8 @@ 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 e6734341..7a39752e 100644 --- a/example/server.dart +++ b/example/server.dart @@ -35,16 +35,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..697402a8 100644 --- a/example/server/in_memory_repo.dart +++ b/example/server/in_memory_repo.dart @@ -30,7 +30,11 @@ class InMemoryRepo implements Repository { @override Stream addMany( - String type, String id, String rel, Iterable ids) { + 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()); @@ -48,20 +52,31 @@ class InMemoryRepo implements Repository { @override Future replaceOne( - String type, String id, String rel, Identifier? one) async { + 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))) - .map((it) => it.toIdentifier()); + 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) { + 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..3c289415 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -4,11 +4,7 @@ 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, - }); + JsonApiServer(this._handler, {this.host = 'localhost', this.port = 8080}); /// Server host name final String host; diff --git a/example/server/repository.dart b/example/server/repository.dart index e8383533..11e2e08f 100644 --- a/example/server/repository.dart +++ b/example/server/repository.dart @@ -17,7 +17,11 @@ abstract class Repository { /// Throws [ResourceNotFound]. /// Throws [RelationshipNotFound]. Stream addMany( - String type, String id, String rel, Iterable many); + String type, + String id, + String rel, + Iterable many, + ); /// Delete the resource Future delete(String type, String id); @@ -30,12 +34,20 @@ 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); + 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); + String type, + String id, + String rel, + Iterable many, + ); } class Reference { @@ -103,8 +115,9 @@ class Model extends ModelProps { 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..3d4c2e8c 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -53,7 +53,9 @@ class RepositoryController implements Controller { final newResource = document.dataAsNewResource(); final res = newResource.toResource(getId); await repo.persist( - res.type, Model(res.id)..setFrom(ModelProps.fromResource(res))); + res.type, + Model(res.id)..setFrom(ModelProps.fromResource(res)), + ); if (newResource.id != null) { return noContent(); } @@ -62,8 +64,9 @@ class RepositoryController implements Controller { 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 @@ -77,28 +80,38 @@ class RepositoryController implements Controller { @override Future deleteResource( - Request request, ResourceTarget target) async { + 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())); + Request request, + ResourceTarget target, + ) async { + await repo.update( + target.type, + target.id, + ModelProps.fromResource((await _decode(request)).dataAsResource()), + ); return noContent(); } @override Future replaceRelationship( - 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 ok( - OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(ref))); + OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(ref)), + ); } if (rel is ToMany) { final ids = await repo @@ -111,7 +124,9 @@ class RepositoryController implements Controller { @override Future deleteMany( - 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) @@ -121,15 +136,21 @@ class RepositoryController implements Controller { @override Future fetchRelationship( - 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 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,15 +162,17 @@ 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()); + await _fetchRelatedCollection(many).toList(), + ); return ok(doc); } throw RelationshipNotFound(target.type, target.id, target.relationship); @@ -157,7 +180,9 @@ class RepositoryController implements Controller { /// Returns a stream of related resources recursively Stream _getAllRelated( - Resource resource, Iterable nodes) async* { + Resource resource, + Iterable nodes, + ) async* { for (final node in nodes) { await for (final r in _getRelated(resource, node.name)) { yield r; @@ -168,9 +193,13 @@ 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); } } diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 92add045..4d4c1fc6 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -16,7 +16,6 @@ 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); diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index e7561d3c..6536b864 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -3,7 +3,7 @@ import 'package:json_api/src/client/response.dart'; /// A response to a relationship fetch request. class RelationshipFetched( -/// The raw JSON:API response + /// The raw JSON:API response final Response rawResponse, final R relationship, ) { diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index 5d6fccfb..8828f363 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -3,9 +3,10 @@ import 'package:json_api/src/client/response.dart'; /// A response to a relationship request. class RelationshipUpdated( -/// The raw JSON:API response + /// The raw JSON:API response final Response rawResponse, -/// Updated relationship. Null if "204 No Content" is returned + + /// Updated relationship. Null if "204 No Content" is returned final R? relationship, ) { static RelationshipUpdated many(Response response) { diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index e2af2547..d897c8fb 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -21,31 +21,42 @@ 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 +70,10 @@ 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 +85,11 @@ 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 +102,9 @@ 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 +123,84 @@ 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'); - }); + 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 {}); @@ -187,7 +230,8 @@ extension _ToManyExt on ToMany { /// 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))); + (resource) => any((identifier) => identifier.identifies(resource)), + ); } extension _IdentifierExt on Identifier { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 96d10f02..cc11f42e 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -18,7 +18,9 @@ void main() { () => 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')), + Uri.parse('/posts/1/relationships/author'), + Request('head'), + ), ]; for (final action in actions) { final response = await action(); @@ -26,30 +28,42 @@ 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(), + http.Headers(), + ), + ); expect(r.statusCode, 400); }); test('Unsupported extension', () async { - final r = await testHandler()(http.Request( + final r = await testHandler()( + http.Request( 'get', Uri.parse('/posts/1'), http.Body(), 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( + final r = await testHandler()( + http.Request( 'get', Uri.parse('/posts/1'), http.Body(), 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..a6f4f674 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -15,39 +15,53 @@ void main() { group('Resource creation', () { test('Resource id assigned on the server', () async { 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.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}'); - }); + .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.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); + expect(r.resource.attributes['title'], 'Hello world'); + expect( + r.resource.links['self'].toString(), + '/posts/${r.resource.id}', + ); + }); }); test('Resource id assigned on the server using local id', () async { - await client.createNew('posts', - lid: 'lid', - attributes: {'title': 'Hello world'}, - one: {'self': LocalIdentifier('posts', 'lid')}).then((r) { - expect(r.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) { - expect(r.rawResponse.httpResponse.statusCode, 204); - expect(r.resource, isNull); - expect(r.rawResponse.httpResponse.headers['location'], isNull); - }); + 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..823d7889 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())), + Client(oneOffHandler), + ); }); testLocationIsSet(() => client); diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index 6dc67ec4..7f00721e 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -12,8 +12,11 @@ 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 +31,10 @@ 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/vm_test.dart b/test/e2e/vm_test.dart index 50d1e128..b9032ad8 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -16,9 +16,11 @@ 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), + ), + Client(oneOffHandler), + ); }); tearDownAll(() async { diff --git a/test/test_handler.dart b/test/test_handler.dart index 4b4288b7..64ca711f 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -6,13 +6,19 @@ 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}) => - corsMiddleware.add(requestValidator).add(errorConverter()).call(router( +Handler testHandler({ + Iterable types = const ['users', 'posts', 'comments'], + Function(Request request)? onRequest, + Function(Response response)? onResponse, +}) => corsMiddleware + .add(requestValidator) + .add(errorConverter()) + .call( + router( RepositoryController(InMemoryRepo(types), _nextId), - StandardUriDesign.pathOnly.matchTarget)); + StandardUriDesign.pathOnly.matchTarget, + ), + ); 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..3755f10e 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -12,8 +12,10 @@ import 'response.dart' as mock; void main() { final mockHandler = MockHandler(); - final client = - RoutingClient(StandardUriDesign.pathOnly, Client(mockHandler.handle)); + final client = RoutingClient( + StandardUriDesign.pathOnly, + Client(mockHandler.handle), + ); group('Failure', () { test('RequestFailure', () async { @@ -48,26 +50,30 @@ void main() { 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); @@ -78,11 +84,11 @@ void main() { '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,32 +97,40 @@ 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.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); @@ -127,11 +141,11 @@ void main() { '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'}); @@ -146,32 +160,40 @@ void main() { 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.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 +203,51 @@ 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.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.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 +255,17 @@ 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.uri.toString(), '/articles/1/author'); expect(mockHandler.request.headers, { - 'Accept': ['application/vnd.api+json'] + 'Accept': ['application/vnd.api+json'], }); }); }); @@ -240,30 +276,37 @@ void main() { 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.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.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'], }); }); }); @@ -274,44 +317,50 @@ void main() { final response = await client.createNew('articles'); expect(response.resource.type, 'articles'); expect( - response.links['self'].toString(), 'http://example.com/articles/1'); + response.links['self'].toString(), + 'http://example.com/articles/1', + ); expect(response.included.length, 3); 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'); + response.links['self'].toString(), + 'http://example.com/articles/1', + ); expect(response.included.length, 3); 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 +371,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'}); @@ -350,10 +399,10 @@ void main() { 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'}, }); }); @@ -365,35 +414,38 @@ void main() { 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.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 +457,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'}); @@ -433,10 +485,10 @@ void main() { 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'}, }); }); @@ -448,36 +500,38 @@ void main() { 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.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 +543,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,42 +566,52 @@ 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.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.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'}, }); }); @@ -555,7 +619,11 @@ void main() { mockHandler.response = mock.error422(); try { await client.replaceToOne( - 'articles', '1', 'author', Identifier('people', '42')); + 'articles', + '1', + 'author', + Identifier('people', '42'), + ); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.rawResponse.httpResponse.statusCode, 422); @@ -566,9 +634,14 @@ 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, + ); }); }); @@ -579,34 +652,44 @@ void main() { 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.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.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 +705,73 @@ 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'); + 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'); + 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 +782,74 @@ 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'); + 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'); + 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 +860,74 @@ 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'); + 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'); + 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 +939,10 @@ 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/response.dart b/test/unit/client/response.dart index a5569c8c..867f6a72 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -2,273 +2,281 @@ 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); + 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'} + 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!'}, + 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); + 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' + 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' + 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); + 'links': {'self': 'http://example.com/comments/12'}, + }, + ], + }), + headers, +); many() => Response( - 200, - Body.json({ - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags' - }, - 'meta': {'hello': 'world'}, - 'data': [ - {'type': 'tags', 'id': '12'} - ] - }), - headers); + 200, + Body.json({ + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags', + }, + 'meta': {'hello': 'world'}, + 'data': [ + {'type': 'tags', 'id': '12'}, + ], + }), + headers, +); noContent() => Response(204, Body(), 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); + 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()); diff --git a/test/unit/client/typed_responses_test.dart b/test/unit/client/typed_responses_test.dart index 85525cd3..5abaa38d 100644 --- a/test/unit/client/typed_responses_test.dart +++ b/test/unit/client/typed_responses_test.dart @@ -15,19 +15,25 @@ void main() { group('RelatedResourceFetched', () { test('throws on empty body', () { expect( - () => RelatedResourceFetched(emptyResponse), throwsFormatException); + () => RelatedResourceFetched(emptyResponse), + throwsFormatException, + ); }); }); group('RelationshipFetched', () { test('.many() throws on empty body', () { expect( - () => RelationshipFetched.many(emptyResponse), throwsFormatException); + () => RelationshipFetched.many(emptyResponse), + throwsFormatException, + ); }); test('.one() throws on empty body', () { expect( - () => RelationshipFetched.one(emptyResponse), throwsFormatException); + () => RelationshipFetched.one(emptyResponse), + throwsFormatException, + ); }); }); diff --git a/test/unit/document/error_object_test.dart b/test/unit/document/error_object_test.dart index f42b2d2b..17ce485d 100644 --- a/test/unit/document/error_object_test.dart +++ b/test/unit/document/error_object_test.dart @@ -11,29 +11,32 @@ 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')) + 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..40164885 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,15 @@ 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 +118,10 @@ 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); }); @@ -128,7 +132,9 @@ void main() { expect(doc.asToOne().first.type, 'people'); expect(doc.included(), isEmpty); expect( - doc.links()['self'].toString(), '/articles/1/relationships/author'); + doc.links()['self'].toString(), + '/articles/1/relationships/author', + ); expect(doc.meta(), isEmpty); }); @@ -138,7 +144,9 @@ void main() { expect(doc.asToOne(), isEmpty); expect(doc.included(), isEmpty); expect( - doc.links()['self'].toString(), '/articles/1/relationships/author'); + doc.links()['self'].toString(), + '/articles/1/relationships/author', + ); expect(doc.meta(), isEmpty); }); @@ -149,7 +157,9 @@ void main() { expect(doc.asToMany().first.type, 'tags'); expect(doc.included(), isEmpty); expect( - doc.links()['self'].toString(), '/articles/1/relationships/tags'); + doc.links()['self'].toString(), + '/articles/1/relationships/tags', + ); expect(doc.meta(), isEmpty); }); @@ -159,48 +169,68 @@ void main() { expect(doc.asToMany(), isEmpty); expect(doc.included(), isEmpty); expect( - doc.links()['self'].toString(), '/articles/1/relationships/tags'); + 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({ - 'links': {'self': 42} - }).asToOne(), - throwsFormatException); + () => InboundDocument({'data': 42}).asToMany(), + throwsFormatException, + ); + expect( + () => 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..704c5dfd 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -16,8 +16,9 @@ void main() { 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 +31,70 @@ 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['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) + 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..69c18245 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,17 @@ 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 +43,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 +72,50 @@ 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,20 +125,24 @@ 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}, + }, + ); }); }); }); diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart index 9653ac33..3c6a2720 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,23 +13,23 @@ 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': [ { @@ -38,9 +38,9 @@ final example = { 'attributes': { 'firstName': 'Dan', 'lastName': 'Gebhardt', - 'twitter': 'dgeb' + 'twitter': 'dgeb', }, - 'links': {'self': 'http://example.com/people/9'} + 'links': {'self': 'http://example.com/people/9'}, }, { 'type': 'comments', @@ -48,10 +48,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 +59,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 +78,58 @@ 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' + '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' + 'related': '/articles/1/tags', }, - 'data': [] + 'data': [], }; final one = { 'links': { 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' + 'related': '/articles/1/author', }, - 'data': {'type': 'people', 'id': '12'} + 'data': {'type': 'people', 'id': '12'}, }; final oneEmpty = { 'links': { 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' + 'related': '/articles/1/author', }, - 'data': null + '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 +137,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 +149,9 @@ final newResourceInvalidRelationship = { 'type': 'articles', 'attributes': {'title': 'JSON:API paints my bikeshed!'}, 'relationships': { - 'author': { - 'data': true, - }, - } - } + 'author': {'data': true}, + }, + }, }; final newResourceIncompleteRelationship = { @@ -168,6 +162,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..d9a96973 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -6,54 +6,59 @@ 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/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/include_test.dart b/test/unit/query/include_test.dart index 7f4eef5b..7129d56a 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -18,15 +18,16 @@ 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 4a8c0260..0a21e2b4 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -8,54 +8,80 @@ void main() { expect(d.collection('books').toString(), '/books'); expect(d.resource('books', '42').toString(), '/books/42'); expect(d.related('books', '42', 'author').toString(), '/books/42/author'); - expect(d.relationship('books', '42', 'author').toString(), - '/books/42/relationships/author'); + expect( + d.relationship('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.collection('books').toString(), 'https://example.com/api/books'); - expect(d.resource('books', '42').toString(), - 'https://example.com/api/books/42'); - expect(d.related('books', '42', 'author').toString(), - 'https://example.com/api/books/42/author'); - expect(d.relationship('books', '42', 'author').toString(), - 'https://example.com/api/books/42/relationships/author'); + expect( + d.resource('books', '42').toString(), + 'https://example.com/api/books/42', + ); + expect( + d.related('books', '42', 'author').toString(), + 'https://example.com/api/books/42/author', + ); + expect( + d.relationship('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.collection('books').toString(), 'https://example.com/api/books'); - expect(d.resource('books', '42').toString(), - 'https://example.com/api/books/42'); - expect(d.related('books', '42', 'author').toString(), - 'https://example.com/api/books/42/author'); - expect(d.relationship('books', '42', 'author').toString(), - 'https://example.com/api/books/42/relationships/author'); + expect( + d.resource('books', '42').toString(), + 'https://example.com/api/books/42', + ); + expect( + d.related('books', '42', 'author').toString(), + 'https://example.com/api/books/42/author', + ); + expect( + d.relationship('books', '42', 'author').toString(), + 'https://example.com/api/books/42/relationships/author', + ); }); test('Authority is retained if exists in base', () { final d = StandardUriDesign(Uri.parse('https://example.com:8080')); expect(d.collection('books').toString(), 'https://example.com:8080/books'); - expect(d.resource('books', '42').toString(), - 'https://example.com:8080/books/42'); - expect(d.related('books', '42', 'author').toString(), - 'https://example.com:8080/books/42/author'); - expect(d.relationship('books', '42', 'author').toString(), - 'https://example.com:8080/books/42/relationships/author'); + expect( + d.resource('books', '42').toString(), + 'https://example.com:8080/books/42', + ); + expect( + d.related('books', '42', 'author').toString(), + 'https://example.com:8080/books/42/author', + ); + expect( + d.relationship('books', '42', 'author').toString(), + 'https://example.com:8080/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.collection('books').toString(), 'https://example.com/foo/books'); - expect(d.resource('books', '42').toString(), - 'https://example.com/foo/books/42'); - expect(d.related('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/author'); - expect(d.relationship('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/relationships/author'); + expect( + d.resource('books', '42').toString(), + 'https://example.com/foo/books/42', + ); + expect( + d.related('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/author', + ); + expect( + d.relationship('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/relationships/author', + ); }); group('Target matching', () { test('Path only', () { @@ -63,111 +89,154 @@ void main() { expect(d.matchTarget(Uri.parse('/books')), isA()); expect(d.matchTarget(Uri.parse('/books/42')), isA()); expect( - d.matchTarget(Uri.parse('/books/42/authors')), isA()); - expect(d.matchTarget(Uri.parse('/books/42/relationships/authors')), - isA()); + d.matchTarget(Uri.parse('/books/42/authors')), + isA(), + ); + expect( + d.matchTarget(Uri.parse('/books/42/relationships/authors')), + isA(), + ); expect(d.matchTarget(Uri.parse('/a/b/c/d')), isNull); }); test('Path only, full url', () { final d = StandardUriDesign.pathOnly; expect( - d.matchTarget(Uri.parse('https://example.com/books')), isA()); - expect(d.matchTarget(Uri.parse('https://example.com/books/42')), - isA()); - expect(d.matchTarget(Uri.parse('https://example.com/books/42/authors')), - isA()); + d.matchTarget(Uri.parse('https://example.com/books')), + isA(), + ); + expect( + d.matchTarget(Uri.parse('https://example.com/books/42')), + isA(), + ); expect( - d.matchTarget( - Uri.parse('https://example.com/books/42/relationships/authors')), - isA()); + d.matchTarget(Uri.parse('https://example.com/books/42/authors')), + isA(), + ); + expect( + d.matchTarget( + Uri.parse('https://example.com/books/42/relationships/authors'), + ), + isA(), + ); expect(d.matchTarget(Uri.parse('https://example.com/a/b/c/d')), isNull); }); test('Authority', () { final d = StandardUriDesign(Uri.parse('https://example.com:8080')); - expect(d.matchTarget(Uri.parse('https://example.com:8080/books')), - isA()); - expect(d.matchTarget(Uri.parse('https://example.com:8080/books/42')), - isA()); expect( - d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')), - isA()); + d.matchTarget(Uri.parse('https://example.com:8080/books')), + isA(), + ); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/books/42')), + isA(), + ); expect( - d.matchTarget(Uri.parse( - 'https://example.com:8080/books/42/relationships/authors')), - isA()); + d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')), + isA(), + ); + expect( + d.matchTarget( + Uri.parse('https://example.com:8080/books/42/relationships/authors'), + ), + isA(), + ); expect( - d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), + isNull, + ); expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); }); test('Authority and path', () { final d = StandardUriDesign(Uri.parse('https://example.com:8080/api')); - expect(d.matchTarget(Uri.parse('https://example.com:8080/api/books')), - isA().having((it) => it.type, 'type', equals('books'))); - expect( - d.matchTarget(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.matchTarget( - 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.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), + ); + expect( + d.matchTarget(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.matchTarget( + 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.matchTarget( + 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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), + isNull, + ); expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); - expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), - isNull); + expect( + d.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/api/books')), - isA().having((it) => it.type, 'type', equals('books'))); - expect( - d.matchTarget(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.matchTarget( - 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.matchTarget(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.matchTarget(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), + ); + expect( + d.matchTarget(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.matchTarget( + 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.matchTarget( + 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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), isNull); + d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), + isNull, + ); expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); - expect(d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), - isNull); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), + isNull, + ); }); }); } diff --git a/test/unit/server/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart index 761bba87..2a991bae 100644 --- a/test/unit/server/cors_middleware_test.dart +++ b/test/unit/server/cors_middleware_test.dart @@ -13,12 +13,13 @@ main() { 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(), + 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 +28,47 @@ main() { test('Responds to OPTIONS', () async { final rq = Request( - 'options', - Uri(host: 'localhost'), - Body(), - Headers.from({ - 'origin': ['foo'] - })); + '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-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(), + 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'])); + rs.headers['Access-Control-Allow-Methods'], + equals(['PUT', 'POST']), + ); expect( - rs.headers['Access-Control-Allow-Headers'], equals(['foo', 'bar'])); + 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 4bb17dfe..cb777a11 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -22,13 +22,15 @@ void main() { expect(r.statusCode, equals(StatusCode.notFound)); }); test('can catch ResourceNotFound', () async { - final r = - await converter((_) => throw ResourceNotFound('foo', 'bar'))(get); + 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); + (_) => throw RelationshipNotFound('foo', 'bar', 'baz'), + )(get); expect(r.statusCode, equals(StatusCode.notFound)); }); test('can catch UnsupportedMediaType', () async { @@ -67,13 +69,15 @@ void main() { expect(r.statusCode, equals(552)); }); test('can catch ResourceNotFound', () async { - final r = - await converter((_) => throw ResourceNotFound('foo', 'bar'))(get); + 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); + (_) => throw RelationshipNotFound('foo', 'bar', 'baz'), + )(get); expect(r.statusCode, equals(554)); }); test('can catch any other error', () async { diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart index 756c56fb..54f229bf 100644 --- a/test/unit/server/response_test.dart +++ b/test/unit/server/response_test.dart @@ -6,13 +6,16 @@ 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)); + final r = response( + 200, + document: OutboundDocument()..meta['date'] = DateTime(2021), + ); expect( - await r.body.decodeJson(), - equals({ - 'meta': {'date': '2021-01-01T00:00:00.000'} - })); + await r.body.decodeJson(), + equals({ + 'meta': {'date': '2021-01-01T00:00:00.000'}, + }), + ); }); }); From 0b168a42b4840e85221d8531e361448f7fd0757f Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 3 Oct 2026 18:10:09 -0700 Subject: [PATCH 21/43] format --- lib/src/document/inbound_document.dart | 4 ++-- lib/src/document/new_resource.dart | 4 ++-- lib/src/query/query.dart | 2 +- test/unit/client/response.dart | 20 +++++++++---------- .../unit/document/outbound_document_test.dart | 2 +- test/unit/server/cors_middleware_test.dart | 2 +- test/unit/server/error_converter_test.dart | 2 +- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index eb4b34bf..6e78b1ca 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -165,14 +165,14 @@ class _Parser { .get('relationships', orGet: () => {}) .map((key, value) => MapEntry(key, newRelationship(value))); - Relationship _rel(data) => switch (data) { + 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(data) => switch (data) { + NewRelationship _newRel(Object?data) => switch (data) { null => NewToOne.empty(), Map() => NewToOne(newIdentifier(data)), List() => NewToMany(data.whereType().map(newIdentifier)), diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index cbebaa20..6d39ba07 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -35,8 +35,8 @@ class NewResource( @override Map toJson() => { 'type': type, - if (id != null) 'id': id!, - if (lid != null) 'lid': lid!, + 'id': ?id, + 'lid': ?lid, if (attributes.isNotEmpty) 'attributes': attributes, if (relationships.isNotEmpty) 'relationships': relationships, if (meta.isNotEmpty) 'meta': meta, diff --git a/lib/src/query/query.dart b/lib/src/query/query.dart index bae53305..4f998de4 100644 --- a/lib/src/query/query.dart +++ b/lib/src/query/query.dart @@ -10,7 +10,7 @@ class Query([Map> parameters = const {}]) 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/test/unit/client/response.dart b/test/unit/client/response.dart index 867f6a72..13404ee9 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -5,7 +5,7 @@ final headers = Headers.from({ 'Content-Type': [mediaType], }); -collectionMin() => Response( +Response collectionMin() => Response( 200, Body.json({ 'data': [ @@ -15,7 +15,7 @@ collectionMin() => Response( headers, ); -collectionFull() => Response( +Response collectionFull() => Response( 200, Body.json({ 'links': { @@ -89,7 +89,7 @@ collectionFull() => Response( headers, ); -primaryResource() => Response( +Response primaryResource() => Response( 200, Body.json({ 'links': {'self': 'http://example.com/articles/1'}, @@ -142,7 +142,7 @@ primaryResource() => Response( headers, ); -relatedResourceNull() => Response( +Response relatedResourceNull() => Response( 200, Body.json({ 'links': {'self': 'http://example.com/articles/1/author'}, @@ -152,7 +152,7 @@ relatedResourceNull() => Response( headers, ); -one() => Response( +Response one() => Response( 200, Body.json({ 'links': { @@ -199,7 +199,7 @@ one() => Response( headers, ); -oneEmpty() => Response( +Response oneEmpty() => Response( 200, Body.json({ 'links': { @@ -246,7 +246,7 @@ oneEmpty() => Response( headers, ); -many() => Response( +Response many() => Response( 200, Body.json({ 'links': { @@ -261,9 +261,9 @@ many() => Response( headers, ); -noContent() => Response(204, Body(), Headers()); +Response noContent() => Response(204, Body(), Headers()); -error422() => Response( +Response error422() => Response( 422, Body.json({ 'meta': {'hello': 'world'}, @@ -279,4 +279,4 @@ error422() => Response( headers, ); -error500() => Response(500, Body(), Headers()); +Response error500() => Response(500, Body(), Headers()); diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index 69c18245..d66b93aa 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -148,4 +148,4 @@ void main() { }); } -Map toObject(v) => jsonDecode(jsonEncode(v)); +Map toObject(Object? v) => jsonDecode(jsonEncode(v)); diff --git a/test/unit/server/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart index 2a991bae..afacaccc 100644 --- a/test/unit/server/cors_middleware_test.dart +++ b/test/unit/server/cors_middleware_test.dart @@ -5,7 +5,7 @@ import 'package:json_api/server.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; -main() { +void main() { Response response = Response(200, Body.text('hello', utf8), Headers()); Future handler(Request rq) async => response; diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart index cb777a11..4e025f40 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -54,7 +54,7 @@ void main() { onCollectionNotFound: (_) async => Response(552, Body(), Headers()), onResourceNotFound: (_) async => Response(553, Body(), Headers()), onRelationshipNotFound: (_) async => Response(554, Body(), Headers()), - onError: (_, __) async => Response(555, Body(), Headers()), + onError: (_, _) async => Response(555, Body(), Headers()), ); test('can catch MethodNotAllowed', () async { final r = await converter((_) => throw MethodNotAllowed('foo'))(get); From f0761eb7743bf48840195c1e40ee50e650e7306d Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 8 Oct 2026 20:26:46 -0700 Subject: [PATCH 22/43] wip --- lib/routing.dart | 1 + lib/server.dart | 4 +- lib/src/routing/target_matcher.dart | 3 + lib/src/server/base_controller.dart | 58 +++++++++++++++++++ lib/src/server/mapping_controller.dart | 51 ++++++++++++++++ ...dart => request_validator_middleware.dart} | 2 +- lib/src/server/router.dart | 2 +- test/test_handler.dart | 2 +- 8 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 lib/src/routing/target_matcher.dart create mode 100644 lib/src/server/base_controller.dart create mode 100644 lib/src/server/mapping_controller.dart rename lib/src/server/{request_validator.dart => request_validator_middleware.dart} (95%) diff --git a/lib/routing.dart b/lib/routing.dart index 98553ed8..b4385cb3 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -4,4 +4,5 @@ library; export 'package:json_api/src/routing/standard_uri_design.dart'; export 'package:json_api/src/routing/target.dart'; +export 'package:json_api/src/routing/target_matcher.dart'; export 'package:json_api/src/routing/uri_design.dart'; diff --git a/lib/server.dart b/lib/server.dart index e6e60e1b..def9fed5 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -2,6 +2,7 @@ /// WARNING: This library is in beta stage. The API is subject to change. library; +export 'package:json_api/src/server/base_controller.dart'; export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/cors_middleware.dart'; export 'package:json_api/src/server/error_converter.dart'; @@ -12,6 +13,7 @@ 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/request_validator.dart'; +export 'package:json_api/src/server/mapping_controller.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/router.dart'; diff --git a/lib/src/routing/target_matcher.dart b/lib/src/routing/target_matcher.dart new file mode 100644 index 00000000..cdd15f38 --- /dev/null +++ b/lib/src/routing/target_matcher.dart @@ -0,0 +1,3 @@ +import 'package:json_api/src/routing/target.dart'; + +typedef TargetMatcher = Target? Function(Uri uri); diff --git a/lib/src/server/base_controller.dart b/lib/src/server/base_controller.dart new file mode 100644 index 00000000..78453b6c --- /dev/null +++ b/lib/src/server/base_controller.dart @@ -0,0 +1,58 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; + +abstract class BaseController() implements Controller { + @override + Future addMany(Request request, RelationshipTarget target) { + throw UnimplementedError(); + } + + @override + Future createResource(Request request, Target target) { + throw UnimplementedError(); + } + + @override + Future deleteMany(Request request, RelationshipTarget target) { + throw UnimplementedError(); + } + + @override + Future deleteResource(Request request, ResourceTarget target) { + throw UnimplementedError(); + } + + @override + Future fetchCollection(Request request, Target target) { + throw UnimplementedError(); + } + + @override + Future fetchRelated(Request request, RelatedTarget target) { + throw UnimplementedError(); + } + + @override + Future fetchRelationship(Request rq, RelationshipTarget target) { + throw UnimplementedError(); + } + + @override + Future fetchResource(Request request, ResourceTarget target) { + throw UnimplementedError(); + } + + @override + Future replaceRelationship( + Request request, + RelationshipTarget target, + ) { + throw UnimplementedError(); + } + + @override + Future updateResource(Request request, ResourceTarget target) { + throw UnimplementedError(); + } +} diff --git a/lib/src/server/mapping_controller.dart b/lib/src/server/mapping_controller.dart new file mode 100644 index 00000000..fc7ebb50 --- /dev/null +++ b/lib/src/server/mapping_controller.dart @@ -0,0 +1,51 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; + +/// A controller that delegates requests to other controllers based on the +/// resource type. +class MappingController() implements Controller { + final map = {}; + + @override + Future addMany(Request request, RelationshipTarget target) => + _controller(target.type).addMany(request, target); + + @override + Future createResource(Request request, Target target) => + _controller(target.type).createResource(request, target); + + @override + Future deleteMany(Request request, RelationshipTarget target) => + _controller(target.type).deleteMany(request, target); + + @override + Future deleteResource(Request request, ResourceTarget target) => + _controller(target.type).deleteResource(request, target); + + @override + Future fetchCollection(Request request, Target target) => + _controller(target.type).fetchCollection(request, target); + + @override + Future fetchRelated(Request request, RelatedTarget target) => + _controller(target.type).fetchRelated(request, target); + + @override + Future fetchRelationship(Request rq, RelationshipTarget target) => + _controller(target.type).fetchRelationship(rq, target); + + @override + Future fetchResource(Request request, ResourceTarget target) => + _controller(target.type).fetchResource(request, target); + + @override + Future replaceRelationship(Request request, RelationshipTarget target) => + _controller(target.type).replaceRelationship(request, target); + + @override + Future updateResource(Request request, ResourceTarget target) => + _controller(target.type).updateResource(request, target); + + Controller _controller(String type) => map[type] ?? (throw CollectionNotFound(type)); +} diff --git a/lib/src/server/request_validator.dart b/lib/src/server/request_validator_middleware.dart similarity index 95% rename from lib/src/server/request_validator.dart rename to lib/src/server/request_validator_middleware.dart index 76f1535a..25249feb 100644 --- a/lib/src/server/request_validator.dart +++ b/lib/src/server/request_validator_middleware.dart @@ -5,7 +5,7 @@ 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 requestValidator = middleware( +final requestValidatorMiddleware = middleware( onRequest: (Request request) async { final contentType = request.headers['Content-Type']?.last; if (contentType != null && _isInvalid(MediaType.parse(contentType))) { diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index ceff1911..27f9ae95 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -4,7 +4,7 @@ import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/errors/method_not_allowed.dart'; import 'package:json_api/src/server/errors/unmatched_target.dart'; -Handler router(Controller controller, Target? Function(Uri uri) matchTarget) => +Handler router(Controller controller, TargetMatcher matchTarget) => (request) => switch (matchTarget(request.uri)) { RelationshipTarget target => switch (request.method) { 'get' => controller.fetchRelationship(request, target), diff --git a/test/test_handler.dart b/test/test_handler.dart index 64ca711f..af30ae19 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -11,7 +11,7 @@ Handler testHandler({ Function(Request request)? onRequest, Function(Response response)? onResponse, }) => corsMiddleware - .add(requestValidator) + .add(requestValidatorMiddleware) .add(errorConverter()) .call( router( From 63b23feca4c75a8cd1a30d6c1edb03b6405ea6ce Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 8 Oct 2026 20:29:58 -0700 Subject: [PATCH 23/43] wip --- lib/src/client/response/collection_fetched.dart | 1 + lib/src/client/response/related_resource_fetched.dart | 1 + lib/src/document/inbound_document.dart | 2 +- lib/src/server/mapping_controller.dart | 9 ++++++--- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart index 35991f2f..28d4026c 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -11,6 +11,7 @@ class CollectionFetched( meta.addAll(document.meta()); links.addAll(document.links()); } + final document = InboundDocument( rawResponse.document ?? (throw FormatException('The document must not be empty')), diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart index ba923ced..9abc014d 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -14,6 +14,7 @@ class RelatedResourceFetched( meta.addAll(document.meta()); links.addAll(document.links()); } + final document = InboundDocument( rawResponse.document ?? (throw FormatException('The document must not be empty')), diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index 6e78b1ca..abcce90f 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -172,7 +172,7 @@ class _Parser { _ => throw FormatException('Invalid relationship object'), }; - NewRelationship _newRel(Object?data) => switch (data) { + NewRelationship _newRel(Object? data) => switch (data) { null => NewToOne.empty(), Map() => NewToOne(newIdentifier(data)), List() => NewToMany(data.whereType().map(newIdentifier)), diff --git a/lib/src/server/mapping_controller.dart b/lib/src/server/mapping_controller.dart index fc7ebb50..f2ccce3a 100644 --- a/lib/src/server/mapping_controller.dart +++ b/lib/src/server/mapping_controller.dart @@ -40,12 +40,15 @@ class MappingController() implements Controller { _controller(target.type).fetchResource(request, target); @override - Future replaceRelationship(Request request, RelationshipTarget target) => - _controller(target.type).replaceRelationship(request, target); + Future replaceRelationship( + Request request, + RelationshipTarget target, + ) => _controller(target.type).replaceRelationship(request, target); @override Future updateResource(Request request, ResourceTarget target) => _controller(target.type).updateResource(request, target); - Controller _controller(String type) => map[type] ?? (throw CollectionNotFound(type)); + Controller _controller(String type) => + map[type] ?? (throw CollectionNotFound(type)); } From 48794133e64b427bde182bc9580d4a1dbecb105a Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 15:04:21 -0700 Subject: [PATCH 24/43] Added: Filter, BaseController --- lib/query.dart | 1 + lib/src/query/fields.dart | 12 ++++---- lib/src/query/filter.dart | 50 ++++++++++++++++++++++++++++++++ lib/src/query/include.dart | 8 ++--- lib/src/query/page.dart | 18 ++++++------ test/unit/query/filter_test.dart | 40 +++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 lib/src/query/filter.dart create mode 100644 test/unit/query/filter_test.dart diff --git a/lib/query.dart b/lib/query.dart index f0311113..105a6237 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -2,6 +2,7 @@ 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/src/query/fields.dart b/lib/src/query/fields.dart index 4bb2ff83..225d6a2a 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -4,15 +4,15 @@ import 'package:json_api/src/query/query_encodable.dart'; /// Query parameters defining Sparse Fieldsets /// @see https://jsonapi.org/format/#fetching-sparse-fieldsets +/// 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 { - /// The [fields] argument maps the resource type to a list of fields. - /// - /// Example: - /// ```dart - /// Fields({'articles': ['title', 'body'], 'people': ['name']}); - /// ``` this { addAll(fields); } diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart new file mode 100644 index 00000000..8be77a44 --- /dev/null +++ b/lib/src/query/filter.dart @@ -0,0 +1,50 @@ +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 e7664f2e..8b9970c4 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -4,13 +4,13 @@ import 'package:json_api/src/query/query_encodable.dart'; /// Query parameter defining inclusion of related resources. /// @see https://jsonapi.org/format/#fetching-includes +/// Example: +/// ```dart +/// Include(['comments', 'comments.author']); +/// ``` class Include([Iterable resources = const []]) with IterableMixin implements QueryEncodable { - /// Example: - /// ```dart - /// Include(['comments', 'comments.author']); - /// ``` this { _.addAll(resources); } diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index 857fed85..28035aae 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -4,18 +4,18 @@ import 'package:json_api/src/query/query_encodable.dart'; /// Query parameters defining the pagination data. /// @see https://jsonapi.org/format/#fetching-pagination +/// 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 { - /// Example: - /// ```dart - /// Page({'limit': '10', 'offset': '20'}).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?page[limit]=10&page[offset]=20 - /// ``` - /// this { addAll(parameters); } diff --git a/test/unit/query/filter_test.dart b/test/unit/query/filter_test.dart new file mode 100644 index 00000000..63c5bbba --- /dev/null +++ b/test/unit/query/filter_test.dart @@ -0,0 +1,40 @@ +import 'package:json_api/query.dart'; +import 'package:json_api/src/query/filter.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'], + }); + }); + }); +} From 770cf08192adc6ac7354bcbdf8938662853f99cb Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 16:54:24 -0700 Subject: [PATCH 25/43] BaseController test --- test/unit/server/base_controller_test.dart | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/unit/server/base_controller_test.dart diff --git a/test/unit/server/base_controller_test.dart b/test/unit/server/base_controller_test.dart new file mode 100644 index 00000000..e6dfe6a0 --- /dev/null +++ b/test/unit/server/base_controller_test.dart @@ -0,0 +1,36 @@ +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() { + group('BaseController', () { + final controller = _Controller(); + final request = Request('get', Uri(), Body(), Headers()); + const target = Target('articles'); + const resourceTarget = ResourceTarget('articles', '1'); + const relationshipTarget = RelationshipTarget('articles', '1', 'author'); + const relatedTarget = RelatedTarget('articles', '1', 'author'); + + test('throws UnimplementedError from default handlers', () { + final handlers = [ + () => controller.addMany(request, relationshipTarget), + () => controller.createResource(request, target), + () => controller.deleteMany(request, relationshipTarget), + () => controller.deleteResource(request, resourceTarget), + () => controller.fetchCollection(request, target), + () => controller.fetchRelated(request, relatedTarget), + () => controller.fetchRelationship(request, relationshipTarget), + () => controller.fetchResource(request, resourceTarget), + () => controller.replaceRelationship(request, relationshipTarget), + () => controller.updateResource(request, resourceTarget), + ]; + + for (final handler in handlers) { + expect(handler, throwsUnimplementedError); + } + }); + }); +} + +final class _Controller extends BaseController {} From 9d68d98d828a70b48e221c10b40ede19ffb954a8 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 16:56:28 -0700 Subject: [PATCH 26/43] MappingController test --- test/unit/query/filter_test.dart | 1 - test/unit/server/mapping_controller_test.dart | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 test/unit/server/mapping_controller_test.dart diff --git a/test/unit/query/filter_test.dart b/test/unit/query/filter_test.dart index 63c5bbba..8aa85112 100644 --- a/test/unit/query/filter_test.dart +++ b/test/unit/query/filter_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/query.dart'; -import 'package:json_api/src/query/filter.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/unit/server/mapping_controller_test.dart b/test/unit/server/mapping_controller_test.dart new file mode 100644 index 00000000..2d994f8e --- /dev/null +++ b/test/unit/server/mapping_controller_test.dart @@ -0,0 +1,107 @@ +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() { + group('MappingController', () { + final request = Request('get', Uri(), Body(), Headers()); + const target = Target('articles'); + const resourceTarget = ResourceTarget('articles', '1'); + const relationshipTarget = RelationshipTarget('articles', '1', 'author'); + const relatedTarget = RelatedTarget('articles', '1', 'author'); + + late MappingController controller; + late _Controller articles; + + setUp(() { + controller = MappingController(); + articles = _Controller(); + controller.map['articles'] = articles; + }); + + test('delegates to the controller mapped to the target type', () async { + await controller.addMany(request, relationshipTarget); + await controller.createResource(request, target); + await controller.deleteMany(request, relationshipTarget); + await controller.deleteResource(request, resourceTarget); + await controller.fetchCollection(request, target); + await controller.fetchRelated(request, relatedTarget); + await controller.fetchRelationship(request, relationshipTarget); + await controller.fetchResource(request, resourceTarget); + await controller.replaceRelationship(request, relationshipTarget); + await controller.updateResource(request, resourceTarget); + + expect(articles.calls, [ + 'addMany', + 'createResource', + 'deleteMany', + 'deleteResource', + 'fetchCollection', + 'fetchRelated', + 'fetchRelationship', + 'fetchResource', + 'replaceRelationship', + 'updateResource', + ]); + }); + + test('throws CollectionNotFound when target type is not mapped', () { + expect( + () => controller.fetchCollection(request, const Target('people')), + throwsA(isA()), + ); + }); + }); +} + +final class _Controller implements Controller { + final calls = []; + + @override + Future addMany(Request request, RelationshipTarget target) => + _call('addMany'); + + @override + Future createResource(Request request, Target target) => + _call('createResource'); + + @override + Future deleteMany(Request request, RelationshipTarget target) => + _call('deleteMany'); + + @override + Future deleteResource(Request request, ResourceTarget target) => + _call('deleteResource'); + + @override + Future fetchCollection(Request request, Target target) => + _call('fetchCollection'); + + @override + Future fetchRelated(Request request, RelatedTarget target) => + _call('fetchRelated'); + + @override + Future fetchRelationship(Request rq, RelationshipTarget target) => + _call('fetchRelationship'); + + @override + Future fetchResource(Request request, ResourceTarget target) => + _call('fetchResource'); + + @override + Future replaceRelationship( + Request request, + RelationshipTarget target, + ) => _call('replaceRelationship'); + + @override + Future updateResource(Request request, ResourceTarget target) => + _call('updateResource'); + + Future _call(String name) async { + calls.add(name); + return Response(200, Body(), Headers()); + } +} From bcfdadfce63e69af7bf23262c68e7fbe4e1e0f4f Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 22:31:09 -0700 Subject: [PATCH 27/43] Routing --- analysis_options.yaml | 11 ++ example/client.dart | 5 +- example/server.dart | 2 +- example/server/in_memory_repo.dart | 36 +--- example/server/json_api_server.dart | 11 +- example/server/relationship_node.dart | 13 +- example/server/repository.dart | 45 +---- example/server/repository_controller.dart | 106 ++++-------- lib/routing.dart | 1 - lib/src/client/client.dart | 21 +-- lib/src/client/payload_codec.dart | 3 +- lib/src/client/request.dart | 8 +- .../client/response/collection_fetched.dart | 3 +- .../response/related_resource_fetched.dart | 3 +- .../client/response/relationship_fetched.dart | 12 +- .../client/response/relationship_updated.dart | 10 +- lib/src/client/response/resource_created.dart | 3 +- lib/src/client/response/resource_fetched.dart | 3 +- lib/src/client/routing_client.dart | 50 +++--- lib/src/document/inbound_document.dart | 40 ++--- lib/src/document/json_encodable.dart | 2 +- lib/src/document/link.dart | 3 +- lib/src/document/new_identifier.dart | 14 +- lib/src/document/new_relationship.dart | 4 +- lib/src/document/new_resource.dart | 22 +-- lib/src/document/new_to_one.dart | 2 +- lib/src/document/outbound_document.dart | 21 +-- lib/src/document/relationship.dart | 2 +- lib/src/document/to_one.dart | 5 +- lib/src/query/fields.dart | 9 +- lib/src/query/filter.dart | 8 +- lib/src/query/include.dart | 5 +- lib/src/query/page.dart | 8 +- lib/src/query/query.dart | 3 +- lib/src/query/query_encodable.dart | 2 +- lib/src/query/sort.dart | 12 +- lib/src/routing/standard_uri_design.dart | 64 +++---- lib/src/routing/target.dart | 20 +-- lib/src/routing/target_matcher.dart | 3 - lib/src/routing/uri_design.dart | 13 +- lib/src/server/base_controller.dart | 5 +- lib/src/server/controller.dart | 11 +- lib/src/server/cors_middleware.dart | 3 +- lib/src/server/error_converter.dart | 14 +- lib/src/server/errors/not_acceptable.dart | 2 +- .../server/errors/relationship_not_found.dart | 7 +- .../server/errors/unsupported_media_type.dart | 2 +- lib/src/server/mapping_controller.dart | 13 +- .../server/request_validator_middleware.dart | 3 +- lib/src/server/response.dart | 10 +- lib/src/server/router.dart | 2 +- test/contract/crud_test.dart | 66 ++----- test/contract/errors_test.dart | 12 +- test/contract/resource_creation_test.dart | 46 ++--- test/e2e/browser_test.dart | 2 +- test/e2e/e2e_test_set.dart | 11 +- test/e2e/vm_test.dart | 4 +- test/test_handler.dart | 5 +- test/unit/client/client_test.dart | 142 ++++----------- test/unit/client/mock_handler.dart | 2 +- test/unit/client/response.dart | 39 +---- test/unit/client/typed_responses_test.dart | 15 +- test/unit/document/error_object_test.dart | 5 +- test/unit/document/inbound_document_test.dart | 66 ++----- test/unit/document/new_resource_test.dart | 18 +- .../unit/document/outbound_document_test.dart | 9 +- test/unit/document/payload.dart | 26 +-- test/unit/document/resource_test.dart | 10 +- test/unit/query/include_test.dart | 4 +- test/unit/routing/url_test.dart | 162 ++++++------------ test/unit/server/base_controller_test.dart | 4 +- test/unit/server/cors_middleware_test.dart | 10 +- test/unit/server/error_converter_test.dart | 16 +- test/unit/server/mapping_controller_test.dart | 30 ++-- test/unit/server/response_test.dart | 5 +- 75 files changed, 415 insertions(+), 974 deletions(-) delete mode 100644 lib/src/routing/target_matcher.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 572dd239..60881936 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,12 @@ include: package:lints/recommended.yaml +formatter: + page_width: 100 + +linter: + rules: + - use_primary_constructors + - 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 39928dcc..f12d1403 100644 --- a/example/client.dart +++ b/example/client.dart @@ -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,8 +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 7a39752e..ed18b937 100644 --- a/example/server.dart +++ b/example/server.dart @@ -20,7 +20,7 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(router(controller, StandardUriDesign.pathOnly.matchTarget)); + .call(router(controller, StandardUriDesign.pathOnly.target)); final server = JsonApiServer(handler, host: host, port: port); diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart index 697402a8..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,12 +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()); @@ -51,32 +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)), - ).map((it) => it.toIdentifier()); + 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 3c289415..3864b91d 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -3,16 +3,11 @@ 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/relationship_node.dart b/example/server/relationship_node.dart index 44bf41f4..9f4ccb31 100644 --- a/example/server/relationship_node.dart +++ b/example/server/relationship_node.dart @@ -1,18 +1,13 @@ /// 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 11e2e08f..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,12 +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); @@ -33,41 +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) { @@ -104,20 +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 3d4c2e8c..9de0c464 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -12,20 +12,14 @@ 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 +32,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); @@ -52,17 +48,15 @@ class RepositoryController implements Controller { 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)), - ); + 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; + var type = ref.type; + var 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(), @@ -72,26 +66,18 @@ class RepositoryController implements Controller { @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(); + final refs = await repo.addMany(target.type, target.id, target.relationship, many).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 { + Future updateResource(Request request, ResourceTarget target) async { await repo.update( target.type, target.id, @@ -101,56 +87,35 @@ class RepositoryController implements Controller { } @override - Future replaceRelationship( - Request request, - RelationshipTarget target, - ) async { + Future replaceRelationship(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 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 { + 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(); + 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); @@ -162,27 +127,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; @@ -195,11 +153,7 @@ class RepositoryController implements Controller { Stream _getRelated(Resource resource, String relationship) async* { for (final rel in resource.relationships[relationship] ?? - (throw RelationshipNotFound( - resource.type, - resource.id, - relationship, - ))) { + (throw RelationshipNotFound(resource.type, resource.id, relationship))) { yield await _fetchLinkedResource(rel.type, rel.id); } } @@ -207,7 +161,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) => @@ -229,10 +183,8 @@ class RepositoryController implements Controller { } } - Future _decode(Request r) => r.body - .decode(utf8) - .then(const PayloadCodec().decode) - .then(InboundDocument.new); + Future _decode(Request r) => + r.body.decode(utf8).then(const PayloadCodec().decode).then(InboundDocument.new); } typedef IdGenerator = String Function(); diff --git a/lib/routing.dart b/lib/routing.dart index b4385cb3..98553ed8 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -4,5 +4,4 @@ library; export 'package:json_api/src/routing/standard_uri_design.dart'; export 'package:json_api/src/routing/target.dart'; -export 'package:json_api/src/routing/target_matcher.dart'; export 'package:json_api/src/routing/uri_design.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 4d4c1fc6..8cc481c7 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -12,10 +12,7 @@ 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 const Client( - final i.Handler _handler, { - final PayloadCodec _codec = const PayloadCodec(), -}) { +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); @@ -25,27 +22,19 @@ class const Client( if (json.isNotEmpty) 'Content-Type': [mediaType], ...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['Content-Type']?.last.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/payload_codec.dart b/lib/src/client/payload_codec.dart index a5b3c9f0..d3641e85 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/client/payload_codec.dart @@ -16,6 +16,5 @@ class const PayloadCodec() { } /// Encodes a JSON:API document into a JSON string. - FutureOr encode(Object document) => - jsonEncode(document, toEncodable: toJsonEncodable); + FutureOr encode(Object document) => jsonEncode(document, toEncodable: toJsonEncodable); } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 1f7dca31..d52d28fb 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -14,16 +14,16 @@ class Request( 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); + new patch([Object? document]) : this('patch', document); /// Query parameters. final query = Query(); diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart index 28d4026c..b0e07d8f 100644 --- a/lib/src/client/response/collection_fetched.dart +++ b/lib/src/client/response/collection_fetched.dart @@ -13,8 +13,7 @@ class CollectionFetched( } final document = InboundDocument( - rawResponse.document ?? - (throw FormatException('The document must not be empty')), + rawResponse.document ?? (throw FormatException('The document must not be empty')), ); /// The resource collection fetched from the server diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart index 9abc014d..114933cd 100644 --- a/lib/src/client/response/related_resource_fetched.dart +++ b/lib/src/client/response/related_resource_fetched.dart @@ -16,8 +16,7 @@ class RelatedResourceFetched( } final document = InboundDocument( - rawResponse.document ?? - (throw FormatException('The document must not be empty')), + rawResponse.document ?? (throw FormatException('The document must not be empty')), ); /// Related resource. May be null diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart index 6536b864..f66b145e 100644 --- a/lib/src/client/response/relationship_fetched.dart +++ b/lib/src/client/response/relationship_fetched.dart @@ -9,20 +9,16 @@ class RelationshipFetched( ) { static RelationshipFetched many(Response response) { final document = InboundDocument( - response.document ?? - (throw FormatException('The document must not be empty')), + response.document ?? (throw FormatException('The document must not be empty')), ); - return RelationshipFetched(response, document.asToMany()) - ..included.addAll(document.included()); + 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')), + response.document ?? (throw FormatException('The document must not be empty')), ); - return RelationshipFetched(response, document.asToOne()) - ..included.addAll(document.included()); + return RelationshipFetched(response, document.asToOne())..included.addAll(document.included()); } final included = []; diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart index 8828f363..4b72267b 100644 --- a/lib/src/client/response/relationship_updated.dart +++ b/lib/src/client/response/relationship_updated.dart @@ -11,17 +11,11 @@ class RelationshipUpdated( ) { 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()); } } diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart index 3e5cd4d1..665211cb 100644 --- a/lib/src/client/response/resource_created.dart +++ b/lib/src/client/response/resource_created.dart @@ -8,8 +8,7 @@ import 'package:json_api/src/client/response.dart'; class ResourceCreated(final Response rawResponse) { this { final document = InboundDocument( - rawResponse.document ?? - (throw FormatException('The document must not be empty')), + rawResponse.document ?? (throw FormatException('The document must not be empty')), ); resource = document.dataAsResource(); included.addAll(document.included()); diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart index cd338434..9877f5dd 100644 --- a/lib/src/client/response/resource_fetched.dart +++ b/lib/src/client/response/resource_fetched.dart @@ -5,8 +5,7 @@ import 'package:json_api/src/client/response.dart'; class ResourceFetched(final Response rawResponse) { this { final document = InboundDocument( - rawResponse.document ?? - (throw FormatException('The document must not be empty')), + rawResponse.document ?? (throw FormatException('The document must not be empty')), ); resource = document.dataAsResource(); included.addAll(document.included()); diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index f9741ace..3529bab3 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -14,7 +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(final UriDesign _baseUri, final Client _client) { +class RoutingClient(final UriBuilder _uriFor, final Client _client) { /// Adds the [identifiers] to the to-many relationship /// identified by [type], [id], [relationship]. /// @@ -29,10 +29,9 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { 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), + _uriFor(RelationshipTarget(type, id, relationship)), + Request.post(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers), ), ); @@ -58,7 +57,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => ResourceUpdated( await send( - _baseUri.collection(type), + _uriFor(CollectionTarget(type)), Request.post( OutboundDataDocument.resource( Resource(type, id) @@ -99,7 +98,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => ResourceCreated( await send( - _baseUri.collection(type), + _uriFor(CollectionTarget(type)), Request.post( OutboundDataDocument.newResource( NewResource(type, lid: lid) @@ -131,10 +130,9 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { 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), + _uriFor(RelationshipTarget(type, id, relationship)), + Request.delete(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) + ..headers.addAll(headers), ), ); @@ -149,7 +147,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => CollectionFetched( await send( - _baseUri.collection(type), + _uriFor(CollectionTarget(type)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -170,7 +168,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => CollectionFetched( await send( - _baseUri.related(type, id, relationship), + _uriFor(RelatedTarget(type, id, relationship)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -191,7 +189,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelationshipFetched.one( await send( - _baseUri.relationship(type, id, relationship), + _uriFor(RelationshipTarget(type, id, relationship)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -212,7 +210,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelationshipFetched.many( await send( - _baseUri.relationship(type, id, relationship), + _uriFor(RelationshipTarget(type, id, relationship)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -233,7 +231,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelatedResourceFetched( await send( - _baseUri.related(type, id, relationship), + _uriFor(RelatedTarget(type, id, relationship)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -252,7 +250,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => ResourceFetched( await send( - _baseUri.resource(type, id), + _uriFor(ResourceTarget(type, id)), Request.get() ..headers.addAll(headers) ..query.mergeAll(query), @@ -281,7 +279,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => ResourceUpdated( await send( - _baseUri.resource(type, id), + _uriFor(ResourceTarget(type, id)), Request.patch( OutboundDataDocument.resource( Resource(type, id) @@ -316,10 +314,8 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelationshipUpdated.one( await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta)), - ) + _uriFor(RelationshipTarget(type, id, relationship)), + Request.patch(OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta))) ..headers.addAll(headers) ..query.mergeAll(query), ), @@ -343,10 +339,8 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelationshipUpdated.many( await send( - _baseUri.relationship(type, id, relationship), - Request.patch( - OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)), - ) + _uriFor(RelationshipTarget(type, id, relationship)), + Request.patch(OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta))) ..headers.addAll(headers) ..query.mergeAll(query), ), @@ -366,7 +360,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Iterable query = const [], }) async => RelationshipUpdated.one( await send( - _baseUri.relationship(type, id, relationship), + _uriFor(RelationshipTarget(type, id, relationship)), Request.patch(OutboundDataDocument.one(ToOne.empty())) ..headers.addAll(headers) ..query.mergeAll(query), @@ -384,7 +378,7 @@ class RoutingClient(final UriDesign _baseUri, final Client _client) { Map> headers = const {}, Iterable query = const [], }) => send( - _baseUri.resource(type, id), + _uriFor(ResourceTarget(type, id)), Request.delete() ..headers.addAll(headers) ..query.mergeAll(query), diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index abcce90f..a51b4eb8 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -22,10 +22,8 @@ class InboundDocument( 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); @@ -34,13 +32,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()); @@ -61,11 +56,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: () => {}); @@ -81,20 +73,17 @@ 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)); + 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( @@ -109,8 +98,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]. diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart index ea5c4717..cfd9e6e7 100644 --- a/lib/src/document/json_encodable.dart +++ b/lib/src/document/json_encodable.dart @@ -1,4 +1,4 @@ -abstract interface class JsonEncodable { +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 a3ff917f..31c798b4 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -13,6 +13,5 @@ class Link( String toString() => uri.toString(); @override - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + 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 8f8466e7..ef901a05 100644 --- a/lib/src/document/new_identifier.dart +++ b/lib/src/document/new_identifier.dart @@ -1,7 +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 implements JsonEncodable { +sealed class NewIdentifier() implements JsonEncodable { /// Resource type. String get type; @@ -34,11 +34,7 @@ class Identifier( 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( @@ -57,11 +53,7 @@ class LocalIdentifier( 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 30ac547d..194be22b 100644 --- a/lib/src/document/new_relationship.dart +++ b/lib/src/document/new_relationship.dart @@ -4,9 +4,7 @@ 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 - implements JsonEncodable { +class NewRelationship() with IterableMixin implements JsonEncodable { final links = {}; final meta = {}; diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index 6d39ba07..e8996094 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -51,9 +51,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) { @@ -70,14 +69,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_one.dart b/lib/src/document/new_to_one.dart index 2aa13a88..8f546ea1 100644 --- a/lib/src/document/new_to_one.dart +++ b/lib/src/document/new_to_one.dart @@ -2,7 +2,7 @@ import 'package:json_api/src/document/new_identifier.dart'; import 'package:json_api/src/document/new_relationship.dart'; class NewToOne(final NewIdentifier? identifier) extends NewRelationship { - NewToOne.empty() : this(null); + new empty() : this(null); @override Map toJson() => {'data': identifier, ...super.toJson()}; diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index 36037a33..a5b30b28 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -7,7 +7,7 @@ import 'package:json_api/src/document/to_many.dart'; import 'package:json_api/src/document/to_one.dart'; /// A sever-to-client document. -class OutboundDocument implements JsonEncodable { +class OutboundDocument() implements JsonEncodable { /// The document "meta" object. final meta = {}; @@ -16,8 +16,7 @@ class OutboundDocument implements JsonEncodable { } /// A sever-to-client document with errors. -class OutboundErrorDocument(Iterable errors) - extends OutboundDocument { +class OutboundErrorDocument(Iterable errors) extends OutboundDocument { this { this.errors.addAll(errors); } @@ -26,32 +25,28 @@ class OutboundErrorDocument(Iterable errors) 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); } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 199cd332..091ae7fe 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -4,7 +4,7 @@ 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 implements JsonEncodable { +class Relationship() with IterableMixin implements JsonEncodable { final links = {}; final meta = {}; diff --git a/lib/src/document/to_one.dart b/lib/src/document/to_one.dart index 60fbec38..5d6ee160 100644 --- a/lib/src/document/to_one.dart +++ b/lib/src/document/to_one.dart @@ -2,12 +2,11 @@ import 'package:json_api/src/document/new_identifier.dart'; import 'package:json_api/src/document/relationship.dart'; class ToOne(final Identifier? identifier) extends Relationship { - ToOne.empty() : this(null); + new empty() : this(null); @override Map toJson() => {'data': identifier, ...super.toJson()}; @override - Iterator get iterator => - identifier == null ? super.iterator : [identifier!].iterator; + Iterator get iterator => identifier == null ? super.iterator : [identifier!].iterator; } diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 225d6a2a..ab9a5128 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -20,10 +20,8 @@ class Fields([Map> fields = const {}]) /// 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(), - ), + (k, v) => + MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.expand((it) => it.split(',')).toList()), )..removeWhere((k, v) => k.isEmpty), ); @@ -33,8 +31,7 @@ class Fields([Map> fields = const {}]) /// 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 index 8be77a44..67117993 100644 --- a/lib/src/query/filter.dart +++ b/lib/src/query/filter.dart @@ -20,9 +20,8 @@ class Filter([Map parameters = const {}]) } static Filter fromUri(Uri uri) => Filter( - uri.queryParametersAll.map( - (k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last), - )..removeWhere((k, v) => k.isEmpty), + uri.queryParametersAll.map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty), ); static final _regex = RegExp(r'^filter\[(.+)\]$'); @@ -30,8 +29,7 @@ class Filter([Map parameters = const {}]) /// Converts to a map of query parameters @override - Map> toQuery() => - _.map((k, v) => MapEntry('filter[$k]', [v])); + Map> toQuery() => _.map((k, v) => MapEntry('filter[$k]', [v])); @override String? operator [](Object? key) => _[key]; diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index 8b9970c4..bcc1c47a 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -15,9 +15,8 @@ class Include([Iterable resources = const []]) _.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 _ = []; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index 28035aae..a186bf28 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -21,9 +21,8 @@ class Page([Map parameters = const {}]) } static Page fromUri(Uri uri) => Page( - uri.queryParametersAll.map( - (k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last), - )..removeWhere((k, v) => k.isEmpty), + uri.queryParametersAll.map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty), ); static final _regex = RegExp(r'^page\[(.+)\]$'); @@ -31,8 +30,7 @@ class Page([Map parameters = const {}]) /// 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 4f998de4..d97d8232 100644 --- a/lib/src/query/query.dart +++ b/lib/src/query/query.dart @@ -1,8 +1,7 @@ import 'package:json_api/src/query/query_encodable.dart'; /// Arbitrary query parameters. -class Query([Map> parameters = const {}]) - implements QueryEncodable { +class Query([Map> parameters = const {}]) implements QueryEncodable { this { mergeMap(parameters); } diff --git a/lib/src/query/query_encodable.dart b/lib/src/query/query_encodable.dart index 055ceade..33f230e3 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 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 393fb751..88981c74 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -17,9 +17,8 @@ class Sort([Iterable fields = const []]) _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 = []; @@ -36,10 +35,9 @@ class Sort([Iterable fields = const []]) Iterator get iterator => _fields.iterator; } -abstract class SortField { - static SortField parse(String queryParam) => queryParam.startsWith('-') - ? Desc(queryParam.substring(1)) - : Asc(queryParam); +abstract class SortField() { + static SortField parse(String queryParam) => + queryParam.startsWith('-') ? Desc(queryParam.substring(1)) : Asc(queryParam); String get name; diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart index 5f454be1..ec927063 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -5,64 +5,46 @@ import 'package:json_api/routing.dart'; 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. - this - : base = baseUri.path.endsWith('/') - ? baseUri - : baseUri.replace(path: '${baseUri.path}/'); + 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: '/')); /// Matches a [uri] to a [Target] object. - Target? matchTarget(Uri uri) => + @override + 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] => Target(type), + : 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, - ), + [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`. - @override - Uri resource(String type, String id) => _resolve([type, id]); - - /// 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]); - - /// 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 4f12eec4..1b2d7658 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,16 +1,16 @@ -class const Target(final String type); +sealed class Target() { + String get type; +} -class const ResourceTarget(@override final String type, final String id) - implements Target; +class const CollectionTarget(@override final String type) implements Target; -class const RelatedTarget( - @override final String type, - @override final String id, - final String relationship, -) implements ResourceTarget; +class const ResourceTarget(@override final String type, final String id) implements Target; + +class const RelatedTarget(@override final String type, final String id, final String relationship) + implements Target; class const RelationshipTarget( @override final String type, - @override final String id, + final String id, final String relationship, -) implements ResourceTarget; +) implements Target; diff --git a/lib/src/routing/target_matcher.dart b/lib/src/routing/target_matcher.dart deleted file mode 100644 index cdd15f38..00000000 --- a/lib/src/routing/target_matcher.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:json_api/src/routing/target.dart'; - -typedef TargetMatcher = Target? Function(Uri uri); diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart index 199b410f..e518e22c 100644 --- a/lib/src/routing/uri_design.dart +++ b/lib/src/routing/uri_design.dart @@ -1,9 +1,10 @@ -abstract interface 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/base_controller.dart b/lib/src/server/base_controller.dart index 78453b6c..a1452d09 100644 --- a/lib/src/server/base_controller.dart +++ b/lib/src/server/base_controller.dart @@ -44,10 +44,7 @@ abstract class BaseController() implements Controller { } @override - Future replaceRelationship( - Request request, - RelationshipTarget target, - ) { + Future replaceRelationship(Request request, RelationshipTarget target) { throw UnimplementedError(); } diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index ce2b0c2b..3b3cf4b9 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -2,12 +2,12 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller -abstract class Controller { +abstract class Controller() { /// Fetch a primary resource collection - Future fetchCollection(Request request, Target target); + Future fetchCollection(Request request, CollectionTarget target); /// Create resource - Future createResource(Request request, Target target); + Future createResource(Request request, CollectionTarget target); /// Fetch a single primary resource Future fetchResource(Request request, ResourceTarget target); @@ -25,10 +25,7 @@ abstract class Controller { Future addMany(Request request, RelationshipTarget target); /// Updates the relationship - Future replaceRelationship( - Request request, - RelationshipTarget target, - ); + Future replaceRelationship(Request request, RelationshipTarget target); /// Deletes the members from the to-many relationship Future deleteMany(Request request, RelationshipTarget target); diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart index a625d551..38220c17 100644 --- a/lib/src/server/cors_middleware.dart +++ b/lib/src/server/cors_middleware.dart @@ -10,8 +10,7 @@ final corsMiddleware = middleware( '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 ['*'], + 'Access-Control-Allow-Headers': rq.headers['Access-Control-Request-Headers'] ?? const ['*'], }), ), _ => null, diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index c7004196..4278acb4 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -20,17 +20,13 @@ Middleware errorConverter({ Future Function(Object, StackTrace)? onError, }) => middleware( onError: (error, trace, _) async => switch (error) { - MethodNotAllowed() => - await onMethodNotAllowed?.call(error) ?? methodNotAllowed(), + 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}', - ), + ErrorObject(title: 'Collection Not Found', detail: 'Type: ${error.type}'), ]), ), ResourceNotFound() => @@ -60,10 +56,6 @@ Middleware errorConverter({ NotAcceptable() => notAcceptable(), _ => await onError?.call(error, trace) ?? - internalServerError( - OutboundErrorDocument([ - ErrorObject(title: 'Internal Server Error'), - ]), - ), + internalServerError(OutboundErrorDocument([ErrorObject(title: 'Internal Server Error')])), }, ); diff --git a/lib/src/server/errors/not_acceptable.dart b/lib/src/server/errors/not_acceptable.dart index fbd94437..57034f47 100644 --- a/lib/src/server/errors/not_acceptable.dart +++ b/lib/src/server/errors/not_acceptable.dart @@ -1 +1 @@ -class NotAcceptable implements Exception {} +class NotAcceptable() implements Exception {} diff --git a/lib/src/server/errors/relationship_not_found.dart b/lib/src/server/errors/relationship_not_found.dart index 3a4a37c9..baa7aa9c 100644 --- a/lib/src/server/errors/relationship_not_found.dart +++ b/lib/src/server/errors/relationship_not_found.dart @@ -1,6 +1,3 @@ /// A relationship is not found on the server. -class RelationshipNotFound( - final String type, - final String id, - final String relationship, -) implements Exception; +class RelationshipNotFound(final String type, final String id, final String relationship) + 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/mapping_controller.dart b/lib/src/server/mapping_controller.dart index f2ccce3a..969dc2ed 100644 --- a/lib/src/server/mapping_controller.dart +++ b/lib/src/server/mapping_controller.dart @@ -12,7 +12,7 @@ class MappingController() implements Controller { _controller(target.type).addMany(request, target); @override - Future createResource(Request request, Target target) => + Future createResource(Request request, CollectionTarget target) => _controller(target.type).createResource(request, target); @override @@ -24,7 +24,7 @@ class MappingController() implements Controller { _controller(target.type).deleteResource(request, target); @override - Future fetchCollection(Request request, Target target) => + Future fetchCollection(Request request, CollectionTarget target) => _controller(target.type).fetchCollection(request, target); @override @@ -40,15 +40,12 @@ class MappingController() implements Controller { _controller(target.type).fetchResource(request, target); @override - Future replaceRelationship( - Request request, - RelationshipTarget target, - ) => _controller(target.type).replaceRelationship(request, target); + Future replaceRelationship(Request request, RelationshipTarget target) => + _controller(target.type).replaceRelationship(request, target); @override Future updateResource(Request request, ResourceTarget target) => _controller(target.type).updateResource(request, target); - Controller _controller(String type) => - map[type] ?? (throw CollectionNotFound(type)); + Controller _controller(String type) => map[type] ?? (throw CollectionNotFound(type)); } diff --git a/lib/src/server/request_validator_middleware.dart b/lib/src/server/request_validator_middleware.dart index 25249feb..596897cf 100644 --- a/lib/src/server/request_validator_middleware.dart +++ b/lib/src/server/request_validator_middleware.dart @@ -23,5 +23,4 @@ final requestValidatorMiddleware = middleware( ); bool _isInvalid(MediaType mt) => - mt.mimeType == mediaType && - mt.parameters.isNotEmpty; // TODO: check for ext and profile + 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 e7a6a82a..eaec410b 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -7,23 +7,19 @@ import 'package:json_api/src/media_type.dart'; Response response(int statusCode, {OutboundDocument? document}) => Response( statusCode, - document != null - ? Body.json(document, toEncodable: toJsonEncodable) - : Body(), + document != null ? Body.json(document, toEncodable: toJsonEncodable) : Body(), Headers(), ) ..headers.addAll({ if (document != null) 'Content-Type': [mediaType], }); -Response ok(OutboundDocument document) => - response(StatusCode.ok, document: document); +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); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 27f9ae95..d59db0af 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -23,7 +23,7 @@ Handler router(Controller controller, TargetMatcher matchTarget) => 'delete' => controller.deleteResource(request, target), _ => throw MethodNotAllowed(request.method), }, - Target target => switch (request.method) { + CollectionTarget target => switch (request.method) { 'get' => controller.fetchCollection(request, target), 'post' => controller.createResource(request, target), _ => throw MethodNotAllowed(request.method), diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index d897c8fb..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,14 +21,8 @@ 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; + 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'}, @@ -45,9 +39,7 @@ void main() { attributes: {'text': 'Secret comment'}, one: {'author': bob.toIdentifier()}, )).resource; - await client.addMany(post.type, post.id, 'comments', [ - comment.toIdentifier(), - ]); + await client.addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); }); test('Fetch a complex resource', () async { @@ -70,10 +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'); }); @@ -85,11 +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'); }); @@ -102,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'); }); }); @@ -137,12 +120,7 @@ void main() { }); test('Replace a to-one relationship', () async { - await client.replaceToOne( - post.type, - post.id, - 'author', - bob.toIdentifier(), - ); + await client.replaceToOne(post.type, post.id, 'author', bob.toIdentifier()); await client .fetchResource( post.type, @@ -163,18 +141,14 @@ void main() { }); 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.replaceToMany(post.type, post.id, 'comments', [secretComment.toIdentifier()]); await client .fetchResource( post.type, @@ -185,19 +159,11 @@ void main() { ) .then((r) { expect( - r.resource - .many('comments')! - .findIn(r.included) - .single - .attributes['text'], + r.resource.many('comments')!.findIn(r.included).single.attributes['text'], 'Secret comment', ); expect( - r.resource - .many('comments')! - .findIn(r.included) - .single - .attributes['text'], + r.resource.many('comments')!.findIn(r.included).single.attributes['text'], 'Secret comment', ); }); @@ -229,13 +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 cc11f42e..6b1b3641 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -17,10 +17,7 @@ void main() { () => 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/1/relationships/author'), Request('head')), ]; for (final action in actions) { final response = await action(); @@ -29,12 +26,7 @@ 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(), - ), + http.Request('get', Uri.parse('/a/long/prefix/'), http.Body(), http.Headers()), ); expect(r.statusCode, 400); }); diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index a6f4f674..864fc3e8 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -9,26 +9,19 @@ 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) { - 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', attributes: {'title': 'Hello world'}).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 server using local id', () async { @@ -41,27 +34,20 @@ void main() { ) .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'); - expect( - r.resource.links['self'].toString(), - '/posts/${r.resource.id}', - ); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); }); }); test('Resource id assigned on the client', () async { - await client - .create('posts', '12345', attributes: {'title': 'Hello world'}) - .then((r) { - expect(r.rawResponse.httpResponse.statusCode, 204); - expect(r.resource, isNull); - expect(r.rawResponse.httpResponse.headers['location'], isNull); - }); + 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 823d7889..534868fa 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -12,7 +12,7 @@ void main() { final channel = spawnHybridUri('hybrid_server.dart'); final serverUrl = await channel.stream.first; client = RoutingClient( - StandardUriDesign(Uri.parse(serverUrl.toString())), + StandardUriDesign(Uri.parse(serverUrl.toString())).uri, Client(oneOffHandler), ); }); diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index 7f00721e..0ba53bcd 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -12,11 +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'); }); @@ -31,10 +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/vm_test.dart b/test/e2e/vm_test.dart index b9032ad8..89ed6d47 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -16,9 +16,7 @@ void main() { server = JsonApiServer(testHandler(), port: 8001); await server.start(); client = RoutingClient( - StandardUriDesign( - Uri(scheme: 'http', host: server.host, port: server.port), - ), + StandardUriDesign(Uri(scheme: 'http', host: server.host, port: server.port)).uri, Client(oneOffHandler), ); }); diff --git a/test/test_handler.dart b/test/test_handler.dart index af30ae19..72f116de 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -14,10 +14,7 @@ Handler testHandler({ .add(requestValidatorMiddleware) .add(errorConverter()) .call( - router( - RepositoryController(InMemoryRepo(types), _nextId), - StandardUriDesign.pathOnly.matchTarget, - ), + router(RepositoryController(InMemoryRepo(types), _nextId), StandardUriDesign.pathOnly.target), ); String _nextId() => (_counter++).toString(); diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 3755f10e..e1748375 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -12,10 +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 { @@ -97,11 +94,7 @@ 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.uri.path, '/people/1/articles'); @@ -203,11 +196,7 @@ 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')); @@ -255,11 +244,7 @@ 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')); @@ -276,10 +261,7 @@ void main() { 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.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], }); @@ -316,10 +298,7 @@ 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.uri.toString(), '/articles'); @@ -350,10 +329,7 @@ void main() { }, ); 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.uri.toString(), '/articles'); @@ -573,10 +549,7 @@ void main() { ); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/author', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -600,10 +573,7 @@ void main() { ); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/author', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -618,12 +588,7 @@ void main() { 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); @@ -634,12 +599,7 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.many(); expect( - () => client.replaceToOne( - 'articles', - '1', - 'author', - Identifier('people', '42'), - ), + () => client.replaceToOne('articles', '1', 'author', Identifier('people', '42')), throwsFormatException, ); }); @@ -652,17 +612,12 @@ void main() { 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.uri.toString(), '/articles/1/relationships/author'); expect(mockHandler.request.headers, { 'Accept': ['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 { @@ -678,18 +633,13 @@ void main() { 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.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 mockHandler.request.body.decode(utf8)), { - 'data': null, - }); + expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {'data': null}); }); test('Throws RequestFailure', () async { @@ -705,10 +655,7 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.many(); - expect( - () => client.deleteToOne('articles', '1', 'author'), - throwsFormatException, - ); + expect(() => client.deleteToOne('articles', '1', 'author'), throwsFormatException); }); }); @@ -720,10 +667,7 @@ void main() { ]); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('delete')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/tags', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -749,10 +693,7 @@ void main() { ); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('delete')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/tags', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -769,9 +710,7 @@ void main() { 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); @@ -782,9 +721,7 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => client.deleteFromMany('articles', '1', 'tags', [ - Identifier('tags', '1'), - ]), + () => client.deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException, ); }); @@ -798,10 +735,7 @@ void main() { ]); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/tags', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -827,10 +761,7 @@ void main() { ); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('patch')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/tags', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -847,9 +778,7 @@ void main() { 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); @@ -860,9 +789,7 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => client.replaceToMany('articles', '1', 'tags', [ - Identifier('tags', '1'), - ]), + () => client.replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException, ); }); @@ -871,15 +798,10 @@ void main() { 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.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -905,10 +827,7 @@ void main() { ); expect(response.relationship, isA()); expect(mockHandler.request.method, equals('post')); - expect( - mockHandler.request.uri.toString(), - '/articles/1/relationships/tags', - ); + expect(mockHandler.request.uri.toString(), '/articles/1/relationships/tags'); expect(mockHandler.request.headers, { 'Accept': ['application/vnd.api+json'], 'Content-Type': ['application/vnd.api+json'], @@ -925,9 +844,7 @@ void main() { 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); @@ -939,8 +856,7 @@ void main() { test('Throws FormatException', () async { mockHandler.response = mock.one(); expect( - () => - client.addMany('articles', '1', 'tags', [Identifier('tags', '1')]), + () => 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/response.dart b/test/unit/client/response.dart index 13404ee9..17fb8cd1 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -55,11 +55,7 @@ Response collectionFull() => Response( { 'type': 'people', 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb', - }, + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, 'links': {'self': 'http://example.com/people/9'}, }, { @@ -108,11 +104,7 @@ Response primaryResource() => Response( { 'type': 'people', 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb', - }, + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, 'links': {'self': 'http://example.com/people/9'}, }, { @@ -155,21 +147,14 @@ Response relatedResourceNull() => Response( Response one() => Response( 200, Body.json({ - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author', - }, + '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', - }, + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, 'links': {'self': 'http://example.com/people/9'}, }, { @@ -202,21 +187,14 @@ Response one() => Response( Response oneEmpty() => Response( 200, Body.json({ - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author', - }, + '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', - }, + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, 'links': {'self': 'http://example.com/people/9'}, }, { @@ -249,10 +227,7 @@ Response oneEmpty() => Response( Response many() => Response( 200, Body.json({ - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags', - }, + 'links': {'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags'}, 'meta': {'hello': 'world'}, 'data': [ {'type': 'tags', 'id': '12'}, diff --git a/test/unit/client/typed_responses_test.dart b/test/unit/client/typed_responses_test.dart index 5abaa38d..e9565ebc 100644 --- a/test/unit/client/typed_responses_test.dart +++ b/test/unit/client/typed_responses_test.dart @@ -14,26 +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 17ce485d..68b003a6 100644 --- a/test/unit/document/error_object_test.dart +++ b/test/unit/document/error_object_test.dart @@ -18,10 +18,7 @@ void main() { 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, diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 40164885..9d94efe0 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -66,13 +66,7 @@ void main() { test('can parse the standard example', () { final doc = InboundDocument(payload.example); expect( - doc - .dataAsCollection() - .first - .relationships['author']! - .links['self']! - .uri - .toString(), + doc.dataAsCollection().first.relationships['author']!.links['self']!.uri.toString(), 'http://example.com/articles/1/relationships/author', ); expect(doc.included().first.attributes['firstName'], 'Dan'); @@ -118,10 +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); }); @@ -131,10 +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); }); @@ -143,10 +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); }); @@ -156,10 +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); }); @@ -168,10 +150,7 @@ 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); }); @@ -180,22 +159,13 @@ void main() { () => InboundDocument(payload.manyEmpty).dataAsResourceOrNull(), throwsFormatException, ); - expect( - () => InboundDocument(payload.newResource).dataAsResource(), - 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({}).dataAsResourceOrNull(), throwsFormatException); + expect(() => InboundDocument({'data': 42}).asToMany(), throwsFormatException); expect( () => InboundDocument({ 'links': {'self': 42}, @@ -205,30 +175,20 @@ void main() { }); 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(), + () => InboundDocument(payload.newResourceInvalidRelationship).dataAsNewResource(), throwsFormatException, ); }); test('throws on incomplete new relationship', () { expect( - () => - InboundDocument(payload.newResourceIncompleteRelationship) - .dataAsNewResource(), + () => InboundDocument(payload.newResourceIncompleteRelationship).dataAsNewResource(), throwsFormatException, ); }); diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart index 704c5dfd..7c52dd57 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -7,18 +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); }); @@ -31,19 +27,15 @@ 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') ..meta['foo'] = [42] ..attributes['color'] = 'green' - ..relationships['one'] = (NewToOne( - Identifier('rel', '1')..meta['rel'] = 1, - )..meta['one'] = 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) diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index d66b93aa..ad3420cb 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -22,10 +22,7 @@ void main() { }); test('full', () { expect( - toObject( - OutboundErrorDocument([ErrorObject(detail: 'Some issue')]) - ..meta['foo'] = 42, - ), + toObject(OutboundErrorDocument([ErrorObject(detail: 'Some issue')])..meta['foo'] = 42), { 'errors': [ {'detail': 'Some issue'}, @@ -94,9 +91,7 @@ void main() { group('One', () { test('minimal', () { - expect(toObject(OutboundDataDocument.one(ToOne.empty())), { - 'data': null, - }); + expect(toObject(OutboundDataDocument.one(ToOne.empty())), {'data': null}); }); test('full', () { expect( diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart index 3c6a2720..1e60712b 100644 --- a/test/unit/document/payload.dart +++ b/test/unit/document/payload.dart @@ -35,11 +35,7 @@ final example = { { 'type': 'people', 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb', - }, + 'attributes': {'firstName': 'Dan', 'lastName': 'Gebhardt', 'twitter': 'dgeb'}, 'links': {'self': 'http://example.com/people/9'}, }, { @@ -85,10 +81,7 @@ final newResource = { }; 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'}, @@ -96,26 +89,17 @@ final many = { }; final manyEmpty = { - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags', - }, + 'links': {'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags'}, 'data': [], }; final one = { - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author', - }, + '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', - }, + 'links': {'self': '/articles/1/relationships/author', 'related': '/articles/1/author'}, 'data': null, }; diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index d9a96973..475ea375 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -16,12 +16,10 @@ void main() { 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) + ..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({ diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index 7129d56a..2520a4ba 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -18,9 +18,7 @@ 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'])); }); diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index 0a21e2b4..7a340dc7 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -5,11 +5,11 @@ void main() { test('uri generation', () { final d = StandardUriDesign.pathOnly; expect(d.base.path, equals('/')); - expect(d.collection('books').toString(), '/books'); - expect(d.resource('books', '42').toString(), '/books/42'); - expect(d.related('books', '42', 'author').toString(), '/books/42/author'); + 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.relationship('books', '42', 'author').toString(), + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), '/books/42/relationships/author', ); }); @@ -17,17 +17,17 @@ void main() { test('uri generation with base, trailing slash', () { final d = StandardUriDesign(Uri.parse('https://example.com/api/')); expect(d.base.path, equals('/api/')); - expect(d.collection('books').toString(), 'https://example.com/api/books'); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/api/books'); expect( - d.resource('books', '42').toString(), + d.uri(const ResourceTarget('books', '42')).toString(), 'https://example.com/api/books/42', ); expect( - d.related('books', '42', 'author').toString(), + d.uri(const RelatedTarget('books', '42', 'author')).toString(), 'https://example.com/api/books/42/author', ); expect( - d.relationship('books', '42', 'author').toString(), + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), 'https://example.com/api/books/42/relationships/author', ); }); @@ -35,208 +35,156 @@ void main() { 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.collection('books').toString(), 'https://example.com/api/books'); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/api/books'); expect( - d.resource('books', '42').toString(), + d.uri(const ResourceTarget('books', '42')).toString(), 'https://example.com/api/books/42', ); expect( - d.related('books', '42', 'author').toString(), + d.uri(const RelatedTarget('books', '42', 'author')).toString(), 'https://example.com/api/books/42/author', ); expect( - d.relationship('books', '42', 'author').toString(), + 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 d = StandardUriDesign(Uri.parse('https://example.com:8080')); - expect(d.collection('books').toString(), 'https://example.com:8080/books'); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com:8080/books'); expect( - d.resource('books', '42').toString(), + d.uri(const ResourceTarget('books', '42')).toString(), 'https://example.com:8080/books/42', ); expect( - d.related('books', '42', 'author').toString(), + d.uri(const RelatedTarget('books', '42', 'author')).toString(), 'https://example.com:8080/books/42/author', ); expect( - d.relationship('books', '42', 'author').toString(), + d.uri(const RelationshipTarget('books', '42', 'author')).toString(), 'https://example.com:8080/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.collection('books').toString(), 'https://example.com/foo/books'); + expect(d.uri(const CollectionTarget('books')).toString(), 'https://example.com/foo/books'); expect( - d.resource('books', '42').toString(), + d.uri(const ResourceTarget('books', '42')).toString(), 'https://example.com/foo/books/42', ); expect( - d.related('books', '42', 'author').toString(), + d.uri(const RelatedTarget('books', '42', 'author')).toString(), 'https://example.com/foo/books/42/author', ); expect( - d.relationship('books', '42', 'author').toString(), + 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.matchTarget(Uri.parse('/books')), isA()); - expect(d.matchTarget(Uri.parse('/books/42')), isA()); - expect( - d.matchTarget(Uri.parse('/books/42/authors')), - isA(), - ); - expect( - d.matchTarget(Uri.parse('/books/42/relationships/authors')), - isA(), - ); - expect(d.matchTarget(Uri.parse('/a/b/c/d')), isNull); + 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.matchTarget(Uri.parse('https://example.com/books')), - isA(), - ); - expect( - d.matchTarget(Uri.parse('https://example.com/books/42')), - isA(), - ); - expect( - d.matchTarget(Uri.parse('https://example.com/books/42/authors')), - isA(), - ); - expect( - d.matchTarget( - Uri.parse('https://example.com/books/42/relationships/authors'), - ), + d.target(Uri.parse('https://example.com/books/42/relationships/authors')), isA(), ); - expect(d.matchTarget(Uri.parse('https://example.com/a/b/c/d')), isNull); + 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.matchTarget(Uri.parse('https://example.com:8080/books')), - isA(), - ); - expect( - d.matchTarget(Uri.parse('https://example.com:8080/books/42')), - isA(), - ); - expect( - d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')), + d.target(Uri.parse('https://example.com:8080/books/42/authors')), isA(), ); expect( - d.matchTarget( - Uri.parse('https://example.com:8080/books/42/relationships/authors'), - ), + d.target(Uri.parse('https://example.com:8080/books/42/relationships/authors')), isA(), ); - expect( - d.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), - isNull, - ); - expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); - expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); + 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.matchTarget(Uri.parse('https://example.com:8080/api/books')), - isA().having((it) => it.type, 'type', equals('books')), + d.target(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), ); expect( - d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')), + 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.matchTarget( - Uri.parse('https://example.com:8080/api/books/42/authors'), - ), + 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.matchTarget( - Uri.parse( - 'https://example.com:8080/api/books/42/relationships/authors', - ), - ), + 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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), - isNull, - ); - expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); - expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); - expect( - d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), - isNull, - ); + 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.matchTarget(Uri.parse('https://example.com:8080/api/books')), - isA().having((it) => it.type, 'type', equals('books')), + d.target(Uri.parse('https://example.com:8080/api/books')), + isA().having((it) => it.type, 'type', equals('books')), ); expect( - d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')), + 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.matchTarget( - Uri.parse('https://example.com:8080/api/books/42/authors'), - ), + 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.matchTarget( - Uri.parse( - 'https://example.com:8080/api/books/42/relationships/authors', - ), - ), + 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.matchTarget(Uri.parse('https://example.com:8080/a/b/c/d')), - isNull, - ); - expect(d.matchTarget(Uri.parse('http://example.com:8080/books')), isNull); - expect(d.matchTarget(Uri.parse('https://foo.net:8080/books')), isNull); - expect( - d.matchTarget(Uri.parse('https://example.com:8080/foo/books')), - isNull, - ); + 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/server/base_controller_test.dart b/test/unit/server/base_controller_test.dart index e6dfe6a0..0293db98 100644 --- a/test/unit/server/base_controller_test.dart +++ b/test/unit/server/base_controller_test.dart @@ -7,7 +7,7 @@ void main() { group('BaseController', () { final controller = _Controller(); final request = Request('get', Uri(), Body(), Headers()); - const target = Target('articles'); + const target = CollectionTarget('articles'); const resourceTarget = ResourceTarget('articles', '1'); const relationshipTarget = RelationshipTarget('articles', '1', 'author'); const relatedTarget = RelatedTarget('articles', '1', 'author'); @@ -33,4 +33,4 @@ void main() { }); } -final class _Controller extends BaseController {} +final class _Controller() extends BaseController {} diff --git a/test/unit/server/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart index afacaccc..9cef0912 100644 --- a/test/unit/server/cors_middleware_test.dart +++ b/test/unit/server/cors_middleware_test.dart @@ -61,14 +61,8 @@ void main() { 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 4e025f40..bef037cb 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -22,15 +22,11 @@ void main() { expect(r.statusCode, equals(StatusCode.notFound)); }); test('can catch ResourceNotFound', () async { - final r = await converter((_) => throw ResourceNotFound('foo', 'bar'))( - get, - ); + 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); + final r = await converter((_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get); expect(r.statusCode, equals(StatusCode.notFound)); }); test('can catch UnsupportedMediaType', () async { @@ -69,15 +65,11 @@ void main() { expect(r.statusCode, equals(552)); }); test('can catch ResourceNotFound', () async { - final r = await converter((_) => throw ResourceNotFound('foo', 'bar'))( - get, - ); + 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); + final r = await converter((_) => throw RelationshipNotFound('foo', 'bar', 'baz'))(get); expect(r.statusCode, equals(554)); }); test('can catch any other error', () async { diff --git a/test/unit/server/mapping_controller_test.dart b/test/unit/server/mapping_controller_test.dart index 2d994f8e..5a497cdd 100644 --- a/test/unit/server/mapping_controller_test.dart +++ b/test/unit/server/mapping_controller_test.dart @@ -6,7 +6,7 @@ import 'package:test/test.dart'; void main() { group('MappingController', () { final request = Request('get', Uri(), Body(), Headers()); - const target = Target('articles'); + const target = CollectionTarget('articles'); const resourceTarget = ResourceTarget('articles', '1'); const relationshipTarget = RelationshipTarget('articles', '1', 'author'); const relatedTarget = RelatedTarget('articles', '1', 'author'); @@ -48,53 +48,45 @@ void main() { test('throws CollectionNotFound when target type is not mapped', () { expect( - () => controller.fetchCollection(request, const Target('people')), + () => controller.fetchCollection(request, const CollectionTarget('people')), throwsA(isA()), ); }); }); } -final class _Controller implements Controller { +final class _Controller() implements Controller { final calls = []; @override - Future addMany(Request request, RelationshipTarget target) => - _call('addMany'); + Future addMany(Request request, RelationshipTarget target) => _call('addMany'); @override - Future createResource(Request request, Target target) => - _call('createResource'); + Future createResource(Request request, Target target) => _call('createResource'); @override - Future deleteMany(Request request, RelationshipTarget target) => - _call('deleteMany'); + Future deleteMany(Request request, RelationshipTarget target) => _call('deleteMany'); @override Future deleteResource(Request request, ResourceTarget target) => _call('deleteResource'); @override - Future fetchCollection(Request request, Target target) => - _call('fetchCollection'); + Future fetchCollection(Request request, Target target) => _call('fetchCollection'); @override - Future fetchRelated(Request request, RelatedTarget target) => - _call('fetchRelated'); + Future fetchRelated(Request request, RelatedTarget target) => _call('fetchRelated'); @override Future fetchRelationship(Request rq, RelationshipTarget target) => _call('fetchRelationship'); @override - Future fetchResource(Request request, ResourceTarget target) => - _call('fetchResource'); + Future fetchResource(Request request, ResourceTarget target) => _call('fetchResource'); @override - Future replaceRelationship( - Request request, - RelationshipTarget target, - ) => _call('replaceRelationship'); + Future replaceRelationship(Request request, RelationshipTarget target) => + _call('replaceRelationship'); @override Future updateResource(Request request, ResourceTarget target) => diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart index 54f229bf..520f6664 100644 --- a/test/unit/server/response_test.dart +++ b/test/unit/server/response_test.dart @@ -6,10 +6,7 @@ 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), - ); + final r = response(200, document: OutboundDocument()..meta['date'] = DateTime(2021)); expect( await r.body.decodeJson(), equals({ From 721452dd737bcdc5fb580896889904676cc6c3d2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 22:32:32 -0700 Subject: [PATCH 28/43] Format --- example/server/json_api_server.dart | 10 +++++++--- example/server/relationship_node.dart | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart index 3864b91d..430d413b 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -3,11 +3,15 @@ import 'dart:io'; import 'package:http_interop/http_interop.dart'; import 'package:http_interop_io/http_interop_io.dart'; -class JsonApiServer(final Handler _handler, { +class JsonApiServer( + final Handler _handler, { + /// Server host name - final String host = 'localhost', + final String host = 'localhost', + /// Server port - final int port = 8080}) { + final int port = 8080, +}) { HttpServer? _server; /// Server uri diff --git a/example/server/relationship_node.dart b/example/server/relationship_node.dart index 9f4ccb31..2a9c5551 100644 --- a/example/server/relationship_node.dart +++ b/example/server/relationship_node.dart @@ -1,7 +1,8 @@ /// Relationship tree node class RelationshipNode( /// The name of the relationship - final String name) { + final String name, +) { static Iterable forest(Iterable relationships) { final root = RelationshipNode(''); relationships.map((rel) => rel.trim().split('.').map((e) => e.trim())).forEach(root.add); From 53681da46d8cfcddd05f71c2f273d9d818799bd5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 22:36:04 -0700 Subject: [PATCH 29/43] Format --- lib/src/server/controller.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 3b3cf4b9..c22a91d1 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -2,7 +2,7 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller -abstract class Controller() { +abstract interface class Controller() { /// Fetch a primary resource collection Future fetchCollection(Request request, CollectionTarget target); From 4b4550be2a368da024afdb531c33a36c1b9a21a9 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 22:46:24 -0700 Subject: [PATCH 30/43] Format --- analysis_options.yaml | 1 - lib/src/document/json_encodable.dart | 2 +- lib/src/document/new_identifier.dart | 2 +- lib/src/query/query_encodable.dart | 2 +- lib/src/query/sort.dart | 2 +- lib/src/routing/target.dart | 2 +- lib/src/routing/uri_design.dart | 2 +- lib/src/server/controller.dart | 2 +- 8 files changed, 7 insertions(+), 8 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 60881936..8fff8b58 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,7 +4,6 @@ formatter: linter: rules: - - use_primary_constructors - use_declaring_parameters - unnecessary_primary_constructor_body - unnecessary_type_name_in_constructor diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart index cfd9e6e7..ea5c4717 100644 --- a/lib/src/document/json_encodable.dart +++ b/lib/src/document/json_encodable.dart @@ -1,4 +1,4 @@ -abstract interface class JsonEncodable() { +abstract interface class JsonEncodable { /// Converts the object to a JSON-encodable object. Object? toJson(); } diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart index ef901a05..6742f4d1 100644 --- a/lib/src/document/new_identifier.dart +++ b/lib/src/document/new_identifier.dart @@ -1,7 +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() implements JsonEncodable { +sealed class NewIdentifier implements JsonEncodable { /// Resource type. String get type; diff --git a/lib/src/query/query_encodable.dart b/lib/src/query/query_encodable.dart index 33f230e3..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 88981c74..01cde6cf 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -35,7 +35,7 @@ class Sort([Iterable fields = const []]) Iterator get iterator => _fields.iterator; } -abstract class SortField() { +abstract interface class SortField { static SortField parse(String queryParam) => queryParam.startsWith('-') ? Desc(queryParam.substring(1)) : Asc(queryParam); diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 1b2d7658..040bbea6 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,4 +1,4 @@ -sealed class Target() { +sealed class Target { String get type; } diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart index e518e22c..be88160a 100644 --- a/lib/src/routing/uri_design.dart +++ b/lib/src/routing/uri_design.dart @@ -1,6 +1,6 @@ import 'package:json_api/routing.dart'; -abstract interface class UriDesign() { +abstract interface class UriDesign { Uri uri(Target target); Target? target(Uri uri); diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index c22a91d1..6789b564 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -2,7 +2,7 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller -abstract interface class Controller() { +abstract interface class Controller { /// Fetch a primary resource collection Future fetchCollection(Request request, CollectionTarget target); From 32635f10e6d1d298eaaac56733d69292592d4e82 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 22:48:31 -0700 Subject: [PATCH 31/43] Format --- lib/src/document/new_identifier.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart index 6742f4d1..0be78d8c 100644 --- a/lib/src/document/new_identifier.dart +++ b/lib/src/document/new_identifier.dart @@ -1,7 +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 implements JsonEncodable { +sealed class NewIdentifier implements JsonEncodable { /// Resource type. String get type; From 09e973bf12c58a61ea96592a8a33daf0d900033e Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 11 Oct 2026 23:00:36 -0700 Subject: [PATCH 32/43] Format --- lib/src/server/base_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/server/base_controller.dart b/lib/src/server/base_controller.dart index a1452d09..6fb85329 100644 --- a/lib/src/server/base_controller.dart +++ b/lib/src/server/base_controller.dart @@ -9,7 +9,7 @@ abstract class BaseController() implements Controller { } @override - Future createResource(Request request, Target target) { + Future createResource(Request request, CollectionTarget target) { throw UnimplementedError(); } @@ -24,7 +24,7 @@ abstract class BaseController() implements Controller { } @override - Future fetchCollection(Request request, Target target) { + Future fetchCollection(Request request, CollectionTarget target) { throw UnimplementedError(); } From 2a3bdd0fc04bb891f7ac4419ce872bedcdcd252b Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 15 Oct 2026 20:58:28 -0700 Subject: [PATCH 33/43] wip --- example/server.dart | 2 +- example/server/repository_controller.dart | 38 +++---- lib/document.dart | 1 + lib/server.dart | 1 - lib/src/client/client.dart | 2 +- lib/src/document/inbound_document.dart | 2 - lib/src/document/new_resource.dart | 2 - lib/src/document/outbound_document.dart | 8 +- .../{client => document}/payload_codec.dart | 2 +- lib/src/document/relationship.dart | 27 +++++ lib/src/document/resource.dart | 2 - lib/src/document/to_many.dart | 18 ---- lib/src/document/to_one.dart | 11 --- lib/src/server/base_controller.dart | 19 +++- lib/src/server/controller.dart | 19 +++- lib/src/server/errors/invalid_payload.dart | 1 + lib/src/server/errors/payload_missing.dart | 1 + lib/src/server/mapping_controller.dart | 51 ---------- lib/src/server/router.dart | 84 ++++++++++------ test/test_handler.dart | 13 +-- test/unit/client/payload_codec_test.dart | 2 +- test/unit/server/base_controller_test.dart | 36 ------- test/unit/server/mapping_controller_test.dart | 99 ------------------- 23 files changed, 138 insertions(+), 303 deletions(-) rename lib/src/{client => document}/payload_codec.dart (93%) create mode 100644 lib/src/server/errors/invalid_payload.dart create mode 100644 lib/src/server/errors/payload_missing.dart delete mode 100644 lib/src/server/mapping_controller.dart delete mode 100644 test/unit/server/base_controller_test.dart delete mode 100644 test/unit/server/mapping_controller_test.dart diff --git a/example/server.dart b/example/server.dart index ed18b937..def98fd1 100644 --- a/example/server.dart +++ b/example/server.dart @@ -20,7 +20,7 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(router(controller, StandardUriDesign.pathOnly.target)); + .call(router((_) => controller, StandardUriDesign.pathOnly.target)); final server = JsonApiServer(handler, host: host, port: port); diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart index 9de0c464..400f4745 100644 --- a/example/server/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -1,12 +1,8 @@ -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'; @@ -44,17 +40,15 @@ class RepositoryController(final Repository repo, final IdGenerator getId) imple } @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))); if (newResource.id != null) { return noContent(); } final ref = Reference.of(res.toIdentifier()); - var type = ref.type; - var id = ref.id; + 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( @@ -64,9 +58,8 @@ class RepositoryController(final Repository repo, final IdGenerator getId) imple } @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))); } @@ -77,18 +70,17 @@ class RepositoryController(final Repository repo, final IdGenerator getId) imple } @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(); + Future replaceRelationship( + 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); @@ -102,8 +94,7 @@ class RepositoryController(final Repository repo, final IdGenerator getId) imple } @override - Future deleteMany(Request request, RelationshipTarget target) async { - final rel = (await _decode(request)).asToMany(); + 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))); } @@ -182,9 +173,6 @@ class RepositoryController(final Repository repo, final IdGenerator getId) imple 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/document.dart b/lib/document.dart index 843207a2..fe589207 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -11,6 +11,7 @@ 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'; diff --git a/lib/server.dart b/lib/server.dart index def9fed5..64cf9356 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -13,7 +13,6 @@ 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/mapping_controller.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/router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 8cc481c7..c376b9e4 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:http_interop/extensions.dart'; import 'package:http_interop/http_interop.dart' as i; -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. diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index a51b4eb8..a5b48c06 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -8,8 +8,6 @@ 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 diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index e8996094..b810eb4b 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -5,8 +5,6 @@ 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( diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index a5b30b28..f59040ea 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -1,10 +1,4 @@ -import 'package:json_api/src/document/error_object.dart'; -import 'package:json_api/src/document/json_encodable.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() implements JsonEncodable { diff --git a/lib/src/client/payload_codec.dart b/lib/src/document/payload_codec.dart similarity index 93% rename from lib/src/client/payload_codec.dart rename to lib/src/document/payload_codec.dart index d3641e85..653c08b0 100644 --- a/lib/src/client/payload_codec.dart +++ b/lib/src/document/payload_codec.dart @@ -9,7 +9,7 @@ import 'package:json_api/src/document/to_json_encodable.dart'; /// performed in isolates if needed. 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}'); diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 091ae7fe..a65f5d72 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -17,3 +17,30 @@ class Relationship() with IterableMixin implements JsonEncodable { @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 fe7cbfb8..61127ca4 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -2,8 +2,6 @@ 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 type. diff --git a/lib/src/document/to_many.dart b/lib/src/document/to_many.dart index b6b4abd4..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(Iterable identifiers) extends Relationship { - this { - _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 5d6ee160..8b137891 100644 --- a/lib/src/document/to_one.dart +++ b/lib/src/document/to_one.dart @@ -1,12 +1 @@ -import 'package:json_api/src/document/new_identifier.dart'; -import 'package:json_api/src/document/relationship.dart'; -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/server/base_controller.dart b/lib/src/server/base_controller.dart index 6fb85329..1db22b84 100644 --- a/lib/src/server/base_controller.dart +++ b/lib/src/server/base_controller.dart @@ -1,20 +1,25 @@ import 'package:http_interop/http_interop.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; abstract class BaseController() implements Controller { @override - Future addMany(Request request, RelationshipTarget target) { + Future addMany(Request request, RelationshipTarget target, ToMany rel) { throw UnimplementedError(); } @override - Future createResource(Request request, CollectionTarget target) { + Future createResource( + Request request, + CollectionTarget target, + NewResource newResource, + ) { throw UnimplementedError(); } @override - Future deleteMany(Request request, RelationshipTarget target) { + Future deleteMany(Request request, RelationshipTarget target, ToMany rel) { throw UnimplementedError(); } @@ -44,12 +49,16 @@ abstract class BaseController() implements Controller { } @override - Future replaceRelationship(Request request, RelationshipTarget target) { + Future replaceRelationship( + Request request, + RelationshipTarget target, + Relationship rel, + ) { throw UnimplementedError(); } @override - Future updateResource(Request request, ResourceTarget target) { + Future updateResource(Request request, ResourceTarget target, Resource res) { throw UnimplementedError(); } } diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 6789b564..917391c0 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,4 +1,5 @@ import 'package:http_interop/http_interop.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; /// JSON:API controller @@ -7,13 +8,17 @@ abstract interface class Controller { Future fetchCollection(Request request, CollectionTarget target); /// Create resource - Future createResource(Request request, CollectionTarget target); + Future createResource( + Request request, + CollectionTarget target, + NewResource newResource, + ); /// Fetch a single primary resource Future fetchResource(Request request, ResourceTarget target); /// Updates a primary resource - Future updateResource(Request request, ResourceTarget target); + Future updateResource(Request request, ResourceTarget target, Resource res); /// Deletes the primary resource Future deleteResource(Request request, ResourceTarget target); @@ -22,13 +27,17 @@ abstract interface class Controller { Future fetchRelationship(Request rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany(Request request, RelationshipTarget target); + Future addMany(Request request, RelationshipTarget target, ToMany rel); /// Updates the relationship - Future replaceRelationship(Request request, RelationshipTarget target); + Future replaceRelationship( + Request request, + RelationshipTarget target, + Relationship rel, + ); /// Deletes the members from the to-many relationship - Future deleteMany(Request request, RelationshipTarget target); + Future deleteMany(Request request, RelationshipTarget target, ToMany rel); /// Fetches related resource or collection Future fetchRelated(Request request, RelatedTarget target); diff --git a/lib/src/server/errors/invalid_payload.dart b/lib/src/server/errors/invalid_payload.dart new file mode 100644 index 00000000..80dcfe9c --- /dev/null +++ b/lib/src/server/errors/invalid_payload.dart @@ -0,0 +1 @@ +class InvalidPayload() 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/mapping_controller.dart b/lib/src/server/mapping_controller.dart deleted file mode 100644 index 969dc2ed..00000000 --- a/lib/src/server/mapping_controller.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:http_interop/http_interop.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; - -/// A controller that delegates requests to other controllers based on the -/// resource type. -class MappingController() implements Controller { - final map = {}; - - @override - Future addMany(Request request, RelationshipTarget target) => - _controller(target.type).addMany(request, target); - - @override - Future createResource(Request request, CollectionTarget target) => - _controller(target.type).createResource(request, target); - - @override - Future deleteMany(Request request, RelationshipTarget target) => - _controller(target.type).deleteMany(request, target); - - @override - Future deleteResource(Request request, ResourceTarget target) => - _controller(target.type).deleteResource(request, target); - - @override - Future fetchCollection(Request request, CollectionTarget target) => - _controller(target.type).fetchCollection(request, target); - - @override - Future fetchRelated(Request request, RelatedTarget target) => - _controller(target.type).fetchRelated(request, target); - - @override - Future fetchRelationship(Request rq, RelationshipTarget target) => - _controller(target.type).fetchRelationship(rq, target); - - @override - Future fetchResource(Request request, ResourceTarget target) => - _controller(target.type).fetchResource(request, target); - - @override - Future replaceRelationship(Request request, RelationshipTarget target) => - _controller(target.type).replaceRelationship(request, target); - - @override - Future updateResource(Request request, ResourceTarget target) => - _controller(target.type).updateResource(request, target); - - Controller _controller(String type) => map[type] ?? (throw CollectionNotFound(type)); -} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index d59db0af..33306cd2 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,32 +1,58 @@ +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/unmatched_target.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/errors/invalid_payload.dart'; +import 'package:json_api/src/server/errors/payload_missing.dart'; + +typedef ControllerMapper = Controller? Function(String); + +Handler router( + ControllerMapper getController, + TargetMatcher matchTarget, { + Future Function(Request request, Target target, InboundDocument doc)? validatePayload, +}) => (request) async { + final target = matchTarget(request.uri) ?? (throw UnmatchedTarget(request.uri)); + final controller = getController(target.type) ?? (throw CollectionNotFound(target.type)); + final docOrNull = await _decode(request); + if (docOrNull != null) await validatePayload?.call(request, target, docOrNull); + InboundDocument doc() => docOrNull ?? (throw PayloadMissing()); + + return switch (target) { + RelationshipTarget target => switch (request.method) { + 'get' => controller.fetchRelationship(request, target), + 'post' => controller.addMany(request, target, doc().asToMany()), + 'patch' => controller.replaceRelationship(request, target, doc().asRelationship()), + 'delete' => controller.deleteMany(request, target, doc().asToMany()), + _ => throw MethodNotAllowed(request.method), + }, + RelatedTarget target => switch (request.method) { + 'get' => controller.fetchRelated(request, target), + _ => throw MethodNotAllowed(request.method), + }, + ResourceTarget target => switch (request.method) { + 'get' => controller.fetchResource(request, target), + 'patch' => controller.updateResource(request, target, doc().dataAsResource()), + 'delete' => controller.deleteResource(request, target), + _ => throw MethodNotAllowed(request.method), + }, + CollectionTarget target => switch (request.method) { + 'get' => controller.fetchCollection(request, target), + 'post' => controller.createResource(request, target, doc().dataAsNewResource()), + _ => throw MethodNotAllowed(request.method), + }, + }; +}; -Handler router(Controller controller, TargetMatcher matchTarget) => - (request) => switch (matchTarget(request.uri)) { - RelationshipTarget target => 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 target => switch (request.method) { - 'get' => controller.fetchRelated(request, target), - _ => throw MethodNotAllowed(request.method), - }, - ResourceTarget target => switch (request.method) { - 'get' => controller.fetchResource(request, target), - 'patch' => controller.updateResource(request, target), - 'delete' => controller.deleteResource(request, target), - _ => throw MethodNotAllowed(request.method), - }, - CollectionTarget target => switch (request.method) { - 'get' => controller.fetchCollection(request, target), - 'post' => controller.createResource(request, target), - _ => throw MethodNotAllowed(request.method), - }, - _ => throw UnmatchedTarget(request.uri), - }; +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 InvalidPayload(); + } +} diff --git a/test/test_handler.dart b/test/test_handler.dart index 72f116de..fb6ac29e 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -10,12 +10,13 @@ Handler testHandler({ Iterable types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, Function(Response response)? onResponse, -}) => corsMiddleware - .add(requestValidatorMiddleware) - .add(errorConverter()) - .call( - router(RepositoryController(InMemoryRepo(types), _nextId), StandardUriDesign.pathOnly.target), - ); +}) { + final controller = RepositoryController(InMemoryRepo(types), _nextId); + return corsMiddleware + .add(requestValidatorMiddleware) + .add(errorConverter(onError: (e, _) => throw e)) + .call(router((_) => controller, StandardUriDesign.pathOnly.target)); +} String _nextId() => (_counter++).toString(); int _counter = 0; 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/server/base_controller_test.dart b/test/unit/server/base_controller_test.dart deleted file mode 100644 index 0293db98..00000000 --- a/test/unit/server/base_controller_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -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() { - group('BaseController', () { - final controller = _Controller(); - final request = Request('get', Uri(), Body(), Headers()); - const target = CollectionTarget('articles'); - const resourceTarget = ResourceTarget('articles', '1'); - const relationshipTarget = RelationshipTarget('articles', '1', 'author'); - const relatedTarget = RelatedTarget('articles', '1', 'author'); - - test('throws UnimplementedError from default handlers', () { - final handlers = [ - () => controller.addMany(request, relationshipTarget), - () => controller.createResource(request, target), - () => controller.deleteMany(request, relationshipTarget), - () => controller.deleteResource(request, resourceTarget), - () => controller.fetchCollection(request, target), - () => controller.fetchRelated(request, relatedTarget), - () => controller.fetchRelationship(request, relationshipTarget), - () => controller.fetchResource(request, resourceTarget), - () => controller.replaceRelationship(request, relationshipTarget), - () => controller.updateResource(request, resourceTarget), - ]; - - for (final handler in handlers) { - expect(handler, throwsUnimplementedError); - } - }); - }); -} - -final class _Controller() extends BaseController {} diff --git a/test/unit/server/mapping_controller_test.dart b/test/unit/server/mapping_controller_test.dart deleted file mode 100644 index 5a497cdd..00000000 --- a/test/unit/server/mapping_controller_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -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() { - group('MappingController', () { - final request = Request('get', Uri(), Body(), Headers()); - const target = CollectionTarget('articles'); - const resourceTarget = ResourceTarget('articles', '1'); - const relationshipTarget = RelationshipTarget('articles', '1', 'author'); - const relatedTarget = RelatedTarget('articles', '1', 'author'); - - late MappingController controller; - late _Controller articles; - - setUp(() { - controller = MappingController(); - articles = _Controller(); - controller.map['articles'] = articles; - }); - - test('delegates to the controller mapped to the target type', () async { - await controller.addMany(request, relationshipTarget); - await controller.createResource(request, target); - await controller.deleteMany(request, relationshipTarget); - await controller.deleteResource(request, resourceTarget); - await controller.fetchCollection(request, target); - await controller.fetchRelated(request, relatedTarget); - await controller.fetchRelationship(request, relationshipTarget); - await controller.fetchResource(request, resourceTarget); - await controller.replaceRelationship(request, relationshipTarget); - await controller.updateResource(request, resourceTarget); - - expect(articles.calls, [ - 'addMany', - 'createResource', - 'deleteMany', - 'deleteResource', - 'fetchCollection', - 'fetchRelated', - 'fetchRelationship', - 'fetchResource', - 'replaceRelationship', - 'updateResource', - ]); - }); - - test('throws CollectionNotFound when target type is not mapped', () { - expect( - () => controller.fetchCollection(request, const CollectionTarget('people')), - throwsA(isA()), - ); - }); - }); -} - -final class _Controller() implements Controller { - final calls = []; - - @override - Future addMany(Request request, RelationshipTarget target) => _call('addMany'); - - @override - Future createResource(Request request, Target target) => _call('createResource'); - - @override - Future deleteMany(Request request, RelationshipTarget target) => _call('deleteMany'); - - @override - Future deleteResource(Request request, ResourceTarget target) => - _call('deleteResource'); - - @override - Future fetchCollection(Request request, Target target) => _call('fetchCollection'); - - @override - Future fetchRelated(Request request, RelatedTarget target) => _call('fetchRelated'); - - @override - Future fetchRelationship(Request rq, RelationshipTarget target) => - _call('fetchRelationship'); - - @override - Future fetchResource(Request request, ResourceTarget target) => _call('fetchResource'); - - @override - Future replaceRelationship(Request request, RelationshipTarget target) => - _call('replaceRelationship'); - - @override - Future updateResource(Request request, ResourceTarget target) => - _call('updateResource'); - - Future _call(String name) async { - calls.add(name); - return Response(200, Body(), Headers()); - } -} From 6c50f0a1f70ed062e510269a6e557c1ab3429578 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 15 Oct 2026 21:35:58 -0700 Subject: [PATCH 34/43] wip --- lib/server.dart | 3 +- lib/src/server/base_controller.dart | 64 ---------------------- lib/src/server/error_converter.dart | 4 ++ lib/src/server/errors/invalid_payload.dart | 1 - lib/src/server/errors/payload_invalid.dart | 1 + lib/src/server/router.dart | 4 +- test/contract/errors_test.dart | 21 +++++++ 7 files changed, 30 insertions(+), 68 deletions(-) delete mode 100644 lib/src/server/base_controller.dart delete mode 100644 lib/src/server/errors/invalid_payload.dart create mode 100644 lib/src/server/errors/payload_invalid.dart diff --git a/lib/server.dart b/lib/server.dart index 64cf9356..34244e44 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -2,13 +2,14 @@ /// WARNING: This library is in beta stage. The API is subject to change. library; -export 'package:json_api/src/server/base_controller.dart'; export 'package:json_api/src/server/controller.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/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'; diff --git a/lib/src/server/base_controller.dart b/lib/src/server/base_controller.dart deleted file mode 100644 index 1db22b84..00000000 --- a/lib/src/server/base_controller.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:http_interop/http_interop.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; - -abstract class BaseController() implements Controller { - @override - Future addMany(Request request, RelationshipTarget target, ToMany rel) { - throw UnimplementedError(); - } - - @override - Future createResource( - Request request, - CollectionTarget target, - NewResource newResource, - ) { - throw UnimplementedError(); - } - - @override - Future deleteMany(Request request, RelationshipTarget target, ToMany rel) { - throw UnimplementedError(); - } - - @override - Future deleteResource(Request request, ResourceTarget target) { - throw UnimplementedError(); - } - - @override - Future fetchCollection(Request request, CollectionTarget target) { - throw UnimplementedError(); - } - - @override - Future fetchRelated(Request request, RelatedTarget target) { - throw UnimplementedError(); - } - - @override - Future fetchRelationship(Request rq, RelationshipTarget target) { - throw UnimplementedError(); - } - - @override - Future fetchResource(Request request, ResourceTarget target) { - throw UnimplementedError(); - } - - @override - Future replaceRelationship( - Request request, - RelationshipTarget target, - Relationship rel, - ) { - throw UnimplementedError(); - } - - @override - Future updateResource(Request request, ResourceTarget target, Resource res) { - throw UnimplementedError(); - } -} diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index 4278acb4..ee29104d 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -4,6 +4,8 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/errors/collection_not_found.dart'; import 'package:json_api/src/server/errors/method_not_allowed.dart'; import 'package:json_api/src/server/errors/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/unmatched_target.dart'; @@ -53,6 +55,8 @@ Middleware errorConverter({ ]), ), UnsupportedMediaType() => unsupportedMediaType(), + PayloadMissing() => badRequest(), + PayloadInvalid() => badRequest(), NotAcceptable() => notAcceptable(), _ => await onError?.call(error, trace) ?? diff --git a/lib/src/server/errors/invalid_payload.dart b/lib/src/server/errors/invalid_payload.dart deleted file mode 100644 index 80dcfe9c..00000000 --- a/lib/src/server/errors/invalid_payload.dart +++ /dev/null @@ -1 +0,0 @@ -class InvalidPayload() 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/router.dart b/lib/src/server/router.dart index 33306cd2..f96bca58 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -5,7 +5,7 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/errors/invalid_payload.dart'; +import 'package:json_api/src/server/errors/payload_invalid.dart'; import 'package:json_api/src/server/errors/payload_missing.dart'; typedef ControllerMapper = Controller? Function(String); @@ -53,6 +53,6 @@ Future _decode(Request r) async { try { return InboundDocument(await const PayloadCodec().decode(body)); } on FormatException { - throw InvalidPayload(); + throw PayloadInvalid(); } } diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 6b1b3641..a0013b25 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; import 'package:test/test.dart'; @@ -12,6 +14,25 @@ void main() { }); group('Errors', () { + test('Bad request when payload is missing', () async { + final r = await testHandler()( + http.Request('post', Uri.parse('/posts'), http.Body(), 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, utf8), + http.Headers(), + ), + ); + expect(r.statusCode, 400); + }); + } test('Method not allowed', () async { final actions = [ () => client.send(Uri.parse('/posts'), Request('delete')), From 458ae0120cc9d9414cfd3d3f98034ec03116393c Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 15 Oct 2026 21:37:05 -0700 Subject: [PATCH 35/43] wip --- lib/src/server/router.dart | 2 -- test/contract/errors_test.dart | 7 +------ 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index f96bca58..ff8f44e9 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -5,8 +5,6 @@ import 'package:http_interop/http_interop.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/errors/payload_invalid.dart'; -import 'package:json_api/src/server/errors/payload_missing.dart'; typedef ControllerMapper = Controller? Function(String); diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index a0013b25..ea52368f 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -23,12 +23,7 @@ void main() { 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, utf8), - http.Headers(), - ), + http.Request('post', Uri.parse('/posts'), http.Body.text(payload, utf8), http.Headers()), ); expect(r.statusCode, 400); }); From e280eb5ee623a0d323eb2c8a08cd6094a871a7cc Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 15 Oct 2026 21:58:51 -0700 Subject: [PATCH 36/43] wip --- lib/server.dart | 1 + lib/src/http/status_code.dart | 1 + lib/src/server/controller.dart | 8 ++++---- lib/src/server/error_converter.dart | 2 ++ lib/src/server/response.dart | 3 +++ lib/src/server/router.dart | 7 ++++++- test/contract/errors_test.dart | 21 ++++++++++++++++++++- test/test_handler.dart | 9 ++++++++- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lib/server.dart b/lib/server.dart index 34244e44..385d8ab9 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,6 +5,7 @@ library; export 'package:json_api/src/server/controller.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'; diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart index fc723cd4..eadcacb3 100644 --- a/lib/src/http/status_code.dart +++ b/lib/src/http/status_code.dart @@ -4,6 +4,7 @@ extension type StatusCode(int value) { 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; diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 917391c0..56dabbfd 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -18,7 +18,7 @@ abstract interface class Controller { Future fetchResource(Request request, ResourceTarget target); /// Updates a primary resource - Future updateResource(Request request, ResourceTarget target, Resource res); + Future updateResource(Request request, ResourceTarget target, Resource resource); /// Deletes the primary resource Future deleteResource(Request request, ResourceTarget target); @@ -27,17 +27,17 @@ abstract interface class Controller { Future fetchRelationship(Request rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany(Request request, RelationshipTarget target, ToMany rel); + Future addMany(Request request, RelationshipTarget target, ToMany relationship); /// Updates the relationship Future replaceRelationship( Request request, RelationshipTarget target, - Relationship rel, + Relationship relationship, ); /// Deletes the members from the to-many relationship - Future deleteMany(Request request, RelationshipTarget target, ToMany rel); + Future deleteMany(Request request, RelationshipTarget target, ToMany relationship); /// Fetches related resource or collection Future fetchRelated(Request request, RelatedTarget target); diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index ee29104d..4bde216c 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -1,6 +1,7 @@ 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'; @@ -57,6 +58,7 @@ Middleware errorConverter({ UnsupportedMediaType() => unsupportedMediaType(), PayloadMissing() => badRequest(), PayloadInvalid() => badRequest(), + ClientGeneratedIdNotSupported() => forbidden(), NotAcceptable() => notAcceptable(), _ => await onError?.call(error, trace) ?? diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index eaec410b..071f348e 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -33,6 +33,9 @@ Response methodNotAllowed([OutboundErrorDocument? 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); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index ff8f44e9..717b8583 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -7,11 +7,16 @@ import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; typedef ControllerMapper = Controller? Function(String); +typedef PayloadValidator = Future Function( + Request request, + Target target, + InboundDocument doc, +); Handler router( ControllerMapper getController, TargetMatcher matchTarget, { - Future Function(Request request, Target target, InboundDocument doc)? validatePayload, + PayloadValidator? validatePayload, }) => (request) async { final target = matchTarget(request.uri) ?? (throw UnmatchedTarget(request.uri)); final controller = getController(target.type) ?? (throw CollectionNotFound(target.type)); diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index ea52368f..aaddcfe7 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; import 'package:test/test.dart'; import '../test_handler.dart'; @@ -10,10 +12,27 @@ void main() { late Client client; setUp(() async { - client = Client(testHandler()); + client = Client( + testHandler( + validatePayload: (rq, target, doc) async { + if (target is CollectionTarget && doc.dataAsNewResource().id != null) { + throw ClientGeneratedIdNotSupported(); + } + }, + ), + ); }); 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(), http.Headers()), diff --git a/test/test_handler.dart b/test/test_handler.dart index fb6ac29e..bb467f14 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -10,12 +10,19 @@ Handler testHandler({ Iterable types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, Function(Response response)? onResponse, + PayloadValidator? validatePayload, }) { final controller = RepositoryController(InMemoryRepo(types), _nextId); return corsMiddleware .add(requestValidatorMiddleware) .add(errorConverter(onError: (e, _) => throw e)) - .call(router((_) => controller, StandardUriDesign.pathOnly.target)); + .call( + router( + (_) => controller, + StandardUriDesign.pathOnly.target, + validatePayload: validatePayload, + ), + ); } String _nextId() => (_counter++).toString(); From 793422715b1221232c8d127c977b92346fb7e52a Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 15 Oct 2026 22:00:24 -0700 Subject: [PATCH 37/43] wip --- lib/src/server/errors/client_generated_id_not_supported.dart | 1 + 1 file changed, 1 insertion(+) create mode 100644 lib/src/server/errors/client_generated_id_not_supported.dart 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; From 23e6bdd015f6e1ac596febf40de550406457882b Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Oct 2026 14:47:54 -0700 Subject: [PATCH 38/43] wip --- example/server.dart | 2 +- lib/src/server/router.dart | 4 ++-- test/test_handler.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/server.dart b/example/server.dart index def98fd1..15a822d3 100644 --- a/example/server.dart +++ b/example/server.dart @@ -20,7 +20,7 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(router((_) => controller, StandardUriDesign.pathOnly.target)); + .call(router((_) => controller, (rq) => StandardUriDesign.pathOnly.target(rq.uri))); final server = JsonApiServer(handler, host: host, port: port); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 717b8583..a2ef5cd8 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -15,10 +15,10 @@ typedef PayloadValidator = Future Function( Handler router( ControllerMapper getController, - TargetMatcher matchTarget, { + Target? Function(Request) getTarget, { PayloadValidator? validatePayload, }) => (request) async { - final target = matchTarget(request.uri) ?? (throw UnmatchedTarget(request.uri)); + final target = getTarget(request) ?? (throw UnmatchedTarget(request.uri)); final controller = getController(target.type) ?? (throw CollectionNotFound(target.type)); final docOrNull = await _decode(request); if (docOrNull != null) await validatePayload?.call(request, target, docOrNull); diff --git a/test/test_handler.dart b/test/test_handler.dart index bb467f14..02c32bbb 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -19,7 +19,7 @@ Handler testHandler({ .call( router( (_) => controller, - StandardUriDesign.pathOnly.target, + (rq) => StandardUriDesign.pathOnly.target(rq.uri), validatePayload: validatePayload, ), ); From 627f44c4fcc7646891afb1b1cb1feb9acfb2e4f7 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Oct 2026 15:04:10 -0700 Subject: [PATCH 39/43] wip --- example/server.dart | 7 ++++++- lib/src/server/router.dart | 20 ++++++++++++-------- test/contract/errors_test.dart | 2 +- test/test_handler.dart | 8 ++++---- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/example/server.dart b/example/server.dart index 15a822d3..e5afbbee 100644 --- a/example/server.dart +++ b/example/server.dart @@ -20,7 +20,12 @@ Future main() async { final handler = errorConverter() .add(loggerMiddleware) - .call(router((_) => controller, (rq) => StandardUriDesign.pathOnly.target(rq.uri))); + .call( + router( + getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), + getController: (_, _) => controller, + ), + ); final server = JsonApiServer(handler, host: host, port: port); diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index a2ef5cd8..df5195f0 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -6,22 +6,26 @@ import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -typedef ControllerMapper = Controller? Function(String); -typedef PayloadValidator = Future Function( +typedef TargetResolver = Target? Function(Request); +typedef ControllerResolver = Controller? Function(Request, Target); +typedef PayloadHandler = Future Function( Request request, Target target, InboundDocument doc, ); -Handler router( - ControllerMapper getController, - Target? Function(Request) getTarget, { - PayloadValidator? validatePayload, +Handler router({ + required Target? Function(Request) getTarget, + required Controller? Function(Request, Target) getController, + PayloadHandler? onPayload, }) => (request) async { final target = getTarget(request) ?? (throw UnmatchedTarget(request.uri)); - final controller = getController(target.type) ?? (throw CollectionNotFound(target.type)); + final controller = getController(request, target) ?? (throw CollectionNotFound(target.type)); final docOrNull = await _decode(request); - if (docOrNull != null) await validatePayload?.call(request, target, docOrNull); + if (docOrNull != null) { + final response = await onPayload?.call(request, target, docOrNull); + if (response != null) return response; + } InboundDocument doc() => docOrNull ?? (throw PayloadMissing()); return switch (target) { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index aaddcfe7..ef081cfc 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -14,7 +14,7 @@ void main() { setUp(() async { client = Client( testHandler( - validatePayload: (rq, target, doc) async { + onPayload: (rq, target, doc) async { if (target is CollectionTarget && doc.dataAsNewResource().id != null) { throw ClientGeneratedIdNotSupported(); } diff --git a/test/test_handler.dart b/test/test_handler.dart index 02c32bbb..8648a209 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -10,7 +10,7 @@ Handler testHandler({ Iterable types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, Function(Response response)? onResponse, - PayloadValidator? validatePayload, + PayloadHandler? onPayload, }) { final controller = RepositoryController(InMemoryRepo(types), _nextId); return corsMiddleware @@ -18,9 +18,9 @@ Handler testHandler({ .add(errorConverter(onError: (e, _) => throw e)) .call( router( - (_) => controller, - (rq) => StandardUriDesign.pathOnly.target(rq.uri), - validatePayload: validatePayload, + getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), + getController: (_, _) => controller, + onPayload: onPayload, ), ); } From 551bb8e5b6b2348f28eb00696c868900f95de0e5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Oct 2026 18:19:04 -0700 Subject: [PATCH 40/43] wip --- lib/server.dart | 1 + lib/src/server/router.dart | 55 +++------------------------------- pubspec.yaml | 2 +- test/contract/errors_test.dart | 6 ++-- test/test_handler.dart | 2 +- 5 files changed, 10 insertions(+), 56 deletions(-) diff --git a/lib/server.dart b/lib/server.dart index 385d8ab9..28f366d5 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -17,4 +17,5 @@ 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/request_validator_middleware.dart'; export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/json_api_request.dart'; export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index df5195f0..be07fa8b 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,65 +1,18 @@ -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/server.dart'; typedef TargetResolver = Target? Function(Request); typedef ControllerResolver = Controller? Function(Request, Target); -typedef PayloadHandler = Future Function( - Request request, - Target target, - InboundDocument doc, -); +typedef PayloadHandler = Future Function(JsonApiRequest request, Controller controller); Handler router({ required Target? Function(Request) getTarget, required Controller? Function(Request, Target) getController, - PayloadHandler? onPayload, + PayloadHandler? beforeDispatch, }) => (request) async { final target = getTarget(request) ?? (throw UnmatchedTarget(request.uri)); final controller = getController(request, target) ?? (throw CollectionNotFound(target.type)); - final docOrNull = await _decode(request); - if (docOrNull != null) { - final response = await onPayload?.call(request, target, docOrNull); - if (response != null) return response; - } - InboundDocument doc() => docOrNull ?? (throw PayloadMissing()); - - return switch (target) { - RelationshipTarget target => switch (request.method) { - 'get' => controller.fetchRelationship(request, target), - 'post' => controller.addMany(request, target, doc().asToMany()), - 'patch' => controller.replaceRelationship(request, target, doc().asRelationship()), - 'delete' => controller.deleteMany(request, target, doc().asToMany()), - _ => throw MethodNotAllowed(request.method), - }, - RelatedTarget target => switch (request.method) { - 'get' => controller.fetchRelated(request, target), - _ => throw MethodNotAllowed(request.method), - }, - ResourceTarget target => switch (request.method) { - 'get' => controller.fetchResource(request, target), - 'patch' => controller.updateResource(request, target, doc().dataAsResource()), - 'delete' => controller.deleteResource(request, target), - _ => throw MethodNotAllowed(request.method), - }, - CollectionTarget target => switch (request.method) { - 'get' => controller.fetchCollection(request, target), - 'post' => controller.createResource(request, target, doc().dataAsNewResource()), - _ => throw MethodNotAllowed(request.method), - }, - }; + final resolved = await JsonApiRequest.resolve(request, target); + return (await beforeDispatch?.call(resolved, controller)) ?? await resolved.dispatch(controller); }; - -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/pubspec.yaml b/pubspec.yaml index 11a16e4a..8922aab3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dev_dependencies: check_coverage: ^0.0.4 http: ^1.2.1 http_interop_http: ^0.9.0 - http_interop_io: ^0.9.0 + http_interop_io: ^0.9.1 cider: diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index ef081cfc..43bb4d33 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:test/test.dart'; @@ -14,10 +13,11 @@ void main() { setUp(() async { client = Client( testHandler( - onPayload: (rq, target, doc) async { - if (target is CollectionTarget && doc.dataAsNewResource().id != null) { + onPayload: (request, controller) async { + if (request is CreateResourceRequest && request.newResource.id != null) { throw ClientGeneratedIdNotSupported(); } + return null; }, ), ); diff --git a/test/test_handler.dart b/test/test_handler.dart index 8648a209..2b0c406c 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -20,7 +20,7 @@ Handler testHandler({ router( getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), getController: (_, _) => controller, - onPayload: onPayload, + beforeDispatch: onPayload, ), ); } From 0abb6cbe52cd358a5e723e228e39ea873a5c5b05 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Oct 2026 18:21:36 -0700 Subject: [PATCH 41/43] wip --- lib/src/server/json_api_request.dart | 169 ++++++++++++++++++++ test/unit/server/json_api_request_test.dart | 96 +++++++++++ 2 files changed, 265 insertions(+) create mode 100644 lib/src/server/json_api_request.dart create mode 100644 test/unit/server/json_api_request_test.dart diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart new file mode 100644 index 00000000..56a343f3 --- /dev/null +++ b/lib/src/server/json_api_request.dart @@ -0,0 +1,169 @@ +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, 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 request; + + Target get target; + + /// Dispatches to the controller. + Future dispatch(Controller controller); +} + +final class const FetchCollectionRequest( + @override final Request request, + @override final CollectionTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchCollection(request, target); +} + +final class const CreateResourceRequest( + @override final Request request, + @override final CollectionTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + NewResource get newResource => doc.dataAsNewResource(); + + @override + Future dispatch(Controller controller) => + controller.createResource(request, target, newResource); +} + +final class const FetchResourceRequest( + @override final Request request, + @override final ResourceTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchResource(request, target); +} + +final class const UpdateResourceRequest( + @override final Request request, + @override final ResourceTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + Resource get resource => doc.dataAsResource(); + + @override + Future dispatch(Controller controller) => + controller.updateResource(request, target, resource); +} + +final class const DeleteResourceRequest( + @override final Request request, + @override final ResourceTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.deleteResource(request, target); +} + +final class const FetchRelatedRequest( + @override final Request request, + @override final RelatedTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchRelated(request, target); +} + +final class const FetchRelationshipRequest( + @override final Request request, + @override final RelationshipTarget target, +) implements JsonApiRequest { + @override + Future dispatch(Controller controller) => controller.fetchRelationship(request, target); +} + +final class const AddManyRequest( + @override final Request request, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + ToMany get relationship => doc.asToMany(); + + @override + Future dispatch(Controller controller) => + controller.addMany(request, target, relationship); +} + +final class const ReplaceRelationshipRequest( + @override final Request request, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + Relationship get relationship => doc.asRelationship(); + + @override + Future dispatch(Controller controller) => + controller.replaceRelationship(request, target, relationship); +} + +final class const DeleteManyRequest( + @override final Request request, + @override final RelationshipTarget target, + final InboundDocument doc, +) implements JsonApiRequest { + ToMany get relationship => doc.asToMany(); + + @override + Future dispatch(Controller controller) => + controller.deleteMany(request, 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/test/unit/server/json_api_request_test.dart b/test/unit/server/json_api_request_test.dart new file mode 100644 index 00000000..6850e547 --- /dev/null +++ b/test/unit/server/json_api_request_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +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.text('{"data":{"type":"books","attributes":{"title":"Dune"}}}', utf8), + 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, utf8), 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(), 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.text('{"data":null}', utf8), + Headers(), + ); + final request = await JsonApiRequest.resolve(httpRequest, target); + expect(request, matcher); + expect(request.request, same(httpRequest)); + expect(request.target, same(target)); + final uppercaseRequest = Request( + method.toUpperCase(), + Uri.parse('/books'), + Body.text('{"data":null}', utf8), + 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(), Headers()); + await expectLater( + JsonApiRequest.resolve(request, target), + throwsA(isA().having((error) => error.method, 'method', method)), + ); + }); + } + } +} From 1c9837722ad98b7f15ce418e68622978e5620e15 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Oct 2026 18:32:09 -0700 Subject: [PATCH 42/43] wip --- lib/server.dart | 2 +- lib/src/server/json_api_request.dart | 45 +++++++++++---------- test/unit/server/json_api_request_test.dart | 2 +- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/lib/server.dart b/lib/server.dart index 28f366d5..ff697aac 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -15,7 +15,7 @@ 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/json_api_request.dart'; export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index 56a343f3..64b04f86 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -50,7 +50,7 @@ sealed class JsonApiRequest { }; } - Request get request; + Request get httpRequest; Target get target; @@ -59,15 +59,16 @@ sealed class JsonApiRequest { } final class const FetchCollectionRequest( - @override final Request request, + @override final Request httpRequest, @override final CollectionTarget target, ) implements JsonApiRequest { @override - Future dispatch(Controller controller) => controller.fetchCollection(request, target); + Future dispatch(Controller controller) => + controller.fetchCollection(httpRequest, target); } final class const CreateResourceRequest( - @override final Request request, + @override final Request httpRequest, @override final CollectionTarget target, final InboundDocument doc, ) implements JsonApiRequest { @@ -75,19 +76,19 @@ final class const CreateResourceRequest( @override Future dispatch(Controller controller) => - controller.createResource(request, target, newResource); + controller.createResource(httpRequest, target, newResource); } final class const FetchResourceRequest( - @override final Request request, + @override final Request httpRequest, @override final ResourceTarget target, ) implements JsonApiRequest { @override - Future dispatch(Controller controller) => controller.fetchResource(request, target); + Future dispatch(Controller controller) => controller.fetchResource(httpRequest, target); } final class const UpdateResourceRequest( - @override final Request request, + @override final Request httpRequest, @override final ResourceTarget target, final InboundDocument doc, ) implements JsonApiRequest { @@ -95,35 +96,37 @@ final class const UpdateResourceRequest( @override Future dispatch(Controller controller) => - controller.updateResource(request, target, resource); + controller.updateResource(httpRequest, target, resource); } final class const DeleteResourceRequest( - @override final Request request, + @override final Request httpRequest, @override final ResourceTarget target, ) implements JsonApiRequest { @override - Future dispatch(Controller controller) => controller.deleteResource(request, target); + Future dispatch(Controller controller) => + controller.deleteResource(httpRequest, target); } final class const FetchRelatedRequest( - @override final Request request, + @override final Request httpRequest, @override final RelatedTarget target, ) implements JsonApiRequest { @override - Future dispatch(Controller controller) => controller.fetchRelated(request, target); + Future dispatch(Controller controller) => controller.fetchRelated(httpRequest, target); } final class const FetchRelationshipRequest( - @override final Request request, + @override final Request httpRequest, @override final RelationshipTarget target, ) implements JsonApiRequest { @override - Future dispatch(Controller controller) => controller.fetchRelationship(request, target); + Future dispatch(Controller controller) => + controller.fetchRelationship(httpRequest, target); } final class const AddManyRequest( - @override final Request request, + @override final Request httpRequest, @override final RelationshipTarget target, final InboundDocument doc, ) implements JsonApiRequest { @@ -131,11 +134,11 @@ final class const AddManyRequest( @override Future dispatch(Controller controller) => - controller.addMany(request, target, relationship); + controller.addMany(httpRequest, target, relationship); } final class const ReplaceRelationshipRequest( - @override final Request request, + @override final Request httpRequest, @override final RelationshipTarget target, final InboundDocument doc, ) implements JsonApiRequest { @@ -143,11 +146,11 @@ final class const ReplaceRelationshipRequest( @override Future dispatch(Controller controller) => - controller.replaceRelationship(request, target, relationship); + controller.replaceRelationship(httpRequest, target, relationship); } final class const DeleteManyRequest( - @override final Request request, + @override final Request httpRequest, @override final RelationshipTarget target, final InboundDocument doc, ) implements JsonApiRequest { @@ -155,7 +158,7 @@ final class const DeleteManyRequest( @override Future dispatch(Controller controller) => - controller.deleteMany(request, target, relationship); + controller.deleteMany(httpRequest, target, relationship); } Future _decode(Request r) async { diff --git a/test/unit/server/json_api_request_test.dart b/test/unit/server/json_api_request_test.dart index 6850e547..ef0a74bb 100644 --- a/test/unit/server/json_api_request_test.dart +++ b/test/unit/server/json_api_request_test.dart @@ -69,7 +69,7 @@ void main() { ); final request = await JsonApiRequest.resolve(httpRequest, target); expect(request, matcher); - expect(request.request, same(httpRequest)); + expect(request.httpRequest, same(httpRequest)); expect(request.target, same(target)); final uppercaseRequest = Request( method.toUpperCase(), From 81bc5df91a371e2f61023382cf5d1d4551397cf0 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 24 Oct 2026 12:09:18 -0700 Subject: [PATCH 43/43] interop v3 --- example/client.dart | 2 +- example/server.dart | 2 +- lib/src/client/client.dart | 2 +- lib/src/server/cors_middleware.dart | 2 +- lib/src/server/json_api_request.dart | 22 ++++++++++----------- lib/src/server/response.dart | 2 +- pubspec.yaml | 8 ++++---- test/contract/errors_test.dart | 12 +++++------ test/e2e/one_off_handler.dart | 2 +- test/test_handler.dart | 21 ++++++++++---------- test/unit/client/response.dart | 4 ++-- test/unit/client/typed_responses_test.dart | 2 +- test/unit/server/cors_middleware_test.dart | 10 ++++------ test/unit/server/error_converter_test.dart | 14 ++++++------- test/unit/server/json_api_request_test.dart | 19 ++++++++++-------- 15 files changed, 62 insertions(+), 62 deletions(-) diff --git a/example/client.dart b/example/client.dart index f12d1403..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. diff --git a/example/server.dart b/example/server.dart index e5afbbee..7592546a 100644 --- a/example/server.dart +++ b/example/server.dart @@ -19,7 +19,7 @@ Future main() async { final controller = RepositoryController(repo, Uuid().v4); final handler = errorConverter() - .add(loggerMiddleware) + .wrapIn(loggerMiddleware) .call( router( getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index c376b9e4..1429488c 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -16,7 +16,7 @@ class const Client(final i.Handler _handler, {final PayloadCodec _codec = const /// 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], diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart index 38220c17..de4e911f 100644 --- a/lib/src/server/cors_middleware.dart +++ b/lib/src/server/cors_middleware.dart @@ -5,7 +5,7 @@ final corsMiddleware = middleware( onRequest: (rq) async => switch (rq.method) { 'options' => Response( 204, - Body(), + Body.empty(), Headers.from({ 'Access-Control-Allow-Methods': rq.headers['Access-Control-Request-Method'] ?? diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index 64b04f86..63f5ee22 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -15,33 +15,33 @@ sealed class JsonApiRequest { /// Throws [MethodNotAllowed] for unsupported combinations. static Future resolve(Request request, Target target) async { final doc = await _decode(request); - return switch ((request.method, target)) { - ('get', CollectionTarget target) => FetchCollectionRequest(request, target), - ('post', CollectionTarget target) => CreateResourceRequest( + 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( + ('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( + ('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( + ('PATCH', RelationshipTarget target) => ReplaceRelationshipRequest( request, target, doc ?? (throw PayloadMissing()), ), - ('delete', RelationshipTarget target) => DeleteManyRequest( + ('DELETE', RelationshipTarget target) => DeleteManyRequest( request, target, doc ?? (throw PayloadMissing()), diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 071f348e..80a51c00 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -7,7 +7,7 @@ import 'package:json_api/src/media_type.dart'; Response response(int statusCode, {OutboundDocument? document}) => Response( statusCode, - document != null ? Body.json(document, toEncodable: toJsonEncodable) : Body(), + document != null ? Body.json(document, toEncodable: toJsonEncodable) : Body.empty(), Headers(), ) ..headers.addAll({ diff --git a/pubspec.yaml b/pubspec.yaml index 8922aab3..05f26253 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,8 +7,8 @@ environment: dependencies: http_parser: ^4.0.0 - http_interop: ^2.1.0 - http_interop_middleware: ^0.1.0 + http_interop: ^3.0.0 + http_interop_middleware: ^0.2.0 dev_dependencies: lints: ^6.1.0 @@ -18,8 +18,8 @@ dev_dependencies: coverage: ^1.3.0 check_coverage: ^0.0.4 http: ^1.2.1 - http_interop_http: ^0.9.0 - http_interop_io: ^0.9.1 + http_interop_http: ^0.10.0 + http_interop_io: ^0.10.0 cider: diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 43bb4d33..8a8cdbd6 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:http_interop/http_interop.dart' as http; import 'package:json_api/client.dart'; import 'package:json_api/server.dart'; @@ -35,14 +33,14 @@ void main() { }); test('Bad request when payload is missing', () async { final r = await testHandler()( - http.Request('post', Uri.parse('/posts'), http.Body(), http.Headers()), + 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, utf8), http.Headers()), + http.Request('post', Uri.parse('/posts'), http.Body.text(payload), http.Headers()), ); expect(r.statusCode, 400); }); @@ -61,7 +59,7 @@ 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()), + http.Request('get', Uri.parse('/a/long/prefix/'), http.Body.empty(), http.Headers()), ); expect(r.statusCode, 400); }); @@ -70,7 +68,7 @@ void main() { 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'], @@ -84,7 +82,7 @@ void main() { 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'], 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/test_handler.dart b/test/test_handler.dart index 2b0c406c..d876cace 100644 --- a/test/test_handler.dart +++ b/test/test_handler.dart @@ -13,16 +13,17 @@ Handler testHandler({ PayloadHandler? onPayload, }) { final controller = RepositoryController(InMemoryRepo(types), _nextId); - return corsMiddleware - .add(requestValidatorMiddleware) - .add(errorConverter(onError: (e, _) => throw e)) - .call( - router( - getTarget: (rq) => StandardUriDesign.pathOnly.target(rq.uri), - getController: (_, _) => controller, - beforeDispatch: onPayload, - ), - ); + 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(); diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart index 17fb8cd1..e507222f 100644 --- a/test/unit/client/response.dart +++ b/test/unit/client/response.dart @@ -236,7 +236,7 @@ Response many() => Response( headers, ); -Response noContent() => Response(204, Body(), Headers()); +Response noContent() => Response(204, Body.empty(), Headers()); Response error422() => Response( 422, @@ -254,4 +254,4 @@ Response error422() => Response( headers, ); -Response 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 e9565ebc..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); diff --git a/test/unit/server/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart index 9cef0912..734acf92 100644 --- a/test/unit/server/cors_middleware_test.dart +++ b/test/unit/server/cors_middleware_test.dart @@ -1,12 +1,10 @@ -import 'dart:convert'; - import 'package:http_interop/http_interop.dart'; import 'package:json_api/server.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; void main() { - Response response = Response(200, Body.text('hello', utf8), Headers()); + Response response = Response(200, Body.text('hello'), Headers()); Future handler(Request rq) async => response; @@ -15,7 +13,7 @@ void main() { final rq = Request( 'get', Uri(host: 'localhost'), - Body(), + Body.empty(), Headers.from({ 'origin': ['foo'], }), @@ -30,7 +28,7 @@ void main() { final rq = Request( 'options', Uri(host: 'localhost'), - Body(), + Body.empty(), Headers.from({ 'origin': ['foo'], }), @@ -50,7 +48,7 @@ void main() { final rq = Request( 'options', Uri(host: 'localhost'), - Body(), + Body.empty(), Headers.from({ 'origin': ['foo'], 'Access-Control-Request-Method': ['PUT', 'POST'], diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart index bef037cb..f450807c 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -5,7 +5,7 @@ import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; void main() { - final get = Request('get', Uri(), Body(), Headers()); + final get = Request('get', Uri(), Body.empty(), Headers()); group('default handlers', () { final converter = errorConverter(); @@ -45,12 +45,12 @@ void main() { group('custom handlers', () { final converter = errorConverter( - onMethodNotAllowed: (_) async => Response(550, Body(), Headers()), - onUnmatchedTarget: (_) async => Response(551, Body(), Headers()), - onCollectionNotFound: (_) async => Response(552, Body(), Headers()), - onResourceNotFound: (_) async => Response(553, Body(), Headers()), - onRelationshipNotFound: (_) async => Response(554, Body(), Headers()), - onError: (_, _) async => Response(555, Body(), Headers()), + 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); diff --git a/test/unit/server/json_api_request_test.dart b/test/unit/server/json_api_request_test.dart index ef0a74bb..bc3c8f58 100644 --- a/test/unit/server/json_api_request_test.dart +++ b/test/unit/server/json_api_request_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:http_interop/http_interop.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; @@ -15,7 +13,12 @@ void main() { final httpRequest = Request( 'post', Uri.parse('/books'), - Body.text('{"data":{"type":"books","attributes":{"title":"Dune"}}}', utf8), + Body.json({ + "data": { + "type": "books", + "attributes": {"title": "Dune"}, + }, + }), Headers(), ); final request = await JsonApiRequest.resolve(httpRequest, collection) as CreateResourceRequest; @@ -25,7 +28,7 @@ void main() { for (final body in ['{', '[]', 'null', '42']) { test('resolve rejects invalid payload $body', () async { - final request = Request('post', Uri.parse('/books'), Body.text(body, utf8), Headers()); + final request = Request('post', Uri.parse('/books'), Body.text(body), Headers()); await expectLater( JsonApiRequest.resolve(request, collection), throwsA(isA()), @@ -54,7 +57,7 @@ void main() { ('delete', relationship), ]) { test('$method ${target.runtimeType} requires a document', () async { - final request = Request(method, Uri.parse('/books'), Body(), Headers()); + final request = Request(method, Uri.parse('/books'), Body.empty(), Headers()); await expectLater(JsonApiRequest.resolve(request, target), throwsA(isA())); }); } @@ -64,7 +67,7 @@ void main() { final httpRequest = Request( method, Uri.parse('/books'), - Body.text('{"data":null}', utf8), + Body.json({'data': null}), Headers(), ); final request = await JsonApiRequest.resolve(httpRequest, target); @@ -74,7 +77,7 @@ void main() { final uppercaseRequest = Request( method.toUpperCase(), Uri.parse('/books'), - Body.text('{"data":null}', utf8), + Body.json({'data': null}), Headers(), ); expect(await JsonApiRequest.resolve(uppercaseRequest, target), matcher); @@ -85,7 +88,7 @@ void main() { 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(), Headers()); + final request = Request(method, Uri.parse('/books'), Body.empty(), Headers()); await expectLater( JsonApiRequest.resolve(request, target), throwsA(isA().having((error) => error.method, 'method', method)),