diff --git a/.travis.yml b/.travis.yml index cd19d1a..0258ab9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: dart dart: - stable + - dev + - "2.6.0" + - "2.6.1" dart_task: - test: --platform vm - test: --platform chrome diff --git a/CHANGELOG.md b/CHANGELOG.md index f763bfb..a10e634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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 +- Support for custom non-standard links ([#61](https://github.com/f3ath/json-api-dart/issues/61)) +- Client supports `jsonapi` key in outgoing requests. +- `Document.contentType` constant. +- `IdentifierObject.fromIdentifier` factory method + +### Changed +Most of this changes are **BC-BREAKING**. +- `URLBuilder` was renamed to `UrlFactory`. +- `DocumentBuilder` was split into `ServerDocumentFactory` and `ClientDocumentFactory`. Some methods were renamed. +- Static `decodeJson` methods were renamed to `fromJson`. +- `Identifier.equals` now requires the runtime type to be exactly the same. +- `Link.decodeJsonMap` was renamed to `mapFromJson` +- `TargetMatcher` changed its signature. + +### Removed +- (Server) `ResourceTarget`, `CollectionTarget`, `RelationshipTarget` classes. +- `QueryParameters` interface. +- `Router` class. ## [2.1.0] - 2019-12-04 ### Added diff --git a/README.md b/README.md index f9208f3..682efe7 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,22 @@ Also in this case the [Response.contentLocation] will point to the job queue resource. You can fetch the job queue resource periodically and check the type of the returned resource. Once the operation is complete, the request will return the created resource. +#### Adding JSON:API Object +It is possible to add the [JSON:API Object] to all documents sent by the [JsonApiClient]. To do so, pass the +pre-configured [DocumentFactory] to the [JsonApiClient]: +```dart +import 'package:http/http.dart'; +import 'package:json_api/json_api.dart'; + +void main() async { + final api = Api(version: "1.0"); + final httpClient = Client(); + final jsonApiClient = JsonApiClient(httpClient, documentFactory: DocumentFactory(api: api)); +} + +``` + + # Server The server included in this package is still under development. It is not yet suitable for real production environment except maybe for really simple demo or testing cases. @@ -112,7 +128,7 @@ possible request targets: - Related resources and collections (parameterized by the resource type, resource id and the relation name) - Relationships (parameterized by the resource type, resource id and the relation name) -The [URLBuilder] builds those 4 kinds of URLs by the given parameters. The [TargetMatcher] does the opposite, +The [UrlFactory] makes those 4 kinds of URLs by the given parameters. The [TargetMatcher] does the opposite, it determines the target of the given URL (if possible). Together they form the [UrlDesign]. This package provides one built-in implementation of [UrlDesign] which is called [PathBasedUrlDesign]. @@ -120,6 +136,11 @@ The [PathBasedUrlDesign] implements the [Recommended URL Design] allowing you to for all your JSON:API endpoints. +[DocumentFactory]: https://pub.dev/documentation/json_api/latest/document_factory/DocumentFactory-class.html +[Document.errors]: https://pub.dev/documentation/json_api/latest/document/Document/errors.html +[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html +[PathBasedUrlDesign]: https://pub.dev/documentation/json_api/latest/url_design/PathBasedUrlDesign-class.html +[PrimaryData.included]: https://pub.dev/documentation/json_api/latest/document/PrimaryData/included.html [Response]: https://pub.dev/documentation/json_api/latest/client/Response-class.html [Response.data]: https://pub.dev/documentation/json_api/latest/client/Response/data.html [Response.document]: https://pub.dev/documentation/json_api/latest/client/Response/document.html @@ -131,14 +152,11 @@ for all your JSON:API endpoints. [Response.status]: https://pub.dev/documentation/json_api/latest/client/Response/status.html [Response.asyncDocument]: https://pub.dev/documentation/json_api/latest/client/Response/asyncDocument.html [Response.asyncData]: https://pub.dev/documentation/json_api/latest/client/Response/asyncData.html - -[PrimaryData.included]: https://pub.dev/documentation/json_api/latest/document/PrimaryData/included.html -[Document.errors]: https://pub.dev/documentation/json_api/latest/document/Document/errors.html -[URLBuilder]: https://pub.dev/documentation/json_api/latest/url_design/UrlBuilder-class.html [TargetMatcher]: https://pub.dev/documentation/json_api/latest/url_design/TargetMatcher-class.html +[UrlFactory]: https://pub.dev/documentation/json_api/latest/url_design/UrlFactory-class.html [UrlDesign]: https://pub.dev/documentation/json_api/latest/url_design/UrlDesign-class.html -[PathBasedUrlDesign]: https://pub.dev/documentation/json_api/latest/url_design/PathBasedUrlDesign-class.html [Asynchronous Processing]: https://jsonapi.org/recommendations/#asynchronous-processing [Compound Documents]: https://jsonapi.org/format/#document-compound-documents +[JSON:API Object]: https://jsonapi.org/format/#document-jsonapi-object [Recommended URL Design]: https://jsonapi.org/recommendations/#urls \ No newline at end of file diff --git a/example/cars_server.dart b/example/cars_server.dart index 691bcbc..48ee0cd 100644 --- a/example/cars_server.dart +++ b/example/cars_server.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:json_api/server.dart'; -import 'package:json_api/src/document_builder.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; import 'package:json_api/url_design.dart'; import 'cars_server/controller.dart'; @@ -59,9 +59,9 @@ Future createServer(InternetAddress addr, int port) async { final httpServer = await HttpServer.bind(addr, port); final urlDesign = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); - final documentBuilder = - DocumentBuilder(urlBuilder: urlDesign, pagination: pagination); - final jsonApiServer = Server(urlDesign, controller, documentBuilder); + final documentFactory = + ServerDocumentFactory(urlDesign, pagination: pagination); + final jsonApiServer = Server(urlDesign, controller, documentFactory); httpServer.forEach(jsonApiServer.serve); return httpServer; diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart index ca27f2b..200b6be 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -1,10 +1,7 @@ import 'dart:async'; +import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_api_error.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/pagination/pagination.dart'; import 'package:uuid/uuid.dart'; import 'dao.dart'; @@ -18,8 +15,8 @@ class CarsController implements Controller { CarsController(this._dao, this._pagination); @override - Response fetchCollection(CollectionTarget target, Query query) { - final dao = _getDaoOrThrow(target); + Response fetchCollection(String type, Query query) { + final dao = _getDaoOrThrow(type); final collection = dao.fetchCollection( _pagination.limit(query.page), _pagination.offset(query.page)); return CollectionResponse(collection.elements.map(dao.toResource), @@ -27,17 +24,18 @@ class CarsController implements Controller { } @override - Response fetchRelated(RelationshipTarget target, Query query) { - final res = _fetchResourceOrThrow(target); + Response fetchRelated( + String type, String id, String relationship, Query query) { + final res = _fetchResourceOrThrow(type, id); - if (res.toOne.containsKey(target.relationship)) { - final id = res.toOne[target.relationship]; + if (res.toOne.containsKey(relationship)) { + final id = res.toOne[relationship]; final resource = _dao[id.type].fetchByIdAsResource(id.id); return RelatedResourceResponse(resource); } - if (res.toMany.containsKey(target.relationship)) { - final relationships = res.toMany[target.relationship]; + if (res.toMany.containsKey(relationship)) { + final relationships = res.toMany[relationship]; final resources = relationships .skip(_pagination.offset(query.page)) .take(_pagination.limit(query.page)) @@ -50,10 +48,10 @@ class CarsController implements Controller { } @override - Response fetchResource(ResourceTarget target, Query query) { - final dao = _getDaoOrThrow(target); + Response fetchResource(String type, String id, Query query) { + final dao = _getDaoOrThrow(type); - final obj = dao.fetchById(target.id); + final obj = dao.fetchById(id); if (obj == null) { return ErrorResponse.notFound( @@ -74,32 +72,31 @@ class CarsController implements Controller { } @override - Response fetchRelationship(RelationshipTarget target, Query query) { - final res = _fetchResourceOrThrow(target); + Response fetchRelationship( + String type, String id, String relationship, Query query) { + final res = _fetchResourceOrThrow(type, id); - if (res.toOne.containsKey(target.relationship)) { - final id = res.toOne[target.relationship]; - return ToOneResponse(target, id); + if (res.toOne.containsKey(relationship)) { + return ToOneResponse(type, id, relationship, res.toOne[relationship]); } - if (res.toMany.containsKey(target.relationship)) { - final ids = res.toMany[target.relationship]; - return ToManyResponse(target, ids); + if (res.toMany.containsKey(relationship)) { + return ToManyResponse(type, id, relationship, res.toMany[relationship]); } return ErrorResponse.notFound( [JsonApiError(detail: 'Relationship not found')]); } @override - Response deleteResource(ResourceTarget target) { - final dao = _getDaoOrThrow(target); + Response deleteResource(String type, String id) { + final dao = _getDaoOrThrow(type); - final res = dao.fetchByIdAsResource(target.id); + final res = dao.fetchByIdAsResource(id); if (res == null) { throw ErrorResponse.notFound( [JsonApiError(detail: 'Resource not found')]); } - final dependenciesCount = dao.deleteById(target.id); + final dependenciesCount = dao.deleteById(id); if (dependenciesCount == 0) { return NoContentResponse(); } @@ -107,10 +104,10 @@ class CarsController implements Controller { } @override - Response createResource(CollectionTarget target, Resource resource) { - final dao = _getDaoOrThrow(target); + Response createResource(String type, Resource resource) { + final dao = _getDaoOrThrow(type); - _throwIfIncompatibleTypes(target, resource); + _throwIfIncompatibleTypes(type, resource); if (resource.id != null) { if (dao.fetchById(resource.id) != null) { @@ -126,7 +123,7 @@ class CarsController implements Controller { toMany: resource.toMany, toOne: resource.toOne)); - if (target.type == 'models') { + if (type == 'models') { // Insertion is artificially delayed final job = Job(Future.delayed(Duration(milliseconds: 100), () { dao.insert(created); @@ -142,15 +139,15 @@ class CarsController implements Controller { } @override - Response updateResource(ResourceTarget target, Resource resource) { - final dao = _getDaoOrThrow(target); + Response updateResource(String type, String id, Resource resource) { + final dao = _getDaoOrThrow(type); - _throwIfIncompatibleTypes(target, resource); - if (dao.fetchById(target.id) == null) { + _throwIfIncompatibleTypes(type, resource); + if (dao.fetchById(id) == null) { return ErrorResponse.notFound( [JsonApiError(detail: 'Resource not found')]); } - final updated = dao.update(target.id, resource); + final updated = dao.update(id, resource); if (updated == null) { return NoContentResponse(); } @@ -158,46 +155,48 @@ class CarsController implements Controller { } @override - Response replaceToOne(RelationshipTarget target, Identifier identifier) { - final dao = _getDaoOrThrow(target); + Response replaceToOne( + String type, String id, String relationship, Identifier identifier) { + final dao = _getDaoOrThrow(type); - dao.replaceToOne(target.id, target.relationship, identifier); + dao.replaceToOne(id, relationship, identifier); return NoContentResponse(); } @override - Response replaceToMany( - RelationshipTarget target, List identifiers) { - final dao = _getDaoOrThrow(target); + Response replaceToMany(String type, String id, String relationship, + List identifiers) { + final dao = _getDaoOrThrow(type); - dao.replaceToMany(target.id, target.relationship, identifiers); + dao.replaceToMany(id, relationship, identifiers); return NoContentResponse(); } @override - Response addToMany(RelationshipTarget target, List identifiers) { - final dao = _getDaoOrThrow(target); + Response addToMany(String type, String id, String relationship, + List identifiers) { + final dao = _getDaoOrThrow(type); return ToManyResponse( - target, dao.addToMany(target.id, target.relationship, identifiers)); + type, id, relationship, dao.addToMany(id, relationship, identifiers)); } - void _throwIfIncompatibleTypes(CollectionTarget target, Resource resource) { - if (target.type != resource.type) { + void _throwIfIncompatibleTypes(String type, Resource resource) { + if (type != resource.type) { throw ErrorResponse.conflict([JsonApiError(detail: 'Incompatible type')]); } } - DAO _getDaoOrThrow(CollectionTarget target) { - if (_dao.containsKey(target.type)) return _dao[target.type]; + DAO _getDaoOrThrow(String type) { + if (_dao.containsKey(type)) return _dao[type]; throw ErrorResponse.notFound( - [JsonApiError(detail: 'Unknown resource type ${target.type}')]); + [JsonApiError(detail: 'Unknown resource type ${type}')]); } - Resource _fetchResourceOrThrow(ResourceTarget target) { - final dao = _getDaoOrThrow(target); - final resource = dao.fetchByIdAsResource(target.id); + Resource _fetchResourceOrThrow(String type, String id) { + final dao = _getDaoOrThrow(type); + final resource = dao.fetchByIdAsResource(id); if (resource == null) { throw ErrorResponse.notFound( [JsonApiError(detail: 'Resource not found')]); diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart index 5e3d1fb..eedb975 100644 --- a/example/cars_server/dao.dart +++ b/example/cars_server/dao.dart @@ -1,5 +1,4 @@ import 'package:json_api/json_api.dart'; -import 'package:json_api/src/nullable.dart'; import 'collection.dart'; import 'job_queue.dart'; @@ -17,7 +16,7 @@ abstract class DAO { T fetchById(String id) => _collection[id]; Resource fetchByIdAsResource(String id) => - nullable(toResource)(_collection[id]); + _collection.containsKey(id) ? toResource(_collection[id]) : null; void insert(T t); // => collection[t.id] = t; diff --git a/lib/client.dart b/lib/client.dart index c30f430..d2f2e06 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,4 +1,4 @@ export 'package:json_api/src/client/client.dart'; +export 'package:json_api/src/client/client_document_factory.dart'; export 'package:json_api/src/client/response.dart'; -export 'package:json_api/src/client/simple_document_builder.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart index e18f8ad..7b504a6 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -2,3 +2,4 @@ export 'package:json_api/src/query/fields.dart'; export 'package:json_api/src/query/include.dart'; export 'package:json_api/src/query/page.dart'; export 'package:json_api/src/query/query.dart'; +export 'package:json_api/src/query/sort.dart'; diff --git a/lib/server.dart b/lib/server.dart index 3b2dbb1..466a1b1 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,7 +1,21 @@ export 'package:json_api/query.dart'; export 'package:json_api/src/pagination/fixed_size_page.dart'; +export 'package:json_api/src/pagination/pagination.dart'; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/response/accepted_response.dart'; +export 'package:json_api/src/server/response/collection_response.dart'; +export 'package:json_api/src/server/response/error_response.dart'; +export 'package:json_api/src/server/response/meta_response.dart'; +export 'package:json_api/src/server/response/no_content_response.dart'; +export 'package:json_api/src/server/response/related_collection_response.dart'; +export 'package:json_api/src/server/response/related_resource_response.dart'; +export 'package:json_api/src/server/response/resource_created_response.dart'; +export 'package:json_api/src/server/response/resource_response.dart'; +export 'package:json_api/src/server/response/resource_updated_response.dart'; +export 'package:json_api/src/server/response/response.dart'; +export 'package:json_api/src/server/response/see_other_response.dart'; +export 'package:json_api/src/server/response/to_many_response.dart'; +export 'package:json_api/src/server/response/to_one_response.dart'; export 'package:json_api/src/server/server.dart'; export 'package:json_api/src/target.dart'; export 'package:json_api/url_design.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 28e4f65..beb49c7 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -2,24 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:json_api/document.dart'; +import 'package:json_api/src/client/client_document_factory.dart'; import 'package:json_api/src/client/response.dart'; -import 'package:json_api/src/client/simple_document_builder.dart'; import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_collection_data.dart'; -import 'package:json_api/src/document/resource_data.dart'; -import 'package:json_api/src/document_builder.dart'; /// JSON:API client class JsonApiClient { - static const contentType = 'application/vnd.api+json'; final http.Client httpClient; final OnHttpCall _onHttpCall; - final SimpleDocumentBuilder _build; + final ClientDocumentFactory _factory; /// Creates an instance of JSON:API client. /// You have to create and pass an instance of the [httpClient] yourself. @@ -28,39 +20,39 @@ class JsonApiClient { /// The [onHttpCall] hook, if passed, gets called when an http response is /// received from the HTTP Client. const JsonApiClient(this.httpClient, - {SimpleDocumentBuilder builder, OnHttpCall onHttpCall}) - : _build = builder ?? const DocumentBuilder(), + {ClientDocumentFactory builder, OnHttpCall onHttpCall}) + : _factory = builder ?? const ClientDocumentFactory(), _onHttpCall = onHttpCall ?? _doNothing; /// Fetches a resource collection by sending a GET query to the [uri]. /// Use [headers] to pass extra HTTP headers. Future> fetchCollection(Uri uri, {Map headers}) => - _call(_get(uri, headers), ResourceCollectionData.decodeJson); + _call(_get(uri, headers), ResourceCollectionData.fromJson); /// Fetches a single resource /// Use [headers] to pass extra HTTP headers. Future> fetchResource(Uri uri, {Map headers}) => - _call(_get(uri, headers), ResourceData.decodeJson); + _call(_get(uri, headers), ResourceData.fromJson); /// Fetches a to-one relationship /// Use [headers] to pass extra HTTP headers. Future> fetchToOne(Uri uri, {Map headers}) => - _call(_get(uri, headers), ToOne.decodeJson); + _call(_get(uri, headers), ToOne.fromJson); /// Fetches a to-many relationship /// Use [headers] to pass extra HTTP headers. Future> fetchToMany(Uri uri, {Map headers}) => - _call(_get(uri, headers), ToMany.decodeJson); + _call(_get(uri, headers), ToMany.fromJson); /// Fetches a to-one or to-many relationship. /// The actual type of the relationship can be determined afterwards. /// Use [headers] to pass extra HTTP headers. Future> fetchRelationship(Uri uri, {Map headers}) => - _call(_get(uri, headers), Relationship.decodeJson); + _call(_get(uri, headers), Relationship.fromJson); /// Creates a new resource. The resource will be added to a collection /// according to its type. @@ -68,8 +60,8 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-creating Future> createResource(Uri uri, Resource resource, {Map headers}) => - _call(_post(uri, headers, _build.resourceDocument(resource)), - ResourceData.decodeJson); + _call(_post(uri, headers, _factory.makeResourceDocument(resource)), + ResourceData.fromJson); /// Deletes the resource. /// @@ -82,16 +74,16 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating Future> updateResource(Uri uri, Resource resource, {Map headers}) => - _call(_patch(uri, headers, _build.resourceDocument(resource)), - ResourceData.decodeJson); + _call(_patch(uri, headers, _factory.makeResourceDocument(resource)), + ResourceData.fromJson); /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships Future> replaceToOne(Uri uri, Identifier identifier, {Map headers}) => - _call(_patch(uri, headers, _build.toOneDocument(identifier)), - ToOne.decodeJson); + _call(_patch(uri, headers, _factory.makeToOneDocument(identifier)), + ToOne.fromJson); /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] /// with id = null. @@ -107,8 +99,8 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> replaceToMany(Uri uri, List identifiers, {Map headers}) => - _call(_patch(uri, headers, _build.toManyDocument(identifiers)), - ToMany.decodeJson); + _call(_patch(uri, headers, _factory.makeToManyDocument(identifiers)), + ToMany.fromJson); /// Adds the given set of [identifiers] to a to-many relationship. /// @@ -130,22 +122,22 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> addToMany(Uri uri, List identifiers, {Map headers}) => - _call(_post(uri, headers, _build.toManyDocument(identifiers)), - ToMany.decodeJson); + _call(_post(uri, headers, _factory.makeToManyDocument(identifiers)), + ToMany.fromJson); http.Request _get(Uri uri, Map headers) => http.Request('GET', uri) ..headers.addAll({ ...headers ?? {}, - 'Accept': contentType, + 'Accept': Document.contentType, }); http.Request _post(Uri uri, Map headers, Document doc) => http.Request('POST', uri) ..headers.addAll({ ...headers ?? {}, - 'Accept': contentType, - 'Content-Type': contentType, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, }) ..body = json.encode(doc); @@ -153,15 +145,15 @@ class JsonApiClient { http.Request('DELETE', uri) ..headers.addAll({ ...headers ?? {}, - 'Accept': contentType, + 'Accept': Document.contentType, }); http.Request _patch(uri, Map headers, Document doc) => http.Request('PATCH', uri) ..headers.addAll({ ...headers ?? {}, - 'Accept': contentType, - 'Content-Type': contentType, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, }) ..body = json.encode(doc); @@ -178,11 +170,11 @@ class JsonApiClient { return Response(response.statusCode, response.headers, asyncDocument: body == null ? null - : Document.decodeJson(body, ResourceData.decodeJson)); + : Document.fromJson(body, ResourceData.fromJson)); } return Response(response.statusCode, response.headers, document: - body == null ? null : Document.decodeJson(body, decodePrimaryData)); + body == null ? null : Document.fromJson(body, decodePrimaryData)); } } diff --git a/lib/src/client/client_document_factory.dart b/lib/src/client/client_document_factory.dart new file mode 100644 index 0000000..e0ebffb --- /dev/null +++ b/lib/src/client/client_document_factory.dart @@ -0,0 +1,29 @@ +import 'package:json_api/document.dart'; + +class ClientDocumentFactory { + final Api _api; + + const ClientDocumentFactory({Api api = const Api(version: '1.0')}) + : _api = api; + + Document makeResourceDocument(Resource resource) => + Document(ResourceData(_resourceObject(resource)), api: _api); + + /// A document containing a to-many relationship + Document makeToManyDocument(Iterable ids) => + Document(ToMany(ids.map(IdentifierObject.fromIdentifier)), api: _api); + + /// A document containing a to-one relationship + Document makeToOneDocument(Identifier id) => + Document(ToOne(IdentifierObject.fromIdentifier(id)), api: _api); + + ResourceObject _resourceObject(Resource resource) => + ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: { + ...resource.toOne.map((k, v) => + MapEntry(k, ToOne(IdentifierObject.fromIdentifier(v)))), + ...resource.toMany.map((k, v) => + MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))) + }); +} diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 91d8d08..70a55dd 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,7 +1,5 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/resource_data.dart'; import 'package:json_api/src/nullable.dart'; /// A response returned by JSON:API client @@ -20,7 +18,8 @@ class Response { /// Headers returned by the server. final Map headers; - Response(this.status, this.headers, {this.document, this.asyncDocument}); + const Response(this.status, this.headers, + {this.document, this.asyncDocument}); /// Primary Data from the document (if any) Data get data => document.data; diff --git a/lib/src/client/simple_document_builder.dart b/lib/src/client/simple_document_builder.dart deleted file mode 100644 index 754eb9d..0000000 --- a/lib/src/client/simple_document_builder.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/document.dart'; - -abstract class SimpleDocumentBuilder { - Document resourceDocument(Resource resource); - - Document toManyDocument(List identifiers); - - Document toOneDocument(Identifier identifier); -} diff --git a/lib/src/client/status_code.dart b/lib/src/client/status_code.dart index c1b18d8..4160ce5 100644 --- a/lib/src/client/status_code.dart +++ b/lib/src/client/status_code.dart @@ -3,7 +3,7 @@ class StatusCode { /// The code final int code; - StatusCode(this.code); + const StatusCode(this.code); /// True for the requests processed asynchronously. /// @see https://jsonapi.org/recommendations/#asynchronous-processing). diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index 00a74b6..5c2b79d 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -2,12 +2,13 @@ import 'package:json_api/src/document/decoding_exception.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object class Api { + /// The JSON:API version. final String version; final Map meta; - Api({this.version, this.meta}); + const Api({this.version, this.meta}); - static Api decodeJson(Object json) { + static Api fromJson(Object json) { if (json is Map) { return Api(version: json['version'], meta: json['meta']); } @@ -15,7 +16,7 @@ class Api { } Map toJson() => { - if (version != null) ...{'version': version}, - if (meta != null) ...{'meta': meta}, + if (null != version) ...{'version': version}, + if (null != meta) ...{'meta': meta}, }; } diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 6bed095..c477d09 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -4,6 +4,8 @@ import 'package:json_api/src/document/json_api_error.dart'; import 'package:json_api/src/document/primary_data.dart'; class Document { + static const contentType = 'application/vnd.api+json'; + /// The Primary Data final Data data; final Api api; @@ -11,7 +13,7 @@ class Document { final Map meta; /// Create a document with primary data - Document(this.data, {this.meta, this.api}) : this.errors = null; + const Document(this.data, {this.meta, this.api}) : this.errors = null; /// Create a document with errors (no primary data) Document.error(Iterable errors, {this.meta, this.api}) @@ -25,18 +27,18 @@ class Document { ArgumentError.checkNotNull(meta, 'meta'); } - /// Decodes a document with the specified primary data - static Document decodeJson( + /// Reconstructs a document with the specified primary data + static Document fromJson( Object json, Data decodePrimaryData(Object json)) { if (json is Map) { Api api; if (json.containsKey('jsonapi')) { - api = Api.decodeJson(json['jsonapi']); + api = Api.fromJson(json['jsonapi']); } if (json.containsKey('errors')) { final errors = json['errors']; if (errors is List) { - return Document.error(errors.map(JsonApiError.decodeJson), + return Document.error(errors.map(JsonApiError.fromJson), meta: json['meta'], api: api); } } else if (json.containsKey('data')) { diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 69edc4a..0baffc6 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -17,8 +17,11 @@ class Identifier { } /// Returns true if the two identifiers have the same [type] and [id] - bool equals(Identifier identifier) => - identifier != null && identifier.type == type && identifier.id == id; + bool equals(Identifier other) => + other != null && + other.runtimeType == Identifier && + other.type == type && + other.id == id; String toString() => "Identifier($type:$id)"; } diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index 659f4c3..8ea436e 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -11,7 +11,14 @@ class IdentifierObject { IdentifierObject(this.type, this.id, {this.meta}); - static IdentifierObject decodeJson(Object json) { + /// Returns null if [identifier] is null + static IdentifierObject fromIdentifier(Identifier identifier, + {Map meta}) => + identifier == null + ? null + : IdentifierObject(identifier.type, identifier.id, meta: meta); + + static IdentifierObject fromJson(Object json) { if (json is Map) { return IdentifierObject(json['type'], json['id'], meta: json['meta']); } diff --git a/lib/src/document/json_api_error.dart b/lib/src/document/json_api_error.dart index 5bc7da6..d96fd30 100644 --- a/lib/src/document/json_api_error.dart +++ b/lib/src/document/json_api_error.dart @@ -6,38 +6,43 @@ import 'package:json_api/src/document/link.dart'; /// More on this: https://jsonapi.org/format/#errors class JsonApiError { /// A unique identifier for this particular occurrence of the problem. - String id; + final String id; /// A link that leads to further details about this particular occurrence of the problem. - Link about; + Link get about => _links['about']; /// The HTTP status code applicable to this problem, expressed as a string value. - String status; + final String status; /// An application-specific error code, expressed as a string value. - String code; + final String code; /// A short, human-readable summary of the problem that SHOULD NOT change /// from occurrence to occurrence of the problem, except for purposes of localization. - String title; + final String title; /// A human-readable explanation specific to this occurrence of the problem. /// Like title, this field’s value can be localized. - String detail; + final String detail; /// A JSON Pointer [RFC6901] to the associated entity in the query document /// [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. - String pointer; + final String pointer; /// A string indicating which URI query parameter caused the error. - String parameter; + final String parameter; /// A meta object containing non-standard meta-information about the error. final Map meta; + final Map _links; + + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [about] argument takes precedence and will overwrite + /// the `about` key in [links]. JsonApiError({ this.id, - this.about, this.status, this.code, this.title, @@ -45,13 +50,19 @@ class JsonApiError { this.parameter, this.pointer, this.meta, - }); + Link about, + Map links = const {}, + }) : _links = { + ...links, + if (about != null) ...{'about': about} + }; - static JsonApiError decodeJson(Object json) { - if (json is Map) { - Link about; - if (json['links'] is Map) about = Link.decodeJson(json['links']['about']); + /// All members of the `links` object, including non-standard links. + /// This map is read-only. + get links => Map.unmodifiable(_links); + static JsonApiError fromJson(Object json) { + if (json is Map) { String pointer; String parameter; if (json['source'] is Map) { @@ -60,14 +71,14 @@ class JsonApiError { } return JsonApiError( id: json['id'], - about: about, status: json['status'], code: json['code'], title: json['title'], detail: json['detail'], pointer: pointer, parameter: parameter, - meta: json['meta']); + meta: json['meta'], + links: Link.mapFromJson(json['links'])); } throw DecodingException('Can not decode ErrorObject from $json'); } @@ -84,9 +95,7 @@ class JsonApiError { if (title != null) ...{'title': title}, if (detail != null) ...{'detail': detail}, if (meta != null) ...{'meta': meta}, - if (about != null) ...{ - 'links': {'about': about} - }, + if (_links.isNotEmpty) ...{'links': links}, if (source.isNotEmpty) ...{'source': source}, }; } diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 68fc7e2..978c471 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -9,21 +9,23 @@ class Link { ArgumentError.checkNotNull(uri, 'uri'); } - static Link decodeJson(Object json) { + /// Reconstructs the link from the [json] object. If [json] is null, returns null; + static Link fromJson(Object json) { + if (json == null) return null; if (json is String) return Link(Uri.parse(json)); - if (json is Map) return LinkObject.decodeJson(json); + if (json is Map) return LinkObject.fromJson(json); throw DecodingException('Can not decode Link from $json'); } - /// Decodes the document's `links` member into a map. - /// The retuning map does not have null values. + /// Reconstructs the document's `links` member into a map. + /// The retuning map will not have null values. /// /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map decodeJsonMap(Object json) { + static Map mapFromJson(Object json) { if (json == null) return {}; if (json is Map) { return ({...json}..removeWhere((_, v) => v == null)) - .map((k, v) => MapEntry(k.toString(), Link.decodeJson(v))); + .map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); } throw DecodingException('Can not decode links map from $json'); } @@ -41,7 +43,7 @@ class LinkObject extends Link { LinkObject(Uri href, {this.meta}) : super(href); - static LinkObject decodeJson(Object json) { + static LinkObject fromJson(Object json) { if (json is Map) { final href = json['href']; if (href is String) { @@ -51,9 +53,8 @@ class LinkObject extends Link { throw DecodingException('Can not decode LinkObject from $json'); } - toJson() { - final json = {'href': uri.toString()}; - if (meta != null && meta.isNotEmpty) json['meta'] = meta; - return json; - } + toJson() => { + 'href': uri.toString(), + if (meta != null && meta.isNotEmpty) ...{'meta': meta} + }; } diff --git a/lib/src/document/navigation.dart b/lib/src/document/navigation.dart index a5796e3..a1555b7 100644 --- a/lib/src/document/navigation.dart +++ b/lib/src/document/navigation.dart @@ -1,5 +1,6 @@ import 'package:json_api/src/document/link.dart'; +/// Navigation links class Navigation { final Link prev; final Link next; diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 9c2f8f5..36b7ab0 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -7,18 +7,26 @@ import 'package:json_api/src/document/resource_object.dart'; /// - it always has the `data` key (could be `null` for an empty to-one relationship) /// - it can not have `meta` and `jsonapi` keys abstract class PrimaryData { - /// In Compound document this member contains the included resources + /// In a Compound document this member contains the included resources final List included; - final Link self; + final Map _links; - PrimaryData({this.self, Iterable included}) - : this.included = (included == null) ? null : List.from(included); + PrimaryData( + {Link self, + Iterable included, + Map links = const {}}) + : this.included = (included == null) ? null : List.from(included), + _links = { + ...links, + if (self != null) ...{'self': self} + }; + + /// The `self` link. May be null. + Link get self => _links['self']; /// The top-level `links` object. May be empty. - Map get links => { - if (self != null) ...{'self': self} - }; + Map get links => Map.unmodifiable(_links); /// Documents with included resources are called compound /// @@ -26,6 +34,8 @@ abstract class PrimaryData { bool get isCompound => included != null && included.isNotEmpty; /// Top-level JSON object - Map toJson() => - (included != null) ? {'included': included} : {}; + Map toJson() => { + if (links.isNotEmpty) ...{'links': links}, + if (included != null) ...{'included': included} + }; } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 40cd372..969287a 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -16,41 +16,41 @@ import 'package:json_api/src/nullable.dart'; /// /// More on this: https://jsonapi.org/format/#document-resource-object-relationships class Relationship extends PrimaryData { - final Link related; + Link get related => links['related']; - Map get links => { - ...super.links, - if (related != null) ...{'related': related}, - }; - - Relationship({this.related, Link self, Iterable included}) - : super(self: self, included: included); - - /// Decodes a JSON:API Document or the `relationship` member of a Resource object. - static Relationship decodeJson(Object json) { + Relationship( + {Link related, + Link self, + Iterable included, + Map links = const {}}) + : super(self: self, included: included, links: { + ...links, + if (related != null) ...{'related': related} + }); + + /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. + static Relationship fromJson(Object json) { if (json is Map) { if (json.containsKey('data')) { final data = json['data']; if (data == null || data is Map) { - return ToOne.decodeJson(json); + return ToOne.fromJson(json); } if (data is List) { - return ToMany.decodeJson(json); + return ToMany.fromJson(json); } - } else { - final links = Link.decodeJsonMap(json['links']); - return Relationship(self: links['self'], related: links['related']); } + return Relationship(links: Link.mapFromJson(json['links'])); } throw DecodingException('Can not decode Relationship from $json'); } /// Parses the `relationships` member of a Resource Object - static Map decodeJsonMap(Object json) { + static Map mapFromJson(Object json) { if (json == null) return {}; if (json is Map) { return json - .map((k, v) => MapEntry(k.toString(), Relationship.decodeJson(v))); + .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); } throw DecodingException('Can not decode Relationship map from $json'); } @@ -73,23 +73,21 @@ class ToOne extends Relationship { final IdentifierObject linkage; ToOne(this.linkage, - {Link self, Link related, Iterable included}) - : super(self: self, related: related, included: included); + {Link self, + Link related, + Iterable included, + Map links = const {}}) + : super(self: self, related: related, included: included, links: links); ToOne.empty({Link self, Link related}) : linkage = null, super(self: self, related: related); - static ToOne decodeJson(Object json) { - if (json is Map) { - final links = Link.decodeJsonMap(json['links']); - final included = json['included']; - if (json.containsKey('data')) { - return ToOne(nullable(IdentifierObject.decodeJson)(json['data']), - self: links['self'], - related: links['related'], - included: nullable(ResourceObject.decodeJsonList)(included)); - } + static ToOne fromJson(Object json) { + if (json is Map && json.containsKey('data')) { + return ToOne(nullable(IdentifierObject.fromJson)(json['data']), + links: Link.mapFromJson(json['links']), + included: ResourceObject.fromJsonList(json['included'])); } throw DecodingException('Can not decode ToOne from $json'); } @@ -97,7 +95,7 @@ class ToOne extends Relationship { Map toJson() => super.toJson()..['data'] = linkage; /// Converts to [Identifier]. - /// For empty relationships return null. + /// For empty relationships returns null. Identifier unwrap() => linkage?.unwrap(); } @@ -116,22 +114,21 @@ class ToMany extends Relationship { {Link self, Link related, Iterable included, - this.navigation = const Navigation()}) - : super(self: self, related: related, included: included) { + this.navigation = const Navigation(), + Map links = const {}}) + : super(self: self, related: related, included: included, links: links) { this.linkage.addAll(linkage); } - static ToMany decodeJson(Object json) { + static ToMany fromJson(Object json) { if (json is Map) { - final links = Link.decodeJsonMap(json['links']); - + final links = Link.mapFromJson(json['links']); if (json.containsKey('data')) { final data = json['data']; if (data is List) { return ToMany( - data.map(IdentifierObject.decodeJson), - self: links['self'], - related: links['related'], + data.map(IdentifierObject.fromJson), + links: links, navigation: Navigation.fromLinks(links), ); } diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index e674015..52c9ae1 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -4,48 +4,43 @@ import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/navigation.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/document/resource_object.dart'; -import 'package:json_api/src/nullable.dart'; /// Represents a resource collection or a collection of related resources of a to-many relationship class ResourceCollectionData extends PrimaryData { final collection = []; - final Navigation navigation; ResourceCollectionData(Iterable collection, {Link self, Iterable included, - this.navigation = const Navigation()}) - : super(self: self, included: included) { + Navigation navigation = const Navigation(), + Map links = const {}}) + : super( + self: self, + included: included, + links: {...links, ...navigation.links}) { this.collection.addAll(collection); } - static ResourceCollectionData decodeJson(Object json) { + static ResourceCollectionData fromJson(Object json) { if (json is Map) { - final links = Link.decodeJsonMap(json['links']); final data = json['data']; if (data is List) { - return ResourceCollectionData(data.map(ResourceObject.decodeJson), - self: links['self'], - navigation: Navigation.fromLinks(links), - included: - nullable(ResourceObject.decodeJsonList)(json['included'])); + return ResourceCollectionData(data.map(ResourceObject.fromJson), + links: Link.mapFromJson(json['links']), + included: ResourceObject.fromJsonList(json['included'])); } } throw DecodingException( 'Can not decode ResourceObjectCollection from $json'); } - Map get links => {...super.links, ...navigation.links}; + Navigation get navigation => Navigation.fromLinks(links); List unwrap() => collection.map((_) => _.unwrap()).toList(); @override - Map toJson() { - final json = super.toJson()..['data'] = collection; - if (included != null && included.isNotEmpty) { - json['included'] = included; - } - if (links.isNotEmpty) json['links'] = links; - return json; - } + Map toJson() => { + ...super.toJson(), + ...{'data': collection}, + }; } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index a27b161..6b199e8 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -9,34 +9,34 @@ class ResourceData extends PrimaryData { final ResourceObject resourceObject; ResourceData(this.resourceObject, - {Link self, Iterable included}) - : super(self: self, included: included); + {Link self, + Iterable included, + Map links = const {}}) + : super( + self: self, + included: included, + links: {...resourceObject.links, ...links}); - static ResourceData decodeJson(Object json) { + static ResourceData fromJson(Object json) { if (json is Map) { - final links = Link.decodeJsonMap(json['links']); final included = json['included']; final resources = []; if (included is List) { - resources.addAll(included.map(ResourceObject.decodeJson)); + resources.addAll(included.map(ResourceObject.fromJson)); } - final data = ResourceObject.decodeJson(json['data']); + final data = ResourceObject.fromJson(json['data']); return ResourceData(data, - self: links['self'], + links: Link.mapFromJson(json['links']), included: resources.isNotEmpty ? resources : null); } throw DecodingException('Can not decode SingleResourceObject from $json'); } @override - Map toJson() { - return { - ...super.toJson(), - 'data': resourceObject, - if (included != null && included.isNotEmpty) ...{'included': included}, - if (links.isNotEmpty) ...{'links': links}, - }; - } + Map toJson() => { + ...super.toJson(), + 'data': resourceObject, + }; Resource unwrap() => resourceObject.unwrap(); } diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index e184122..911aff6 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -6,7 +6,6 @@ import 'package:json_api/src/document/resource.dart'; /// [ResourceObject] is a JSON representation of a [Resource]. /// -/// It carries all JSON-related logic and the Meta-data. /// In a JSON:API Document it can be the value of the `data` member (a `data` /// member element in case of a collection) or a member of the `included` /// resource collection. @@ -15,40 +14,52 @@ import 'package:json_api/src/document/resource.dart'; class ResourceObject { final String type; final String id; - final Link self; + final Map attributes; final Map relationships; final Map meta; + final Map _links; ResourceObject(this.type, this.id, - {this.self, + {Link self, Map attributes, Map relationships, - this.meta}) - : attributes = attributes == null ? null : Map.from(attributes), + this.meta, + Map links = const {}}) + : _links = { + ...links, + if (self != null) ...{'self': self} + }, + attributes = attributes == null ? null : Map.from(attributes), relationships = relationships == null ? null : Map.from(relationships); - /// Decodes the `data` member of a JSON:API Document - static ResourceObject decodeJson(Object json) { - final mapOrNull = (_) => _ == null || _ is Map; + Link get self => _links['self']; + + /// Read-only `links` object. May be empty. + Map get links => Map.unmodifiable(_links); + + /// Reconstructs the `data` member of a JSON:API Document. + /// If [json] is null, returns null. + static ResourceObject fromJson(Object json) { + if (json == null) return null; if (json is Map) { final relationships = json['relationships']; final attributes = json['attributes']; - final links = Link.decodeJsonMap(json['links']); - - if (mapOrNull(relationships) && mapOrNull(attributes)) { + if ((relationships == null || relationships is Map) && + (attributes == null || attributes is Map)) { return ResourceObject(json['type'], json['id'], attributes: attributes, - relationships: Relationship.decodeJsonMap(relationships), - self: links['self'], + relationships: Relationship.mapFromJson(relationships), + links: Link.mapFromJson(json['links']), meta: json['meta']); } } throw DecodingException('Can not decode ResourceObject from $json'); } - static List decodeJsonList(Object json) { - if (json is List) return json.map(decodeJson).toList(); + static List fromJsonList(Object json) { + if (json == null) return null; + if (json is List) return json.map(fromJson).toList(); throw DecodingException( 'Can not decode Iterable from $json'); } @@ -63,9 +74,7 @@ class ResourceObject { if (relationships?.isNotEmpty == true) ...{ 'relationships': relationships }, - if (self != null) ...{ - 'links': {'self': self} - }, + if (_links.isNotEmpty) ...{'links': links}, }; /// Extracts the [Resource] if possible. The standard allows relationships diff --git a/lib/src/document_builder.dart b/lib/src/document_builder.dart deleted file mode 100644 index 912c6a0..0000000 --- a/lib/src/document_builder.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/client/simple_document_builder.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/pagination/pagination.dart'; -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/target.dart'; -import 'package:json_api/url_design.dart'; - -/// The Document builder is used by the Client and the Server. It abstracts the process -/// of building response documents and is responsible for such aspects as -/// adding `meta` and `jsonapi` attributes and generating links -class DocumentBuilder implements SimpleDocumentBuilder { - final UrlBuilder _urlBuilder; - final Pagination _pagination; - - const DocumentBuilder( - {UrlBuilder urlBuilder = const _NullObjectUrlDesign(), - Pagination pagination = const _NoPagination()}) - : _pagination = pagination, - _urlBuilder = urlBuilder; - - /// A document containing a list of errors - Document errorDocument(Iterable errors) => - Document.error(errors); - - /// A collection of (primary) resources - Document collectionDocument( - Iterable collection, - {Uri self, - int total, - Iterable included}) => - Document(ResourceCollectionData(collection.map(_resourceObject), - self: _link(self), - navigation: _navigation(self, total), - included: included?.map(_resourceObject))); - - /// A collection of related resources - Document relatedCollectionDocument( - Iterable collection, - {Uri self, - int total, - Iterable included}) => - Document(ResourceCollectionData(collection.map(_resourceObject), - self: _link(self), navigation: _navigation(self, total))); - - /// A single (primary) resource - Document resourceDocument(Resource resource, - {Uri self, Iterable included}) => - Document( - ResourceData(_resourceObject(resource), - self: _link(self), included: included?.map(_resourceObject)), - ); - - /// A single related resource - Document relatedResourceDocument(Resource resource, - {Uri self, Iterable included}) => - Document(ResourceData(_resourceObject(resource), - included: included?.map(_resourceObject), self: _link(self))); - - /// A to-many relationship - Document toManyDocument(Iterable identifiers, - {RelationshipTarget target, Uri self}) => - Document(ToMany(identifiers.map(_identifierObject), - self: _link(self), - related: target == null - ? null - : _link(_urlBuilder.related( - target.type, target.id, target.relationship)))); - - /// A to-one relationship - Document toOneDocument(Identifier identifier, - {RelationshipTarget target, Uri self}) => - Document(ToOne(nullable(_identifierObject)(identifier), - self: _link(self), - related: target == null - ? null - : _link(_urlBuilder.related( - target.type, target.id, target.relationship)))); - - /// A document containing just a meta member - Document metaDocument(Map meta) => Document.empty(meta); - - IdentifierObject _identifierObject(Identifier id) => - IdentifierObject(id.type, id.id); - - ResourceObject _resourceObject(Resource resource) { - final relationships = {}; - relationships.addAll(resource.toOne.map((k, v) => MapEntry( - k, - ToOne(nullable(_identifierObject)(v), - self: - _link(_urlBuilder.relationship(resource.type, resource.id, k)), - related: - _link(_urlBuilder.related(resource.type, resource.id, k)))))); - - relationships.addAll(resource.toMany.map((k, v) => MapEntry( - k, - ToMany(v.map(_identifierObject), - self: - _link(_urlBuilder.relationship(resource.type, resource.id, k)), - related: - _link(_urlBuilder.related(resource.type, resource.id, k)))))); - - return ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: relationships, - self: _link(_urlBuilder.resource(resource.type, resource.id))); - } - - Navigation _navigation(Uri uri, int total) { - if (uri == null) return Navigation(); - final page = Page.decode(uri.queryParametersAll); - return Navigation( - first: _link(_pagination.first()?.addTo(uri)), - last: _link(_pagination.last(total)?.addTo(uri)), - prev: _link(_pagination.prev(page)?.addTo(uri)), - next: _link(_pagination.next(page, total)?.addTo(uri)), - ); - } - - Link _link(Uri uri) => uri == null ? null : Link(uri); -} - -class _NullObjectUrlDesign implements UrlBuilder { - const _NullObjectUrlDesign(); - - @override - Uri collection(String type) => null; - - @override - Uri related(String type, String id, String relationship) => null; - - @override - Uri relationship(String type, String id, String relationship) => null; - - @override - Uri resource(String type, String id) => null; -} - -class _NoPagination implements Pagination { - const _NoPagination(); - - @override - Page first() => null; - - @override - Page last(int total) => null; - - @override - int limit(Page page) => -1; - - @override - Page next(Page page, [int total]) => null; - - @override - int offset(Page page) => 0; - - @override - Page prev(Page page) => null; -} diff --git a/lib/src/pagination/no_pagination.dart b/lib/src/pagination/no_pagination.dart new file mode 100644 index 0000000..c1014d0 --- /dev/null +++ b/lib/src/pagination/no_pagination.dart @@ -0,0 +1,24 @@ +import 'package:json_api/src/pagination/pagination.dart'; +import 'package:json_api/src/query/page.dart'; + +class NoPagination implements Pagination { + const NoPagination(); + + @override + Page first() => null; + + @override + Page last(int total) => null; + + @override + int limit(Page page) => -1; + + @override + Page next(Page page, [int total]) => null; + + @override + int offset(Page page) => 0; + + @override + Page prev(Page page) => null; +} diff --git a/lib/src/query/add_to_uri.dart b/lib/src/query/add_to_uri.dart new file mode 100644 index 0000000..a6bcb4b --- /dev/null +++ b/lib/src/query/add_to_uri.dart @@ -0,0 +1,8 @@ +abstract class AddToUri { + Map get queryParameters; + + Uri addToUri(Uri uri) => queryParameters.isEmpty + ? uri + : uri.replace( + queryParameters: {...uri.queryParameters, ...queryParameters}); +} diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 768c347..7490df6 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -1,18 +1,17 @@ -import 'package:json_api/src/query/query_parameters.dart'; +import 'package:json_api/src/query/add_to_uri.dart'; -class Fields extends QueryParameters { - static final _regex = RegExp(r'^fields\[(.+)\]$'); - - final _fields = >{}; +class Fields with AddToUri implements AddToUri { + static Fields fromUri(Uri uri) => Fields(uri.queryParameters + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.split(','))) + ..removeWhere((k, v) => k == null)); Fields(Map> fields) { _fields.addAll(fields); } - static Fields decode(Map> parameters) => - Fields(parameters.map((k, v) => - MapEntry(_regex.firstMatch(k)?.group(1), v.first.split(','))) - ..removeWhere((k, v) => k == null)); + static final _regex = RegExp(r'^fields\[(.+)\]$'); + + final _fields = >{}; List operator [](String key) => _fields[key]; diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index 60ba58f..9248652 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -1,16 +1,14 @@ import 'dart:collection'; -import 'package:json_api/src/query/query_parameters.dart'; +import 'package:json_api/src/query/add_to_uri.dart'; -class Include extends QueryParameters with IterableMixin { +class Include with AddToUri, IterableMixin implements AddToUri { final Iterable _resources; Include(this._resources); - factory Include.decode(Map> query) { - final resources = (query['include'] ?? []).expand((_) => _.split(',')); - return Include(resources); - } + factory Include.fromUri(Uri uri) => + Include((uri.queryParameters['include'] ?? '').split(',')); @override Iterator get iterator => _resources.iterator; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index facd22a..91255ae 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -1,5 +1,7 @@ +import 'package:json_api/src/query/add_to_uri.dart'; + /// The "page" query parameters -class Page { +class Page with AddToUri implements AddToUri { static final _regex = RegExp(r'^page\[(.+)\]$'); final _params = {}; @@ -8,16 +10,12 @@ class Page { this._params.addAll(parameters); } - factory Page.decode(Map> queryParameters) => - Page(queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.first)) - ..removeWhere((k, v) => k == null)); + static Page fromUri(Uri uri) => Page(uri.queryParameters + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v)) + ..removeWhere((k, v) => k == null)); - Map> encode() => - _params.map((k, v) => MapEntry('page[${k}]', [v])); + Map get queryParameters => + _params.map((k, v) => MapEntry('page[${k}]', v)); String operator [](String key) => _params[key]; - - Uri addTo(Uri uri) => - uri.replace(queryParameters: {...uri.queryParameters, ...encode()}); } diff --git a/lib/src/query/query.dart b/lib/src/query/query.dart index 7653519..df21171 100644 --- a/lib/src/query/query.dart +++ b/lib/src/query/query.dart @@ -1,22 +1,26 @@ +import 'package:json_api/src/query/add_to_uri.dart'; import 'package:json_api/src/query/fields.dart'; import 'package:json_api/src/query/include.dart'; import 'package:json_api/src/query/page.dart'; import 'package:json_api/src/query/sort.dart'; -class Query { +class Query with AddToUri implements AddToUri { final Page page; final Include include; final Fields fields; final Sort sort; - final Uri _uri; - Query(this._uri) - : page = Page.decode(_uri.queryParametersAll), - include = Include.decode(_uri.queryParametersAll), - sort = Sort.decode(_uri.queryParametersAll), - fields = Fields.decode(_uri.queryParametersAll); + Query({this.page, this.include, this.fields, this.sort}); - Map> get parametersAll => _uri.queryParametersAll; + static Query fromUri(Uri uri) => Query( + page: Page.fromUri(uri), + include: Include.fromUri(uri), + sort: Sort.fromUri(uri), + fields: Fields.fromUri(uri)); - Map get parameters => _uri.queryParameters; + @override + Map get queryParameters => [page, include, fields, sort] + .where((_) => _ != null) + .map((_) => _.queryParameters) + .fold({}, (value, element) => {...value, ...element}); } diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart deleted file mode 100644 index c5fe4f9..0000000 --- a/lib/src/query/query_parameters.dart +++ /dev/null @@ -1,6 +0,0 @@ -abstract class QueryParameters { - Map get queryParameters; - - Uri addTo(Uri uri) => uri - .replace(queryParameters: {...uri.queryParameters, ...queryParameters}); -} diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index cf5a28c..2846d47 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -1,44 +1,68 @@ import 'dart:collection'; -import 'package:json_api/src/query/query_parameters.dart'; +import 'package:json_api/src/query/add_to_uri.dart'; + +class Sort with AddToUri, IterableMixin implements AddToUri { + static Sort fromUri(Uri uri) => + Sort((uri.queryParameters['sort'] ?? '').split(',').map(SortField.parse)); -class Sort extends QueryParameters with IterableMixin { final _fields = []; Sort([Iterable fields = const []]) { _fields.addAll(fields); } - static Sort decode(Map> queryParameters) => - Sort((queryParameters['sort'] ?? []) - .expand((_) => _.split(',')) - .map(SortField.parse)); - @override Iterator get iterator => _fields.iterator; - Sort desc(String name) => Sort([..._fields, SortField.desc(name)]); + Sort desc(String name) => Sort([..._fields, Descending(name)]); - Sort asc(String name) => Sort([..._fields, SortField.asc(name)]); + Sort asc(String name) => Sort([..._fields, Ascending(name)]); @override Map get queryParameters => {'sort': join(',')}; } -class SortField { - final bool isAsc; +abstract class SortField { + bool get isAsc; + + bool get isDesc; + + String get name; + + static SortField parse(String queryParam) => queryParam.startsWith('-') + ? Descending(queryParam.substring(1)) + : Ascending(queryParam); +} + +class Ascending implements SortField { + Ascending(this.name); + + @override + bool get isAsc => true; + + @override + bool get isDesc => false; + + @override final String name; - SortField.asc(this.name) : isAsc = true; + @override + String toString() => name; +} + +class Descending implements SortField { + Descending(this.name); - SortField.desc(this.name) : isAsc = false; + @override + bool get isAsc => false; - static SortField parse(String str) => str.startsWith('-') - ? SortField.desc(str.substring(1)) - : SortField.asc(str); + @override + bool get isDesc => true; - bool get isDesc => !isAsc; + @override + final String name; @override - String toString() => (isDesc ? '-' : '') + name; + String toString() => '-${name}'; } diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 8ab6f8e..7d3c236 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -4,30 +4,31 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/query/query.dart'; -import 'package:json_api/src/server/response.dart'; -import 'package:json_api/src/target.dart'; +import 'package:json_api/src/server/response/response.dart'; abstract class Controller { - FutureOr fetchCollection(CollectionTarget target, Query query); + FutureOr fetchCollection(String type, Query query); - FutureOr fetchResource(ResourceTarget target, Query query); + FutureOr fetchResource(String type, String id, Query query); - FutureOr fetchRelated(RelationshipTarget target, Query query); + FutureOr fetchRelated( + String type, String id, String relationship, Query query); - FutureOr fetchRelationship(RelationshipTarget target, Query query); + FutureOr fetchRelationship( + String type, String id, String relationship, Query query); - FutureOr deleteResource(ResourceTarget target); + FutureOr deleteResource(String type, String id); - FutureOr createResource(CollectionTarget target, Resource resource); + FutureOr createResource(String type, Resource resource); - FutureOr updateResource(ResourceTarget target, Resource resource); + FutureOr updateResource(String type, String id, Resource resource); FutureOr replaceToOne( - RelationshipTarget target, Identifier identifier); + String type, String id, String relationship, Identifier identifier); - FutureOr replaceToMany( - RelationshipTarget target, List identifiers); + FutureOr replaceToMany(String type, String id, String relationship, + List identifiers); - FutureOr addToMany( - RelationshipTarget target, List identifiers); + FutureOr addToMany(String type, String id, String relationship, + List identifiers); } diff --git a/lib/src/server/http_method.dart b/lib/src/server/http_method.dart new file mode 100644 index 0000000..d51d257 --- /dev/null +++ b/lib/src/server/http_method.dart @@ -0,0 +1,13 @@ +class HttpMethod { + final String _name; + + HttpMethod(String name) : this._name = name.toUpperCase(); + + bool isPost() => _name == 'POST'; + + bool isGet() => _name == 'GET'; + + bool isPatch() => _name == 'PATCH'; + + bool isDelete() => _name == 'DELETE'; +} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart deleted file mode 100644 index bfc80c5..0000000 --- a/lib/src/server/response.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_api_error.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document_builder.dart'; -import 'package:json_api/src/target.dart'; -import 'package:json_api/url_design.dart'; - -abstract class Response { - final int status; - - const Response(this.status); - - Document getDocument(DocumentBuilder builder, Uri self); - - Map getHeaders(UrlBuilder route) => - {'Content-Type': 'application/vnd.api+json'}; -} - -class ErrorResponse extends Response { - final Iterable errors; - - const ErrorResponse(int status, this.errors) : super(status); - - Document getDocument(DocumentBuilder builder, Uri self) => - builder.errorDocument(errors); - - const ErrorResponse.notImplemented(this.errors) : super(501); - - const ErrorResponse.notFound(this.errors) : super(404); - - const ErrorResponse.badRequest(this.errors) : super(400); - - const ErrorResponse.methodNotAllowed(this.errors) : super(405); - - const ErrorResponse.conflict(this.errors) : super(409); -} - -class CollectionResponse extends Response { - final Iterable collection; - final Iterable included; - final int total; - - const CollectionResponse(this.collection, - {this.included = const [], this.total}) - : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.collectionDocument(collection, - self: self, included: included, total: total); -} - -class ResourceResponse extends Response { - final Resource resource; - final Iterable included; - - const ResourceResponse(this.resource, {this.included = const []}) - : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.resourceDocument(resource, self: self, included: included); -} - -class RelatedResourceResponse extends Response { - final Resource resource; - final Iterable included; - - const RelatedResourceResponse(this.resource, {this.included = const []}) - : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.relatedResourceDocument(resource, self: self); -} - -class RelatedCollectionResponse extends Response { - final Iterable collection; - final Iterable included; - final int total; - - const RelatedCollectionResponse(this.collection, - {this.included = const [], this.total}) - : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.relatedCollectionDocument(collection, self: self, total: total); -} - -class ToOneResponse extends Response { - final Identifier identifier; - final RelationshipTarget target; - - const ToOneResponse(this.target, this.identifier) : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.toOneDocument(identifier, target: target, self: self); -} - -class ToManyResponse extends Response { - final Iterable collection; - final RelationshipTarget target; - - const ToManyResponse(this.target, this.collection) : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.toManyDocument(collection, target: target, self: self); -} - -class MetaResponse extends Response { - final Map meta; - - MetaResponse(this.meta) : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.metaDocument(meta); -} - -class NoContentResponse extends Response { - const NoContentResponse() : super(204); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => null; -} - -class SeeOtherResponse extends Response { - final Resource resource; - - SeeOtherResponse(this.resource) : super(303); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => null; - - @override - Map getHeaders(UrlBuilder route) => { - ...super.getHeaders(route), - 'Location': route.resource(resource.type, resource.id).toString() - }; -} - -class ResourceCreatedResponse extends Response { - final Resource resource; - - ResourceCreatedResponse(this.resource) : super(201); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.resourceDocument(resource, self: self); - - @override - Map getHeaders(UrlBuilder route) => { - ...super.getHeaders(route), - 'Location': route.resource(resource.type, resource.id).toString() - }; -} - -class ResourceUpdatedResponse extends Response { - final Resource resource; - - ResourceUpdatedResponse(this.resource) : super(200); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.resourceDocument(resource, self: self); -} - -class AcceptedResponse extends Response { - final Resource resource; - - AcceptedResponse(this.resource) : super(202); - - @override - Document getDocument(DocumentBuilder builder, Uri self) => - builder.resourceDocument(resource, self: self); - - @override - Map getHeaders(UrlBuilder route) => { - ...super.getHeaders(route), - 'Content-Location': - route.resource(resource.type, resource.id).toString(), - }; -} diff --git a/lib/src/server/response/accepted_response.dart b/lib/src/server/response/accepted_response.dart new file mode 100644 index 0000000..b1c9a4e --- /dev/null +++ b/lib/src/server/response/accepted_response.dart @@ -0,0 +1,22 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/url_design.dart'; + +class AcceptedResponse extends Response { + final Resource resource; + + AcceptedResponse(this.resource) : super(202); + + @override + Document buildDocument( + ServerDocumentFactory factory, Uri self) => + factory.makeResourceDocument(self, resource); + + @override + Map getHeaders(UrlFactory route) => { + ...super.getHeaders(route), + 'Content-Location': + route.resource(resource.type, resource.id).toString(), + }; +} diff --git a/lib/src/server/response/collection_response.dart b/lib/src/server/response/collection_response.dart new file mode 100644 index 0000000..08fcc3b --- /dev/null +++ b/lib/src/server/response/collection_response.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class CollectionResponse extends Response { + final Iterable collection; + final Iterable included; + final int total; + + const CollectionResponse(this.collection, + {this.included = const [], this.total}) + : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeCollectionDocument(self, collection, + included: included, total: total); +} diff --git a/lib/src/server/response/error_response.dart b/lib/src/server/response/error_response.dart new file mode 100644 index 0000000..f39c4f2 --- /dev/null +++ b/lib/src/server/response/error_response.dart @@ -0,0 +1,22 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class ErrorResponse extends Response { + final Iterable errors; + + const ErrorResponse(int status, this.errors) : super(status); + + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeErrorDocument(errors); + + const ErrorResponse.notImplemented(this.errors) : super(501); + + const ErrorResponse.notFound(this.errors) : super(404); + + const ErrorResponse.badRequest(this.errors) : super(400); + + const ErrorResponse.methodNotAllowed(this.errors) : super(405); + + const ErrorResponse.conflict(this.errors) : super(409); +} diff --git a/lib/src/server/response/meta_response.dart b/lib/src/server/response/meta_response.dart new file mode 100644 index 0000000..c4dc61a --- /dev/null +++ b/lib/src/server/response/meta_response.dart @@ -0,0 +1,13 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class MetaResponse extends Response { + final Map meta; + + MetaResponse(this.meta) : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeMetaDocument(meta); +} diff --git a/lib/src/server/response/no_content_response.dart b/lib/src/server/response/no_content_response.dart new file mode 100644 index 0000000..a6dc7e9 --- /dev/null +++ b/lib/src/server/response/no_content_response.dart @@ -0,0 +1,12 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class NoContentResponse extends Response { + const NoContentResponse() : super(204); + + @override + Document buildDocument( + ServerDocumentFactory factory, Uri self) => + null; +} diff --git a/lib/src/server/response/related_collection_response.dart b/lib/src/server/response/related_collection_response.dart new file mode 100644 index 0000000..6145e45 --- /dev/null +++ b/lib/src/server/response/related_collection_response.dart @@ -0,0 +1,18 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class RelatedCollectionResponse extends Response { + final Iterable collection; + final Iterable included; + final int total; + + const RelatedCollectionResponse(this.collection, + {this.included = const [], this.total}) + : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeRelatedCollectionDocument(self, collection, total: total); +} diff --git a/lib/src/server/response/related_resource_response.dart b/lib/src/server/response/related_resource_response.dart new file mode 100644 index 0000000..19f4bdd --- /dev/null +++ b/lib/src/server/response/related_resource_response.dart @@ -0,0 +1,16 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class RelatedResourceResponse extends Response { + final Resource resource; + final Iterable included; + + const RelatedResourceResponse(this.resource, {this.included = const []}) + : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeRelatedResourceDocument(self, resource); +} diff --git a/lib/src/server/response/resource_created_response.dart b/lib/src/server/response/resource_created_response.dart new file mode 100644 index 0000000..2f8363d --- /dev/null +++ b/lib/src/server/response/resource_created_response.dart @@ -0,0 +1,21 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/url_design.dart'; + +class ResourceCreatedResponse extends Response { + final Resource resource; + + ResourceCreatedResponse(this.resource) : super(201); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeResourceDocument(self, resource); + + @override + Map getHeaders(UrlFactory route) => { + ...super.getHeaders(route), + 'Location': route.resource(resource.type, resource.id).toString() + }; +} diff --git a/lib/src/server/response/resource_response.dart b/lib/src/server/response/resource_response.dart new file mode 100644 index 0000000..4c84b7b --- /dev/null +++ b/lib/src/server/response/resource_response.dart @@ -0,0 +1,16 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class ResourceResponse extends Response { + final Resource resource; + final Iterable included; + + const ResourceResponse(this.resource, {this.included = const []}) + : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeResourceDocument(self, resource, included: included); +} diff --git a/lib/src/server/response/resource_updated_response.dart b/lib/src/server/response/resource_updated_response.dart new file mode 100644 index 0000000..a221afd --- /dev/null +++ b/lib/src/server/response/resource_updated_response.dart @@ -0,0 +1,14 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class ResourceUpdatedResponse extends Response { + final Resource resource; + + ResourceUpdatedResponse(this.resource) : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeResourceDocument(self, resource); +} diff --git a/lib/src/server/response/response.dart b/lib/src/server/response/response.dart new file mode 100644 index 0000000..13d9110 --- /dev/null +++ b/lib/src/server/response/response.dart @@ -0,0 +1,14 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/url_design.dart'; + +abstract class Response { + final int status; + + const Response(this.status); + + Document buildDocument(ServerDocumentFactory factory, Uri self); + + Map getHeaders(UrlFactory route) => + {'Content-Type': Document.contentType}; +} diff --git a/lib/src/server/response/see_other_response.dart b/lib/src/server/response/see_other_response.dart new file mode 100644 index 0000000..aa35f8e --- /dev/null +++ b/lib/src/server/response/see_other_response.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/url_design.dart'; + +class SeeOtherResponse extends Response { + final Resource resource; + + SeeOtherResponse(this.resource) : super(303); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => null; + + @override + Map getHeaders(UrlFactory route) => { + ...super.getHeaders(route), + 'Location': route.resource(resource.type, resource.id).toString() + }; +} diff --git a/lib/src/server/response/to_many_response.dart b/lib/src/server/response/to_many_response.dart new file mode 100644 index 0000000..cf6d119 --- /dev/null +++ b/lib/src/server/response/to_many_response.dart @@ -0,0 +1,17 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class ToManyResponse extends Response { + final Iterable collection; + final String type; + final String id; + final String relationship; + + const ToManyResponse(this.type, this.id, this.relationship, this.collection) + : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeToManyDocument(self, collection, type, id, relationship); +} diff --git a/lib/src/server/response/to_one_response.dart b/lib/src/server/response/to_one_response.dart new file mode 100644 index 0000000..bcddbd0 --- /dev/null +++ b/lib/src/server/response/to_one_response.dart @@ -0,0 +1,17 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; + +class ToOneResponse extends Response { + final String type; + final String id; + final String relationship; + final Identifier identifier; + + const ToOneResponse(this.type, this.id, this.relationship, this.identifier) + : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeToOneDocument(self, identifier, type, id, relationship); +} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart deleted file mode 100644 index d892cac..0000000 --- a/lib/src/server/router.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/query/query.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/response.dart'; -import 'package:json_api/src/target.dart'; -import 'package:json_api/url_design.dart'; - -class Router { - final TargetMatcher matcher; - - Router(this.matcher); - - Route getRoute(Uri uri) { - Route route = InvalidRoute(); - matcher.match( - uri, - onCollection: (type) => route = CollectionRoute(CollectionTarget(type)), - onResource: (type, id) => route = ResourceRoute(ResourceTarget(type, id)), - onRelationship: (type, id, relationship) => - route = RelationshipRoute(RelationshipTarget(type, id, relationship)), - onRelated: (type, id, relationship) => - route = RelatedRoute(RelationshipTarget(type, id, relationship)), - ); - return route; - } -} - -abstract class Route { - FutureOr call( - Controller controller, Query query, Method method, Object body); -} - -class CollectionRoute extends Route { - final CollectionTarget target; - - CollectionRoute(this.target); - - @override - FutureOr call( - Controller controller, Query query, Method method, Object body) { - if (method.isGet()) { - return controller.fetchCollection(target, query); - } - if (method.isPost()) { - return controller.createResource(target, - Document.decodeJson(body, ResourceData.decodeJson).data.unwrap()); - } - return null; - } -} - -class ResourceRoute extends Route { - final ResourceTarget target; - - ResourceRoute(this.target); - - @override - FutureOr call( - Controller controller, Query query, Method method, Object body) { - if (method.isGet()) { - return controller.fetchResource(target, query); - } - if (method.isDelete()) { - return controller.deleteResource(target); - } - if (method.isPatch()) { - return controller.updateResource(target, - Document.decodeJson(body, ResourceData.decodeJson).data.unwrap()); - } - return null; - } -} - -class RelationshipRoute extends Route { - final RelationshipTarget target; - - RelationshipRoute(this.target); - - @override - FutureOr call( - Controller controller, Query query, Method method, Object body) { - if (method.isGet()) { - return controller.fetchRelationship(target, query); - } - if (method.isPatch()) { - final rel = Relationship.decodeJson(body); - if (rel is ToOne) { - return controller.replaceToOne(target, rel.unwrap()); - } - if (rel is ToMany) { - return controller.replaceToMany(target, rel.identifiers); - } - } - if (method.isPost()) { - final rel = Relationship.decodeJson(body); - if (rel is ToMany) { - return controller.addToMany(target, rel.identifiers); - } - } - return null; - } -} - -class RelatedRoute extends Route { - final RelationshipTarget target; - - RelatedRoute(this.target); - - @override - FutureOr call( - Controller controller, Query query, Method method, Object body) { - if (method.isGet()) return controller.fetchRelated(target, query); - return null; - } -} - -class InvalidRoute extends Route { - InvalidRoute(); - - @override - Future call( - Controller controller, Query query, Method method, Object body) => - null; -} - -class Method { - final String _name; - - Method(String name) : this._name = name.toUpperCase(); - - bool isPost() => _name == 'POST'; - - bool isGet() => _name == 'GET'; - - bool isPatch() => _name == 'PATCH'; - - bool isDelete() => _name == 'DELETE'; -} diff --git a/lib/src/server/routing/relationship_route.dart b/lib/src/server/routing/relationship_route.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/src/server/routing/relationship_route.dart @@ -0,0 +1 @@ + diff --git a/lib/src/server/routing/resource_route.dart b/lib/src/server/routing/resource_route.dart new file mode 100644 index 0000000..bbd2667 --- /dev/null +++ b/lib/src/server/routing/resource_route.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/http_method.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/routing/route.dart'; + +class ResourceRoute implements Route { + final String type; + final String id; + + ResourceRoute(this.type, this.id); + + @override + FutureOr call( + Controller controller, Query query, HttpMethod method, Object body) { + if (method.isGet()) { + return controller.fetchResource(type, id, query); + } + if (method.isDelete()) { + return controller.deleteResource(type, id); + } + if (method.isPatch()) { + return controller.updateResource(type, id, + Document.fromJson(body, ResourceData.fromJson).data.unwrap()); + } + return null; + } +} diff --git a/lib/src/server/routing/route.dart b/lib/src/server/routing/route.dart new file mode 100644 index 0000000..8e226bc --- /dev/null +++ b/lib/src/server/routing/route.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/http_method.dart'; +import 'package:json_api/src/server/response/response.dart'; + +abstract class Route { + FutureOr call( + Controller controller, Query query, HttpMethod method, Object body); +} + +class InvalidRoute implements Route { + InvalidRoute(); + + @override + Future call( + Controller controller, Query query, HttpMethod method, Object body) => + null; +} + +class CollectionRoute implements Route { + final String type; + + CollectionRoute(this.type); + + @override + FutureOr call( + Controller controller, Query query, HttpMethod method, Object body) { + if (method.isGet()) { + return controller.fetchCollection(type, query); + } + if (method.isPost()) { + return controller.createResource( + type, Document.fromJson(body, ResourceData.fromJson).data.unwrap()); + } + return null; + } +} + +class RelatedRoute implements Route { + final String type; + final String id; + final String relationship; + + const RelatedRoute(this.type, this.id, this.relationship); + + @override + FutureOr call( + Controller controller, Query query, HttpMethod method, Object body) { + if (method.isGet()) + return controller.fetchRelated(type, id, relationship, query); + return null; + } +} + +class RelationshipRoute implements Route { + final String type; + final String id; + final String relationship; + + RelationshipRoute(this.type, this.id, this.relationship); + + @override + FutureOr call( + Controller controller, Query query, HttpMethod method, Object body) { + if (method.isGet()) { + return controller.fetchRelationship(type, id, relationship, query); + } + if (method.isPatch()) { + final rel = Relationship.fromJson(body); + if (rel is ToOne) { + return controller.replaceToOne(type, id, relationship, rel.unwrap()); + } + if (rel is ToMany) { + return controller.replaceToMany( + type, id, relationship, rel.identifiers); + } + } + if (method.isPost()) { + final rel = Relationship.fromJson(body); + if (rel is ToMany) { + return controller.addToMany(type, id, relationship, rel.identifiers); + } + } + return null; + } +} diff --git a/lib/src/server/routing/route_factory.dart b/lib/src/server/routing/route_factory.dart new file mode 100644 index 0000000..57b4c14 --- /dev/null +++ b/lib/src/server/routing/route_factory.dart @@ -0,0 +1,24 @@ +import 'package:json_api/src/server/routing/resource_route.dart'; +import 'package:json_api/src/server/routing/route.dart'; +import 'package:json_api/url_design.dart'; + +class RouteFactory implements MatchCase { + const RouteFactory(); + + @override + unmatched() => InvalidRoute(); + + @override + Route collection(String type) => CollectionRoute(type); + + @override + Route related(String type, String id, String relationship) => + RelatedRoute(type, id, relationship); + + @override + Route relationship(String type, String id, String relationship) => + RelationshipRoute(type, id, relationship); + + @override + Route resource(String type, String id) => ResourceRoute(type, id); +} diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 31797b5..f4545b6 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -2,23 +2,25 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:json_api/src/document_builder.dart'; -import 'package:json_api/src/query/query.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/response.dart'; -import 'package:json_api/src/server/router.dart'; +import 'package:json_api/src/server/http_method.dart'; +import 'package:json_api/src/server/response/error_response.dart'; +import 'package:json_api/src/server/response/response.dart'; +import 'package:json_api/src/server/routing/route_factory.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; import 'package:json_api/url_design.dart'; class Server { final UrlDesign urlDesign; final Controller controller; - final DocumentBuilder documentBuilder; + final ServerDocumentFactory documentFactory; final String allowOrigin; - final Router router; + final RouteFactory routeMapper; - Server(this.urlDesign, this.controller, this.documentBuilder, + Server(this.urlDesign, this.controller, this.documentFactory, {this.allowOrigin = '*'}) - : router = Router(urlDesign); + : routeMapper = RouteFactory(); Future serve(HttpRequest request) async { final response = await _call(controller, request); @@ -31,19 +33,20 @@ class Server { } Future _call(Controller controller, HttpRequest request) async { - final route = router.getRoute(request.requestedUri); - final query = Query(request.requestedUri); - final method = Method(request.method); + final query = Query.fromUri(request.requestedUri); + final method = HttpMethod(request.method); final body = await _getBody(request); try { - return await route.call(controller, query, method, body); + return await urlDesign + .match(request.requestedUri, routeMapper) + .call(controller, query, method, body); } on ErrorResponse catch (error) { return error; } } void _writeBody(HttpRequest request, Response response) { - final doc = response.getDocument(documentBuilder, request.requestedUri); + final doc = response.buildDocument(documentFactory, request.requestedUri); if (doc != null) request.response.write(json.encode(doc)); } diff --git a/lib/src/server/server_document_factory.dart b/lib/src/server/server_document_factory.dart new file mode 100644 index 0000000..357ee4f --- /dev/null +++ b/lib/src/server/server_document_factory.dart @@ -0,0 +1,120 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/pagination/no_pagination.dart'; +import 'package:json_api/src/pagination/pagination.dart'; +import 'package:json_api/src/query/page.dart'; +import 'package:json_api/url_design.dart'; + +class ServerDocumentFactory { + final UrlFactory _url; + final Pagination _pagination; + final Api _api; + + const ServerDocumentFactory(this._url, + {Api api = const Api(version: '1.0'), + Pagination pagination = const NoPagination()}) + : _api = api, + _pagination = pagination; + + /// A document containing a list of errors + Document makeErrorDocument(Iterable errors) => + Document.error(errors, api: _api); + + /// A document containing a collection of (primary) resources + Document makeCollectionDocument( + Uri self, Iterable collection, + {int total, Iterable included}) => + Document( + ResourceCollectionData(collection.map(_resourceObject), + self: Link(self), + navigation: _navigation(self, total), + included: included?.map(_resourceObject)), + api: _api); + + /// A document containing a collection of related resources + Document makeRelatedCollectionDocument( + Uri self, Iterable collection, + {int total, Iterable included}) => + Document( + ResourceCollectionData(collection.map(_resourceObject), + self: Link(self), navigation: _navigation(self, total)), + api: _api); + + /// A document containing a single (primary) resource + Document makeResourceDocument(Uri self, Resource resource, + {Iterable included}) => + Document( + ResourceData(_resourceObject(resource), + self: Link(self), included: included?.map(_resourceObject)), + api: _api); + + /// A document containing a single related resource + Document makeRelatedResourceDocument( + Uri self, Resource resource, {Iterable included}) => + Document( + ResourceData(_resourceObject(resource), + self: Link(self), included: included?.map(_resourceObject)), + api: _api); + + /// A document containing a to-many relationship + Document makeToManyDocument( + Uri self, + Iterable identifiers, + String type, + String id, + String relationship) => + Document( + ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + self: Link(self), + related: Link(_url.related(type, id, relationship)), + ), + api: _api); + + /// A document containing a to-one relationship + Document makeToOneDocument(Uri self, Identifier identifier, + String type, String id, String relationship) => + Document( + ToOne( + nullable(IdentifierObject.fromIdentifier)(identifier), + self: Link(self), + related: Link(_url.related(type, id, relationship)), + ), + api: _api); + + /// A document containing just a meta member + Document makeMetaDocument(Map meta) => + Document.empty(meta, api: _api); + + ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, + attributes: r.attributes, + relationships: { + ...r.toOne.map((k, v) => MapEntry( + k, + ToOne( + IdentifierObject.fromIdentifier(v), + self: Link(_url.relationship(r.type, r.id, k)), + related: Link(_url.related(r.type, r.id, k)), + ))), + ...r.toMany.map((k, v) => MapEntry( + k, + ToMany( + v.map(IdentifierObject.fromIdentifier), + self: Link(_url.relationship(r.type, r.id, k)), + related: Link(_url.related(r.type, r.id, k)), + ))) + }, + self: Link(_url.resource(r.type, r.id))); + + Navigation _navigation(Uri uri, int total) { + final page = Page.fromUri(uri); + return Navigation( + first: nullable(_link)(_pagination.first()?.addToUri(uri)), + last: nullable(_link)(_pagination.last(total)?.addToUri(uri)), + prev: nullable(_link)(_pagination.prev(page)?.addToUri(uri)), + next: nullable(_link)(_pagination.next(page, total)?.addToUri(uri)), + ); + } + + Link _link(Uri uri) => Link(uri); +} diff --git a/lib/src/target.dart b/lib/src/target.dart index b859e2a..8b13789 100644 --- a/lib/src/target.dart +++ b/lib/src/target.dart @@ -1,20 +1 @@ -class CollectionTarget { - final String type; - const CollectionTarget(this.type); -} - -class ResourceTarget implements CollectionTarget { - final String type; - final String id; - - const ResourceTarget(this.type, this.id); -} - -class RelationshipTarget implements ResourceTarget { - final String type; - final String id; - final String relationship; - - const RelationshipTarget(this.type, this.id, this.relationship); -} diff --git a/lib/src/url_design/path_based_url_design.dart b/lib/src/url_design/path_based_url_design.dart index a02a47c..51e532c 100644 --- a/lib/src/url_design/path_based_url_design.dart +++ b/lib/src/url_design/path_based_url_design.dart @@ -1,3 +1,4 @@ +import 'package:json_api/server.dart'; import 'package:json_api/src/url_design/url_design.dart'; /// URL Design where the target is determined by the URL path. @@ -10,59 +11,48 @@ class PathBasedUrlDesign implements UrlDesign { PathBasedUrlDesign(this.base); /// Returns a URL for the primary resource collection of type [type] - Uri collection(String type) => _appendToBase([type]); + Uri collection(String type, {Query query}) => _appendToBase([type]); /// Returns a URL for the related resource/collection. /// The [type] and [id] identify the primary resource and the [relationship] /// is the relationship name. - Uri related(String type, String id, String relationship) => + Uri related(String type, String id, String relationship, {Query query}) => _appendToBase([type, id, relationship]); /// Returns a URL for the relationship itself. /// The [type] and [id] identify the primary resource and the [relationship] /// is the relationship name. - Uri relationship(String type, String id, String relationship) => + Uri relationship(String type, String id, String relationship, + {Query query}) => _appendToBase([type, id, _relationships, relationship]); /// Returns a URL for the primary resource of type [type] with id [id] - Uri resource(String type, String id) => _appendToBase([type, id]); - - /// Matches the target of the [uri]. If the target can be determined, - /// the corresponding callback will be called with the target parameters. - void match(Uri uri, - {onCollection(String type), - onResource(String type, String id), - onRelationship(String type, String id, String relationship), - onRelated(String type, String id, String relationship)}) { - if (!_matchesBase(uri)) return; - final seg = _getPathSegments(uri); - - if (_isCollection(seg) && onCollection != null) { - onCollection(seg[0]); - } else if (_isResource(seg) && onResource != null) { - onResource(seg[0], seg[1]); - } else if (_isRelated(seg) && onRelated != null) { - onRelated(seg[0], seg[1], seg[2]); - } else if (_isRelationship(seg) && onRelationship != null) { - onRelationship(seg[0], seg[1], seg[3]); + Uri resource(String type, String id, {Query query}) => + _appendToBase([type, id]); + + @override + T match(final Uri uri, final MatchCase matchCase) { + if (_matchesBase(uri)) { + final seg = uri.pathSegments.sublist(base.pathSegments.length); + if (seg.length == 1) { + return matchCase.collection(seg[0]); + } + if (seg.length == 2) { + return matchCase.resource(seg[0], seg[1]); + } + if (seg.length == 3) { + return matchCase.related(seg[0], seg[1], seg[2]); + } + if (seg.length == 4 && seg[2] == _relationships) { + return matchCase.relationship(seg[0], seg[1], seg[3]); + } } + return matchCase.unmatched(); } Uri _appendToBase(List segments) => base.replace(pathSegments: base.pathSegments + segments); - List _getPathSegments(Uri uri) => - uri.pathSegments.sublist(base.pathSegments.length); - - bool _isRelationship(List seg) => - seg.length == 4 && seg[2] == _relationships; - - bool _isRelated(List seg) => seg.length == 3; - - bool _isResource(List seg) => seg.length == 2; - - bool _isCollection(List seg) => seg.length == 1; - bool _matchesBase(Uri uri) => base.host == uri.host && base.port == uri.port && diff --git a/lib/src/url_design/target_matcher.dart b/lib/src/url_design/target_matcher.dart deleted file mode 100644 index c9176cd..0000000 --- a/lib/src/url_design/target_matcher.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// Determines if a given URI matches a specific target -abstract class TargetMatcher { - /// Matches the target of the [uri]. If the target can be determined, - /// the corresponding callback will be called with the target parameters. - void match(Uri uri, - {onCollection(String type), - onResource(String type, String id), - onRelationship(String type, String id, String relationship), - onRelated(String type, String id, String relationship)}); -} diff --git a/lib/src/url_design/url_builder.dart b/lib/src/url_design/url_builder.dart deleted file mode 100644 index f87023b..0000000 --- a/lib/src/url_design/url_builder.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// Builds URIs for specific targets -abstract class UrlBuilder { - /// Returns a URL for the primary resource collection of type [type] - Uri collection(String type); - - /// Returns a URL for the related resource/collection. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - Uri related(String type, String id, String relationship); - - /// Returns a URL for the relationship itself. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - Uri relationship(String type, String id, String relationship); - - /// Returns a URL for the primary resource of type [type] with id [id] - Uri resource(String type, String id); -} diff --git a/lib/src/url_design/url_design.dart b/lib/src/url_design/url_design.dart index cf14500..b13a60d 100644 --- a/lib/src/url_design/url_design.dart +++ b/lib/src/url_design/url_design.dart @@ -1,5 +1,42 @@ -import 'package:json_api/src/url_design/target_matcher.dart'; -import 'package:json_api/src/url_design/url_builder.dart'; +/// URL Design describes how the endpoints are organized. +abstract class UrlDesign implements TargetMatcher, UrlFactory {} -/// url_design (URL Design) describes how the endpoints are organized. -abstract class UrlDesign implements TargetMatcher, UrlBuilder {} +/// Makes URIs for specific targets +abstract class UrlFactory { + /// Returns a URL for the primary resource collection of type [type] + Uri collection(String type); + + /// Returns a URL for the related resource/collection. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + Uri related(String type, String id, String relationship); + + /// Returns a URL for the relationship itself. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + Uri relationship(String type, String id, String relationship); + + /// Returns a URL for the primary resource of type [type] with id [id] + Uri resource(String type, String id); +} + +/// Determines if a given URI matches a specific target +abstract class TargetMatcher { + /// Matches the target of the [uri]. If the target can be determined, + /// the corresponding method of [matchCase] will be called with the target parameters + /// and the result will be returned. + /// Otherwise returns the result of [MatchCase.unmatched]. + T match(Uri uri, MatchCase matchCase); +} + +abstract class MatchCase { + T collection(String type); + + T resource(String type, String id); + + T relationship(String type, String id, String relationship); + + T related(String type, String id, String relationship); + + T unmatched(); +} diff --git a/lib/url_design.dart b/lib/url_design.dart index a0942d2..af1e4fd 100644 --- a/lib/url_design.dart +++ b/lib/url_design.dart @@ -1,4 +1,2 @@ export 'package:json_api/src/url_design/path_based_url_design.dart'; -export 'package:json_api/src/url_design/target_matcher.dart'; -export 'package:json_api/src/url_design/url_builder.dart'; export 'package:json_api/src/url_design/url_design.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index d9f5f4b..bd0d68f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,12 +4,12 @@ version: 2.1.0 homepage: https://github.com/f3ath/json-api-dart description: JSON:API Client for Flutter, Web and VM. Supports JSON:API v1.0 (http://jsonapi.org) environment: - sdk: '>=2.3.0 <3.0.0' + sdk: '>=2.6.0 <3.0.0' dependencies: http: ^0.12.0 collection: ^1.14.11 dev_dependencies: - test: 1.9.2 + test: ^1.9.2 json_matcher: ^0.2.3 - stream_channel: ^1.6.8 + stream_channel: ^2.0.0 uuid: ^2.0.1 diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart index a9069ce..d00bf0b 100644 --- a/test/unit/document/api_test.dart +++ b/test/unit/document/api_test.dart @@ -1,5 +1,13 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - test('Api can be json-encoded', () {}); + test('Api can be json-encoded', () { + final api = Api.fromJson( + json.decode(json.encode(Api(version: "1.0", meta: {"foo": "bar"})))); + expect("1.0", api.version); + expect("bar", api.meta["foo"]); + }); } diff --git a/test/unit/document/helper.dart b/test/unit/document/helper.dart deleted file mode 100644 index 04bd034..0000000 --- a/test/unit/document/helper.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'dart:convert'; - -/// Strips types from a json object. -/// This way we can simulate real world use cases when JSON comes in a string -/// rather than a Dart object, thus having information about types inside the JSON. -recodeJson(j) => json.decode(json.encode(j)); diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart new file mode 100644 index 0000000..05a4755 --- /dev/null +++ b/test/unit/document/json_api_error_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('custom links', () { + test('recognizes custom links', () { + final e = JsonApiError( + links: {'my-link': Link(Uri.parse('http://example.com'))}); + expect(e.links['my-link'].toString(), 'http://example.com'); + }); + + test('if passed, "about" argument is merged into "links"', () { + final e = JsonApiError( + about: Link(Uri.parse('/about')), + links: {'my-link': Link(Uri.parse('http://example.com'))}); + expect(e.links['my-link'].toString(), 'http://example.com'); + expect(e.links['about'].toString(), '/about'); + expect(e.about.toString(), '/about'); + }); + + test('"links" may contain the "about" key', () { + final e = JsonApiError(links: { + 'my-link': Link(Uri.parse('http://example.com')), + 'about': Link(Uri.parse('/about')) + }); + expect(e.links['my-link'].toString(), 'http://example.com'); + expect(e.links['about'].toString(), '/about'); + expect(e.about.toString(), '/about'); + }); + + test('"about" argument takes precedence over "links"', () { + final e = JsonApiError( + about: Link(Uri.parse('/about')), + links: {'about': Link(Uri.parse('/will-be-replaced'))}); + expect(e.links['about'].toString(), '/about'); + }); + + test('custom "links" survives json serialization', () { + final e = JsonApiError( + links: {'my-link': Link(Uri.parse('http://example.com'))}); + expect( + JsonApiError.fromJson(json.decode(json.encode(e))) + .links['my-link'] + .toString(), + 'http://example.com'); + }); + }); +} diff --git a/test/unit/document/meta_members_test.dart b/test/unit/document/meta_members_test.dart index 9af8383..f0e6ce7 100644 --- a/test/unit/document/meta_members_test.dart +++ b/test/unit/document/meta_members_test.dart @@ -85,7 +85,7 @@ void main() { ] }; - final doc = Document.decodeJson(json, ResourceCollectionData.decodeJson); + final doc = Document.fromJson(json, ResourceCollectionData.fromJson); expect(doc.meta["bool"], true); expect(doc.data.collection.first.meta, meta); expect( diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart new file mode 100644 index 0000000..9459fb5 --- /dev/null +++ b/test/unit/document/relationship_test.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('custom links', () { + test('recognizes custom links', () { + final r = Relationship(links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "related" and "self" arguments are merged into "links"', + () { + final r = Relationship( + related: Link(Uri.parse('/related')), + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('"links" may contain the "related" and "self" keys', () { + final r = Relationship(links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/related')), + 'self': Link(Uri.parse('/self')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('"related" and "self" take precedence over "links"', () { + final r = Relationship( + self: Link(Uri.parse('/self')), + related: Link(Uri.parse('/related')), + links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/will-be-replaced')), + 'self': Link(Uri.parse('/will-be-replaced')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('custom "links" survives json serialization', () { + final r = Relationship(links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + Relationship.fromJson(json.decode(json.encode(r))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); +} diff --git a/test/unit/document/resource_collection_data_test.dart b/test/unit/document/resource_collection_data_test.dart new file mode 100644 index 0000000..6433acc --- /dev/null +++ b/test/unit/document/resource_collection_data_test.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:json_api/json_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('custom links', () { + test('recognizes custom links', () { + final r = ResourceCollectionData([], + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "self" and "navigation" arguments are merged into "links"', + () { + final r = ResourceCollectionData([], + navigation: Navigation( + next: Link(Uri.parse('/next')), prev: Link(Uri.parse('/prev'))), + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['next'].toString(), '/next'); + expect(r.links['prev'].toString(), '/prev'); + expect(r.self.toString(), '/self'); + expect(r.navigation.next.toString(), '/next'); + expect(r.navigation.prev.toString(), '/prev'); + }); + + test('"links" may contain the "self" and navigation keys', () { + final r = ResourceCollectionData([], links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/self')), + 'next': Link(Uri.parse('/next')), + 'prev': Link(Uri.parse('/prev')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['next'].toString(), '/next'); + expect(r.links['prev'].toString(), '/prev'); + expect(r.self.toString(), '/self'); + expect(r.navigation.next.toString(), '/next'); + expect(r.navigation.prev.toString(), '/prev'); + }); + + test('"self" and "navigation" takes precedence over "links"', () { + final r = ResourceCollectionData([], + self: Link(Uri.parse('/self')), + navigation: Navigation( + next: Link(Uri.parse('/next')), prev: Link(Uri.parse('/prev'))), + links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/will-be-replaced')), + 'next': Link(Uri.parse('/will-be-replaced')), + 'prev': Link(Uri.parse('/will-be-replaced')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['next'].toString(), '/next'); + expect(r.links['prev'].toString(), '/prev'); + expect(r.self.toString(), '/self'); + expect(r.navigation.next.toString(), '/next'); + expect(r.navigation.prev.toString(), '/prev'); + }); + + test('survives json serialization', () { + final r = ResourceCollectionData([], links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + ResourceCollectionData.fromJson(json.decode(json.encode(r))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); +} diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 3d0f078..3d22bc6 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -1,22 +1,86 @@ +import 'dart:convert'; + import 'package:json_api/json_api.dart'; import 'package:test/test.dart'; -import 'helper.dart'; - void main() { test('Can decode a primary resource with missing id', () { - final data = ResourceData.decodeJson(recodeJson({ + final data = ResourceData.fromJson(json.decode(json.encode({ 'data': {'type': 'apples'} - })); + }))); expect(data.unwrap().type, 'apples'); expect(data.unwrap().id, isNull); }); test('Can decode a primary resource with null id', () { - final data = ResourceData.decodeJson(recodeJson({ + final data = ResourceData.fromJson(json.decode(json.encode({ 'data': {'type': 'apples', 'id': null} - })); + }))); expect(data.unwrap().type, 'apples'); expect(data.unwrap().id, isNull); }); + + test('Inherits links from ResourceObject', () { + final res = ResourceObject('apples', '1', + self: Link(Uri.parse('/self')), + links: { + 'foo': Link(Uri.parse('/foo')), + 'bar': Link(Uri.parse('/bar')) + }); + final data = ResourceData(res, links: { + 'bar': Link(Uri.parse('/bar-new')), + }); + expect(data.links['foo'].toString(), '/foo'); + expect(data.links['bar'].toString(), '/bar-new'); + expect(data.self.toString(), '/self'); + }); + + group('custom links', () { + final res = ResourceObject('apples', '1'); + test('recognizes custom links', () { + final data = + ResourceData(res, links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(data.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "self" argument is merged into "links"', () { + final data = ResourceData(res, + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(data.links['my-link'].toString(), '/my-link'); + expect(data.links['self'].toString(), '/self'); + expect(data.self.toString(), '/self'); + }); + + test('"links" may contain the "self" key', () { + final data = ResourceData(res, links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/self')) + }); + expect(data.links['my-link'].toString(), '/my-link'); + expect(data.links['self'].toString(), '/self'); + expect(data.self.toString(), '/self'); + }); + + test('"self" takes precedence over "links"', () { + final data = ResourceData(res, self: Link(Uri.parse('/self')), links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/will-be-replaced')) + }); + expect(data.links['my-link'].toString(), '/my-link'); + expect(data.links['self'].toString(), '/self'); + expect(data.self.toString(), '/self'); + }); + + test('survives json serialization', () { + final data = ResourceData(res, links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + ResourceData.fromJson(json.decode(json.encode(data))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); } diff --git a/test/unit/document/resource_object_test.dart b/test/unit/document/resource_object_test.dart index 983dcd7..3fafde7 100644 --- a/test/unit/document/resource_object_test.dart +++ b/test/unit/document/resource_object_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:json_api/json_api.dart'; import 'package:json_matcher/json_matcher.dart'; import 'package:test/test.dart'; @@ -29,4 +31,53 @@ void main() { })); }); }); + + group('custom links', () { + test('recognizes custom links', () { + final r = ResourceObject('apples', '1', + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "self" argument is merged into "links"', () { + final r = ResourceObject('apples', '1', + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.self.toString(), '/self'); + }); + + test('"links" may contain the "self" key', () { + final r = ResourceObject('apples', '1', links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/self')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.self.toString(), '/self'); + }); + + test('"self" takes precedence over "links"', () { + final r = + ResourceObject('apples', '1', self: Link(Uri.parse('/self')), links: { + 'my-link': Link(Uri.parse('/my-link')), + 'self': Link(Uri.parse('/will-be-replaced')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.self.toString(), '/self'); + }); + + test('survives json serialization', () { + final r = ResourceObject('apples', '1', links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + ResourceObject.fromJson(json.decode(json.encode(r))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); } diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart new file mode 100644 index 0000000..ae42d8a --- /dev/null +++ b/test/unit/document/to_many_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('custom links', () { + test('recognizes custom links', () { + final r = ToMany([], links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "related" and "self" arguments are merged into "links"', + () { + final r = ToMany([], + related: Link(Uri.parse('/related')), + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + }); + + test('"links" may contain the "related" and "self" keys', () { + final r = ToMany([], links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/related')), + 'self': Link(Uri.parse('/self')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('"related" and "self" take precedence over "links"', () { + final r = ToMany([], + self: Link(Uri.parse('/self')), + related: Link(Uri.parse('/related')), + links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/will-be-replaced')), + 'self': Link(Uri.parse('/will-be-replaced')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('custom "links" survives json serialization', () { + final r = ToMany([], links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + ToMany.fromJson(json.decode(json.encode(r))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); +} diff --git a/test/unit/document/to_one_test.dart b/test/unit/document/to_one_test.dart new file mode 100644 index 0000000..743086e --- /dev/null +++ b/test/unit/document/to_one_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('custom links', () { + test('recognizes custom links', () { + final r = ToOne(null, links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + }); + + test('if passed, "related" and "self" arguments are merged into "links"', + () { + final r = ToOne(null, + related: Link(Uri.parse('/related')), + self: Link(Uri.parse('/self')), + links: {'my-link': Link(Uri.parse('/my-link'))}); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + }); + + test('"links" may contain the "related" and "self" keys', () { + final r = ToOne(null, links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/related')), + 'self': Link(Uri.parse('/self')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('"related" and "self" take precedence over "links"', () { + final r = ToOne(null, + self: Link(Uri.parse('/self')), + related: Link(Uri.parse('/related')), + links: { + 'my-link': Link(Uri.parse('/my-link')), + 'related': Link(Uri.parse('/will-be-replaced')), + 'self': Link(Uri.parse('/will-be-replaced')) + }); + expect(r.links['my-link'].toString(), '/my-link'); + expect(r.links['self'].toString(), '/self'); + expect(r.links['related'].toString(), '/related'); + expect(r.self.toString(), '/self'); + expect(r.related.toString(), '/related'); + }); + + test('custom "links" survives json serialization', () { + final r = ToOne(null, links: { + 'my-link': Link(Uri.parse('/my-link')), + }); + expect( + ToOne.fromJson(json.decode(json.encode(r))) + .links['my-link'] + .toString(), + '/my-link'); + }); + }); +} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index e5e7ec4..70d25c0 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -5,7 +5,7 @@ void main() { test('Can decode url', () { final uri = Uri.parse( '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); - final fields = Fields.decode(uri.queryParametersAll); + final fields = Fields.fromUri(uri); expect(fields['articles'], ['title', 'body']); expect(fields['people'], ['name']); }); @@ -17,7 +17,7 @@ void main() { }); final uri = Uri.parse('/articles'); - expect(fields.addTo(uri).toString(), + expect(fields.addToUri(uri).toString(), '/articles?fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); }); } diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index 770452b..4cb87dc 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Can decode url', () { final uri = Uri.parse('/articles/1?include=author,comments.author'); - final include = Include.decode(uri.queryParametersAll); + final include = Include.fromUri(uri); expect(include.length, 2); expect(include.first, 'author'); expect(include.last, 'comments.author'); @@ -12,7 +12,7 @@ void main() { test('Can add to uri', () { final uri = Uri.parse('/articles/1'); final include = Include(['author', 'comments.author']); - expect(include.addTo(uri).toString(), + expect(include.addToUri(uri).toString(), '/articles/1?include=author%2Ccomments.author'); }); } diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart index 5a6c14f..931ca73 100644 --- a/test/unit/query/page_test.dart +++ b/test/unit/query/page_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Can decode url', () { final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); - final page = Page.decode(uri.queryParametersAll); + final page = Page.fromUri(uri); expect(page['limit'], '10'); expect(page['offset'], '20'); }); @@ -13,7 +13,7 @@ void main() { final fields = Page({'limit': '10', 'offset': '20'}); final uri = Uri.parse('/articles'); - expect(fields.addTo(uri).toString(), + expect(fields.addToUri(uri).toString(), '/articles?page%5Blimit%5D=10&page%5Boffset%5D=20'); }); } diff --git a/test/unit/query/query_test.dart b/test/unit/query/query_test.dart new file mode 100644 index 0000000..f185959 --- /dev/null +++ b/test/unit/query/query_test.dart @@ -0,0 +1,33 @@ +import 'package:json_api/query.dart'; +import 'package:test/test.dart'; + +void main() { + final page = Page({"limit": "10", "offset": "3"}); + final include = Include(["foo", "bar"]); + final fields = Fields({ + "foo": ["bar"] + }); + final sort = Sort().asc("foo").desc("bar"); + + test('query can be constructed of independent elements', () { + final query = + Query(page: page, include: include, fields: fields, sort: sort); + + expect(query.addToUri(Uri.parse('http://example.com')).toString(), + 'http://example.com?page%5Blimit%5D=10&page%5Boffset%5D=3&include=foo%2Cbar&fields%5Bfoo%5D=bar&sort=foo%2C-bar'); + }); + + test('query elements are optional', () { + final query = Query(page: page, fields: fields); + + expect(query.addToUri(Uri.parse('http://example.com')).toString(), + 'http://example.com?page%5Blimit%5D=10&page%5Boffset%5D=3&fields%5Bfoo%5D=bar'); + }); + + test('query can be empty, in this case it does not change the uri', () { + final query = Query(); + + expect(query.addToUri(Uri.parse('http://example.com')).toString(), + 'http://example.com'); + }); +} diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index a139728..740fa51 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Can decode url', () { final uri = Uri.parse('/articles?sort=-created,title'); - final sort = Sort.decode(uri.queryParametersAll); + final sort = Sort.fromUri(uri); expect(sort.length, 2); expect(sort.first.isDesc, true); expect(sort.first.name, 'created'); @@ -15,6 +15,6 @@ void main() { test('Can add to uri', () { final sort = Sort().desc('created').asc('title'); final uri = Uri.parse('/articles'); - expect(sort.addTo(uri).toString(), '/articles?sort=-created%2Ctitle'); + expect(sort.addToUri(uri).toString(), '/articles?sort=-created%2Ctitle'); }); } diff --git a/test/unit/url_design/path_based_url_design_test.dart b/test/unit/url_design/path_based_url_design_test.dart index f719c20..9193a85 100644 --- a/test/unit/url_design/path_based_url_design_test.dart +++ b/test/unit/url_design/path_based_url_design_test.dart @@ -1,8 +1,10 @@ +import 'package:json_api/server.dart'; import 'package:json_api/url_design.dart'; import 'package:test/test.dart'; void main() { final routing = PathBasedUrlDesign(Uri.parse('http://example.com/api')); + final mapper = _Mapper(); group('URL construction', () { test('Collection URL adds type', () { @@ -27,106 +29,67 @@ void main() { }); group('URL matching', () { - String type; - String id; - String relationship; - - final doNotCall = ([a, b, c]) => throw 'Invalid match ${[a, b, c]}'; - - setUp(() { - type = null; - id = null; - relationship = null; - }); - test('Matches collection URL', () { - routing.match( - Uri.parse('http://example.com/api/books'), - onCollection: (_) => type = _, - onResource: doNotCall, - onRelationship: doNotCall, - onRelated: doNotCall, - ); - expect(type, 'books'); + expect(routing.match(Uri.parse('http://example.com/api/books'), mapper), + 'collection:books'); }); test('Matches resource URL', () { - routing.match( - Uri.parse('http://example.com/api/books/42'), - onCollection: doNotCall, - onResource: (a, b) { - type = a; - id = b; - }, - onRelationship: doNotCall, - onRelated: doNotCall, - ); - expect(type, 'books'); - expect(id, '42'); + expect( + routing.match(Uri.parse('http://example.com/api/books/42'), mapper), + 'resource:books:42'); }); test('Matches related URL', () { - routing.match( - Uri.parse('http://example.com/api/books/42/authors'), - onCollection: doNotCall, - onResource: doNotCall, - onRelated: (a, b, c) { - type = a; - id = b; - relationship = c; - }, - onRelationship: doNotCall, - ); - expect(type, 'books'); - expect(id, '42'); - expect(relationship, 'authors'); + expect( + routing.match( + Uri.parse('http://example.com/api/books/42/authors'), mapper), + 'related:books:42:authors'); }); test('Matches relationship URL', () { - routing.match( - Uri.parse('http://example.com/api/books/42/relationships/authors'), - onCollection: doNotCall, - onResource: doNotCall, - onRelationship: (a, b, c) { - type = a; - id = b; - relationship = c; - }, - onRelated: doNotCall, - ); - expect(type, 'books'); - expect(id, '42'); - expect(relationship, 'authors'); + expect( + routing.match( + Uri.parse( + 'http://example.com/api/books/42/relationships/authors'), + mapper), + 'relationship:books:42:authors'); }); test('Does not match collection URL with incorrect path', () { - routing.match( - Uri.parse('http://example.com/foo/apples'), - onCollection: doNotCall, - onResource: doNotCall, - onRelationship: doNotCall, - onRelated: doNotCall, - ); + expect(routing.match(Uri.parse('http://example.com/foo/apples'), mapper), + 'unmatched'); }); test('Does not match collection URL with incorrect host', () { - routing.match( - Uri.parse('http://example.org/api/apples'), - onCollection: doNotCall, - onResource: doNotCall, - onRelationship: doNotCall, - onRelated: doNotCall, - ); + expect(routing.match(Uri.parse('http://example.org/api/apples'), mapper), + 'unmatched'); }); test('Does not match collection URL with incorrect port', () { - routing.match( - Uri.parse('http://example.com:8080/api/apples'), - onCollection: doNotCall, - onResource: doNotCall, - onRelationship: doNotCall, - onRelated: doNotCall, - ); + expect( + routing.match( + Uri.parse('http://example.com:8080/api/apples'), mapper), + 'unmatched'); }); }); } + +class _Mapper implements MatchCase { + @override + unmatched() => 'unmatched'; + + @override + String collection(String type) => 'collection:$type'; + + @override + String related(String type, String id, String relationship) => + 'related:$type:$id:$relationship'; + + @override + String relationship(String type, String id, String relationship) => + 'relationship:$type:$id:$relationship'; + + @override + String resource(String type, String id) => 'resource:$type:$id'; +}