From 78251b1730bf86a3e0b007c1cbf9a889bc24ff4e Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 15 Jul 2024 18:16:43 -0700 Subject: [PATCH 01/18] 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 42e78d1..6b87ea3 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 a8173b4..550c28e 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 9a3ced9..9adb5b2 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 1cf7a97..4a9666a 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 c5b1f0f..f3f32cc 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 0000000..2b70a25 --- /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 4171d1f..fc05fd6 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 0000000..f14d236 --- /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 6300408..95ab1b8 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 0000000..86e0827 --- /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 b9cba2b..0000000 --- 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 d378e2d..9d9a8c9 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 e9177b1..608733b 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 6dc2939..761bba8 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 ca1faaf..0000000 --- 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 b2fbc4b..0000000 --- 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/18] 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 550c28e..ab13f03 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 4a9666a..d090ab0 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 fc05fd6..0000000 --- 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 f14d236..3c288aa 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 0000000..c4e97d0 --- /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 0000000..8f985e2 --- /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 608733b..da8ffe7 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/18] 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 9adb5b2..a1fee2b 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 d090ab0..198266c 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 2b70a25..e3c9eba 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 95ab1b8..7b3e91e 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 0000000..fbd9443 --- /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 ac6d685..0000000 --- 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 c4e97d0..3f2b9a8 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 16109f9..0ec571b 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 0000000..4bb17df --- /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/18] 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 a1fee2b..888d57d 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 0ec571b..226335e 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 9d9a8c9..5fa5af2 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 da8ffe7..8153faf 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/18] 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 6b87ea3..44a2b09 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 ab13f03..e673434 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 2b474f6..f26bc73 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 5fa5af2..695d853 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 8153faf..4b4288b 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 e3781eb..b534f98 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/18] 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 44a2b09..748d780 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 7b3e91e..45af2ad 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 695d853..a15a464 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/18] 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 748d780..b6d8819 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 f26bc73..cc56394 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 a15a464..7bf0add 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 b534f98..4a8c026 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/18] 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 b6d8819..198aab1 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 e3ed74b..7004a64 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 0000000..c61bc9f --- /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 6936003..b3d97d6 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 226335e..5dadd15 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 7bf0add..be428d4 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 0000000..80729ef --- /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/18] 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 04efafb..c8ab18f 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 7004a64..0d9d517 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 c61bc9f..0000000 --- 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 07ef02c..3202ea9 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 ae8c6ce..66b6c36 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 0000000..ea5c471 --- /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 6bd3480..b2bcebe 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 1c0653d..82eeea9 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 65de471..6e10797 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 3cf4be7..51f6ddf 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 78192f6..332f9e7 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 3526ffc..d5a7dcb 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 b9ca5f7..fecd2cb 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 0000000..e275bbe --- /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 902b1ff..6bb5d52 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 ae61979..d5a402b 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 5dadd15..e4fbccb 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/18] 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 c8ab18f..ab17ada 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 0000000..967c58a --- /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/18] 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 e4fbccb..c878de7 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 967c58a..6acf024 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/18] 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 d643258..f77b709 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 39a71a7..ab3cd60 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 a99c78e..889070f 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 2adb742..9a885d8 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 096a84c..1f69695 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 3b15781..d67a8ec 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 3e62829..2b69446 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 ca9b919..381ebe9 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 9a250d0..7a11e24 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 41520f5..e2af254 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 06b215b..c29991a 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 4d7a3fd..6dc67ec 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 eaf38e0..1ce542a 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 0000000..85525cd --- /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/18] 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 f77b709..08f0f60 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 ab3cd60..7b94287 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 889070f..6f40891 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 9a885d8..da77fc9 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 1f69695..c6e0159 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 d67a8ec..b83eebf 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 2b69446..7054ae4 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 381ebe9..e9a1bc8 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/18] 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 63db258..a3ab397 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 034266e..1556780 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 ab17ada..843207a 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 888d57d..fbb191b 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 335eb69..f031111 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 4f267e5..98553ed 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 198266c..e6e60e1 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 3c288aa..d174de5 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 8d4e0bb..524b309 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/18] update cors --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 524b309..2c4ceee 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/18] 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 a3ab397..176957c 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 e3c9eba..6f5e8b4 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 4bb1519..199b410 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 c878de7..b523e76 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 2c4ceee..0ac3f16 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/18] 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 6f5e8b4..fc723cd 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 80729ef..756c56f 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/18] fix --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0ac3f16..00afda3 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: