diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc3aae..176957c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Automatically encode DateTime objects as an ISO-8601 string +- The "conflict" server response + +### Changed +- URL Design matching now respects the base URL +- Allow null to be returned by error interceptors + +### Fixed +- StandardUriDesign working inconsistently, depending on the trailing slash in the path + ## [8.1.0] - 2024-08-29 ### Added - The rawResponse property to the responses of the RoutingClient @@ -13,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [8.0.0] - 2024-07-01 ### Added -- CORS middware +- CORS middleware ### Changed - Bump http\_interop to v2.0 @@ -264,6 +276,7 @@ the Document model. ### Added - Client: fetch resources, collections, related resources and relationships +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/8.1.0...HEAD [8.1.0]: https://github.com/f3ath/json-api-dart/compare/8.0.0...8.1.0 [8.0.0]: https://github.com/f3ath/json-api-dart/compare/7.0.1...8.0.0 [7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1 diff --git a/example/server.dart b/example/server.dart index a8173b4..e673434 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,14 +1,13 @@ import 'dart:io'; -import 'package:http_interop/http_interop.dart' as interop; -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:uuid/uuid.dart'; import 'server/in_memory_repo.dart'; import 'server/json_api_server.dart'; +import 'server/logger_middleware.dart'; import 'server/repository.dart'; import 'server/repository_controller.dart'; @@ -18,21 +17,11 @@ Future<void> 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(router(controller, StandardUriDesign.pathOnly.matchTarget)); + final server = JsonApiServer(handler, host: host, port: port); ProcessSignal.sigint.watch().listen((event) async { diff --git a/example/server/logger_middleware.dart b/example/server/logger_middleware.dart new file mode 100644 index 0000000..86e0827 --- /dev/null +++ b/example/server/logger_middleware.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:http_interop_middleware/http_interop_middleware.dart'; + +/// Middleware that logs all requests and responses to stderr. +final Middleware loggerMiddleware = middleware( + onRequest: (r) async { + stderr.writeln(r); + return null; + }, + onResponse: (r, _) async { + stderr.writeln(r); + return null; + }, + onError: (e, t, _) async { + stderr.writeln(e); + stderr.writeln(t); + return null; + }, +); diff --git a/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 04efafb..843207a 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,8 +1,9 @@ /// JSON:API Document model. -library document; +library; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/inbound_document.dart'; +export 'package:json_api/src/document/json_encodable.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/new_identifier.dart'; export 'package:json_api/src/document/new_relationship.dart'; @@ -12,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/lib/http.dart b/lib/http.dart index 9a3ced9..fbb191b 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,69 +1,5 @@ -import 'package:http_interop/http_interop.dart'; +/// Common HTTP utilities for JSON:API clients and servers. +/// WARNING: This library is in beta stage. The API is subject to change. +library; -class StatusCode { - const StatusCode(this.value); - - static const ok = 200; - static const created = 201; - static const accepted = 202; - static const noContent = 204; - static const badRequest = 400; - static const notFound = 404; - static const methodNotAllowed = 405; - static const unacceptable = 406; - static const unsupportedMediaType = 415; - - final int value; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => value == accepted; - - /// True for successfully processed requests - bool get isSuccessful => value >= ok && value < 300 && !isPending; - - /// True for failed requests (i.e. neither successful nor pending) - bool get isFailed => !isSuccessful && !isPending; -} - -Handler loggingMiddleware(Handler handler, - {Function(Request request)? onRequest, - Function(Response response)? onResponse}) => - (Request request) async { - onRequest?.call(request); - final response = await handler(request); - onResponse?.call(response); - return response; - }; - -/// CORS middleware -Handler corsMiddleware(Handler handler) => (Request request) async { - final corsHeaders = { - 'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'], - 'Access-Control-Expose-Headers': ['Location'], - }; - - if (request.method == 'options') { - const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; - return Response( - 204, - Body(), - Headers.from({ - ...corsHeaders, - 'Access-Control-Allow-Methods': - request.headers['Access-Control-Request-Method'] ?? methods, - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? ['*'], - })); - } - final response = await handler(request); - response.headers.addAll(corsHeaders); - return response; - }; - -extension HeaderExt on Headers { - String? last(String key) { - final v = this[key]; - return (v != null && v.isNotEmpty) ? v.last : null; - } -} +export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart index 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 1cf7a97..e6e60e1 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,13 +1,17 @@ /// JSON:API server on top of dart:io. -library server; +/// WARNING: This library is in beta stage. The API is subject to change. +library; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/controller_router.dart'; +export 'package:json_api/src/server/cors_middleware.dart'; export 'package:json_api/src/server/error_converter.dart'; export 'package:json_api/src/server/errors/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/try_catch_middleware.dart'; +export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 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<Map?> _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/client/payload_codec.dart b/lib/src/client/payload_codec.dart index e3ed74b..0d9d517 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/to_json_encodable.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<String> encode(Object document) => jsonEncode(document); + FutureOr<String> encode(Object document) => + jsonEncode(document, toEncodable: toJsonEncodable); } 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<R extends Relationship> { ..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<R extends Relationship> { 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; 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 = <String, Object?>{}; + @override Map<String, Object> 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<String, String> 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<String, Object?> get meta; + @override Map<String, Object> 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<NewIdentifier> { +class NewRelationship + with IterableMixin<NewIdentifier> + implements JsonEncodable { final links = <String, Link>{}; final meta = <String, Object?>{}; + @override Map<String, dynamic> 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 = <String, NewRelationship>{}; + @override Map<String, Object> 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 = <String, Object?>{}; - /// Returns the JSON representation. + @override Map<String, Object?> 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<Identifier> { +class Relationship with IterableMixin<Identifier> implements JsonEncodable { final links = <String, Link>{}; final meta = <String, Object?>{}; - Map<String, dynamic> toJson() => { + @override + Map<String, Object?> 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<String, Object> 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 = <Identifier>[]; @override - Map<String, Object> toJson() => { + Map<String, Object?> 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<String, dynamic> toJson() => {'data': identifier, ...super.toJson()}; + Map<String, Object?> toJson() => {'data': identifier, ...super.toJson()}; final Identifier? identifier; diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart new file mode 100644 index 0000000..fc723cd --- /dev/null +++ b/lib/src/http/status_code.dart @@ -0,0 +1,23 @@ +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 notAcceptable = 406; + static const conflict = 409; + static const unsupportedMediaType = 415; + static const internalServerError = 500; + + /// True for the requests processed asynchronously. + /// @see https://jsonapi.org/recommendations/#asynchronous-processing). + bool get isPending => value == accepted; + + /// True for successfully processed requests + bool get isSuccessful => value >= ok && value < 300 && !isPending; + + /// True for failed requests (i.e. neither successful nor pending) + bool get isFailed => !isSuccessful && !isPending; +} diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart index 2b474f6..cc56394 100644 --- a/lib/src/routing/standard_uri_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -5,20 +5,30 @@ 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` 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.where((it) => it.isNotEmpty).length)) { + [var type] => Target(type), + [var type, var id] => ResourceTarget(type, id), + [var type, var id, var rel] => RelatedTarget(type, id, rel), + [var type, var id, 'relationships', var rel] => + RelationshipTarget(type, id, rel), + _ => null + }; final Uri base; 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/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<http.Response> fetchCollection(http.Request request, Target target); + Future<Response> fetchCollection(Request request, Target target); /// Create resource - Future<http.Response> createResource(http.Request request, Target target); + Future<Response> createResource(Request request, Target target); /// Fetch a single primary resource - Future<http.Response> fetchResource( - http.Request request, ResourceTarget target); + Future<Response> fetchResource(Request request, ResourceTarget target); /// Updates a primary resource - Future<http.Response> updateResource( - http.Request request, ResourceTarget target); + Future<Response> updateResource(Request request, ResourceTarget target); /// Deletes the primary resource - Future<http.Response> deleteResource( - http.Request request, ResourceTarget target); + Future<Response> deleteResource(Request request, ResourceTarget target); /// Fetches a relationship - Future<http.Response> fetchRelationship( - http.Request rq, RelationshipTarget target); + Future<Response> fetchRelationship(Request rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future<http.Response> addMany( - http.Request request, RelationshipTarget target); + Future<Response> addMany(Request request, RelationshipTarget target); /// Updates the relationship - Future<http.Response> replaceRelationship( - http.Request request, RelationshipTarget target); + Future<Response> replaceRelationship( + Request request, RelationshipTarget target); /// Deletes the members from the to-many relationship - Future<http.Response> deleteMany( - http.Request request, RelationshipTarget target); + Future<Response> deleteMany(Request request, RelationshipTarget target); /// Fetches related resource or collection - Future<http.Response> fetchRelated( - http.Request request, RelatedTarget target); + Future<Response> fetchRelated(Request request, RelatedTarget target); } diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart deleted file mode 100644 index 4171d1f..0000000 --- a/lib/src/server/controller_router.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:http_interop/http_interop.dart'; -import 'package:http_parser/http_parser.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/media_type.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/errors/method_not_allowed.dart'; -import 'package:json_api/src/server/errors/unacceptable.dart'; -import 'package:json_api/src/server/errors/unmatched_target.dart'; -import 'package:json_api/src/server/errors/unsupported_media_type.dart'; - -class ControllerRouter { - ControllerRouter(this._controller, this._matchTarget); - - final Controller _controller; - final Target? Function(Uri uri) _matchTarget; - - Future<Response> handle(Request request) async { - _validate(request); - final target = _matchTarget(request.uri); - return await switch (target) { - RelationshipTarget() => switch (request.method) { - 'get' => _controller.fetchRelationship(request, target), - 'post' => _controller.addMany(request, target), - 'patch' => _controller.replaceRelationship(request, target), - 'delete' => _controller.deleteMany(request, target), - _ => throw MethodNotAllowed(request.method) - }, - RelatedTarget() => switch (request.method) { - 'get' => _controller.fetchRelated(request, target), - _ => throw MethodNotAllowed(request.method) - }, - ResourceTarget() => switch (request.method) { - 'get' => _controller.fetchResource(request, target), - 'patch' => _controller.updateResource(request, target), - 'delete' => _controller.deleteResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - Target() => switch (request.method) { - 'get' => _controller.fetchCollection(request, target), - 'post' => _controller.createResource(request, target), - _ => throw MethodNotAllowed(request.method) - }, - _ => throw UnmatchedTarget(request.uri) - }; - } - - void _validate(Request request) { - final contentType = request.headers.last('Content-Type'); - if (contentType != null && _isInvalid(MediaType.parse(contentType))) { - throw UnsupportedMediaType(); - } - if ((request.headers['Accept'] ?? []) - .expand((it) => it.split(',')) - .map((it) => it.trim()) - .map(MediaType.parse) - .any(_isInvalid)) { - throw Unacceptable(); - } - } - - bool _isInvalid(MediaType mt) => - mt.mimeType == mediaType && - mt.parameters.isNotEmpty; // TODO: check for ext and profile -} diff --git a/lib/src/server/cors_middleware.dart b/lib/src/server/cors_middleware.dart new file mode 100644 index 0000000..d174de5 --- /dev/null +++ b/lib/src/server/cors_middleware.dart @@ -0,0 +1,22 @@ +import 'package:http_interop/http_interop.dart'; +import 'package:http_interop_middleware/http_interop_middleware.dart'; + +final corsMiddleware = middleware( + onRequest: (rq) async => switch (rq.method) { + 'options' => Response( + 204, + Body(), + Headers.from({ + 'Access-Control-Allow-Methods': + rq.headers['Access-Control-Request-Method'] ?? + const ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'], + 'Access-Control-Allow-Headers': + rq.headers['Access-Control-Request-Headers'] ?? const ['*'], + })), + _ => null + }, + onResponse: (rs, rq) async => rs + ..headers.addAll({ + 'Access-Control-Allow-Origin': [rq.headers['origin']?.last ?? '*'], + 'Access-Control-Expose-Headers': const ['Location'], + })); diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart index 6300408..45af2ad 100644 --- a/lib/src/server/error_converter.dart +++ b/lib/src/server/error_converter.dart @@ -1,70 +1,57 @@ -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'; +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'; -/// 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<http.Response> Function(MethodNotAllowed)? onMethodNotAllowed; - final Future<http.Response> Function(UnmatchedTarget)? onUnmatchedTarget; - final Future<http.Response> Function(CollectionNotFound)? - onCollectionNotFound; - final Future<http.Response> Function(ResourceNotFound)? onResourceNotFound; - final Future<http.Response> Function(RelationshipNotFound)? - onRelationshipNotFound; - final Future<http.Response> Function(dynamic, StackTrace)? onError; - - Future<http.Response> 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<Response?> Function(MethodNotAllowed)? onMethodNotAllowed, + Future<Response?> Function(UnmatchedTarget)? onUnmatchedTarget, + Future<Response?> Function(CollectionNotFound)? onCollectionNotFound, + Future<Response?> Function(ResourceNotFound)? onResourceNotFound, + Future<Response?> Function(RelationshipNotFound)? onRelationshipNotFound, + Future<Response?> Function(Object, StackTrace)? onError, +}) => + middleware( + onError: (error, trace, _) async => switch (error) { + MethodNotAllowed() => + await onMethodNotAllowed?.call(error) ?? methodNotAllowed(), + UnmatchedTarget() => + await onUnmatchedTarget?.call(error) ?? badRequest(), + CollectionNotFound() => await onCollectionNotFound?.call(error) ?? + notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Collection Not Found', + detail: 'Type: ${error.type}', + ) + ])), + ResourceNotFound() => await onResourceNotFound?.call(error) ?? + notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Resource Not Found', + detail: 'Type: ${error.type}, id: ${error.id}', + ) + ])), + RelationshipNotFound() => + await onRelationshipNotFound?.call(error) ?? + notFound(OutboundErrorDocument([ + ErrorObject( + title: 'Relationship Not Found', + detail: 'Type: ${error.type}' + ', id: ${error.id}' + ', relationship: ${error.relationship}', + ) + ])), + UnsupportedMediaType() => unsupportedMediaType(), + NotAcceptable() => notAcceptable(), + _ => await onError?.call(error, trace) ?? + internalServerError(OutboundErrorDocument( + [ErrorObject(title: 'Internal Server Error')])) + }); 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 new file mode 100644 index 0000000..3f2b9a8 --- /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/not_acceptable.dart'; +import 'package:json_api/src/server/errors/unsupported_media_type.dart'; + +final requestValidator = middleware(onRequest: (Request request) async { + final contentType = request.headers['Content-Type']?.last; + if (contentType != null && _isInvalid(MediaType.parse(contentType))) { + throw UnsupportedMediaType(); + } + if ((request.headers['Accept'] ?? []) + .expand((it) => it.split(',')) + .map((it) => it.trim()) + .map(MediaType.parse) + .any(_isInvalid)) { + throw NotAcceptable(); + } + return null; +}); + +bool _isInvalid(MediaType mt) => + mt.mimeType == mediaType && + mt.parameters.isNotEmpty; // TODO: check for ext and profile diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 16109f9..b523e76 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -4,14 +4,15 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/media_type.dart'; /// JSON:API response -Response response(int statusCode, {OutboundDocument? document}) { - final r = Response( - statusCode, document != null ? Body.json(document) : Body(), Headers()); - if (document != null) { - r.headers['Content-Type'] = [mediaType]; - } - return r; -} +Response response(int statusCode, {OutboundDocument? document}) => Response( + statusCode, + document != null + ? Body.json(document, toEncodable: toJsonEncodable) + : Body(), + Headers()) + ..headers.addAll({ + if (document != null) 'Content-Type': [mediaType] + }); Response ok(OutboundDocument document) => response(StatusCode.ok, document: document); @@ -25,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); @@ -34,5 +38,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); 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/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<Response> 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 75619d3..00afda3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,17 @@ name: json_api -version: 8.1.0 +version: 9.0.0-alpha.9 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) environment: - sdk: '>=3.4.0 <4.0.0' + sdk: '>=3.5.0 <4.0.0' dependencies: http_parser: ^4.0.0 - http_interop: ^2.0.0 + http_interop: ^2.1.0 + http_interop_middleware: ^0.1.0 dev_dependencies: - lints: ^4.0.0 + lints: ^5.0.0 test: ^1.21.1 stream_channel: ^2.1.0 uuid: ^4.2.0 diff --git a/test/test_handler.dart b/test/test_handler.dart index e9177b1..4b4288b 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<String> types = const ['users', 'posts', 'comments'], Function(Request request)? onRequest, - Function(Response response)? onResponse, - Future<Response> 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}) => + corsMiddleware.add(requestValidator).add(errorConverter()).call(router( + RepositoryController(InMemoryRepo(types), _nextId), + StandardUriDesign.pathOnly.matchTarget)); +String _nextId() => (_counter++).toString(); int _counter = 0; 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..6acf024 --- /dev/null +++ b/test/unit/document/to_json_encodable_test.dart @@ -0,0 +1,10 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('toJsonEncodable()', () { + test('throws UnsupportedError', () { + expect(() => toJsonEncodable('wow'), throwsUnsupportedError); + }); + }); +} diff --git a/test/unit/http/logging_middleware_test.dart b/test/unit/http/logging_middleware_test.dart deleted file mode 100644 index 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<Response> 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/routing/url_test.dart b/test/unit/routing/url_test.dart index e3781eb..4a8c026 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -3,33 +3,171 @@ 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.base.path, equals('/')); + expect(d.collection('books').toString(), '/books'); + expect(d.resource('books', '42').toString(), '/books/42'); + expect(d.related('books', '42', 'author').toString(), '/books/42/author'); + expect(d.relationship('books', '42', 'author').toString(), '/books/42/relationships/author'); }); + test('uri generation with base, trailing slash', () { + final d = StandardUriDesign(Uri.parse('https://example.com/api/')); + expect(d.base.path, equals('/api/')); + expect(d.collection('books').toString(), 'https://example.com/api/books'); + expect(d.resource('books', '42').toString(), + 'https://example.com/api/books/42'); + expect(d.related('books', '42', 'author').toString(), + 'https://example.com/api/books/42/author'); + expect(d.relationship('books', '42', 'author').toString(), + 'https://example.com/api/books/42/relationships/author'); + }); + + 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 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<Target>()); + expect(d.matchTarget(Uri.parse('/books/42')), isA<ResourceTarget>()); + expect( + d.matchTarget(Uri.parse('/books/42/authors')), isA<RelatedTarget>()); + expect(d.matchTarget(Uri.parse('/books/42/relationships/authors')), + isA<RelationshipTarget>()); + 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<Target>()); + expect(d.matchTarget(Uri.parse('https://example.com/books/42')), + isA<ResourceTarget>()); + expect(d.matchTarget(Uri.parse('https://example.com/books/42/authors')), + isA<RelatedTarget>()); + expect( + d.matchTarget( + Uri.parse('https://example.com/books/42/relationships/authors')), + isA<RelationshipTarget>()); + 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<Target>()); + expect(d.matchTarget(Uri.parse('https://example.com:8080/books/42')), + isA<ResourceTarget>()); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/books/42/authors')), + isA<RelatedTarget>()); + expect( + d.matchTarget(Uri.parse( + 'https://example.com:8080/books/42/relationships/authors')), + isA<RelationshipTarget>()); + + 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<Target>().having((it) => it.type, 'type', equals('books'))); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')), + isA<ResourceTarget>() + .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<RelatedTarget>() + .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<RelationshipTarget>() + .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); + }); + + 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<Target>().having((it) => it.type, 'type', equals('books'))); + expect( + d.matchTarget(Uri.parse('https://example.com:8080/api/books/42')), + isA<ResourceTarget>() + .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<RelatedTarget>() + .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<RelationshipTarget>() + .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); + }); + }); } diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/server/cors_middleware_test.dart similarity index 98% rename from test/unit/http/cors_middleware_test.dart rename to test/unit/server/cors_middleware_test.dart index 6dc2939..761bba8 100644 --- a/test/unit/http/cors_middleware_test.dart +++ b/test/unit/server/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/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart index b2fbc4b..4bb17df 100644 --- a/test/unit/server/error_converter_test.dart +++ b/test/unit/server/error_converter_test.dart @@ -1,14 +1,84 @@ -import 'dart:convert'; - -import 'package:http_interop/extensions.dart'; +import 'package:http_interop/http_interop.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; -import 'package:test/test.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; void main() { - test('500', () async { - final r = await ErrorConverter().call('Foo', StackTrace.current); - expect(r.statusCode, equals(500)); - expect(await r.body.decode(utf8), - equals('{"errors":[{"title":"Internal Server Error"}]}')); + final get = Request('get', Uri(), Body(), 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)); + }); }); } diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart new file mode 100644 index 0000000..756c56f --- /dev/null +++ b/test/unit/server/response_test.dart @@ -0,0 +1,23 @@ +import 'package:http_interop/extensions.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:test/test.dart'; + +void main() { + group('Response', () { + test('converts DateTime to ISO-8601', () async { + final r = response(200, + document: OutboundDocument()..meta['date'] = DateTime(2021)); + expect( + await r.body.decodeJson(), + equals({ + 'meta': {'date': '2021-01-01T00:00:00.000'} + })); + }); + }); + + test('conflict', () async { + final r = conflict(); + expect(r.statusCode, equals(409)); + }); +}