From 0a1574e1cbbb47de3b60f034b159abaa159890f5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 7 Jan 2020 21:42:53 -0800 Subject: [PATCH 01/99] wip --- example/README.md | 4 +- example/cars_server.dart | 69 --- example/cars_server/collection.dart | 9 - example/cars_server/controller.dart | 216 ------- example/cars_server/dao.dart | 187 ------ example/cars_server/job_queue.dart | 15 - example/cars_server/model.dart | 29 - example/fetch_collection.dart | 2 +- example/server.dart | 167 ++++++ lib/server.dart | 14 +- lib/src/client/json_api_client.dart | 2 +- lib/src/document/identifier.dart | 5 + lib/src/document/resource.dart | 3 + lib/src/document/resource_data.dart | 3 +- lib/src/server/http_handler.dart | 48 ++ lib/src/server/json_api_controller.dart | 232 +++++++- lib/src/server/json_api_request.dart | 13 - lib/src/server/json_api_server.dart | 69 --- .../server/response/accepted_response.dart | 6 +- .../server/response/collection_response.dart | 2 +- lib/src/server/response/error_response.dart | 2 +- .../server/response/json_api_response.dart | 8 +- lib/src/server/response/meta_response.dart | 2 +- .../server/response/no_content_response.dart | 2 +- .../response/related_collection_response.dart | 2 +- .../response/related_resource_response.dart | 2 +- .../response/resource_created_response.dart | 10 +- .../server/response/resource_response.dart | 2 +- .../response/resource_updated_response.dart | 2 +- .../server/response/see_other_response.dart | 13 +- lib/src/server/response/to_many_response.dart | 2 +- lib/src/server/response/to_one_response.dart | 2 +- lib/src/server/routing/route.dart | 128 ---- lib/src/server/routing/route_factory.dart | 23 - lib/src/server/server_document_factory.dart | 20 +- lib/src/server/target.dart | 175 ++++++ lib/src/url_design/path_based_url_design.dart | 3 + test/functional/create_test.dart | 98 +-- test/functional/delete_test.dart | 52 +- test/functional/fetch_test.dart | 556 +++++++++++------- test/functional/hooks_test.dart | 31 +- test/functional/test_server.dart | 2 +- test/functional/update_test.dart | 2 +- test/unit/document/resource_data_test.dart | 7 + 44 files changed, 1139 insertions(+), 1102 deletions(-) delete mode 100644 example/cars_server.dart delete mode 100644 example/cars_server/collection.dart delete mode 100644 example/cars_server/controller.dart delete mode 100644 example/cars_server/dao.dart delete mode 100644 example/cars_server/job_queue.dart delete mode 100644 example/cars_server/model.dart create mode 100644 example/server.dart create mode 100644 lib/src/server/http_handler.dart delete mode 100644 lib/src/server/json_api_request.dart delete mode 100644 lib/src/server/json_api_server.dart delete mode 100644 lib/src/server/routing/route.dart delete mode 100644 lib/src/server/routing/route_factory.dart create mode 100644 lib/src/server/target.dart diff --git a/example/README.md b/example/README.md index 7704f96e..c66508be 100644 --- a/example/README.md +++ b/example/README.md @@ -1,10 +1,10 @@ # JSON:API examples -## [Cars Server](./cars_server) +## [Server](./server.dart) This is a simple JSON:API server which is used in the tests. It provides an API to a collection to car companies and models. You can run it locally to play around. -- In you console run `dart example/cars_server.dart`, this will start the server at port 8080. +- In you console run `dart example/server.dart`, this will start the server at port 8080. - Open http://localhost:8080/companies in the browser. ## [Fetch example](./fetch_collection.dart) diff --git a/example/cars_server.dart b/example/cars_server.dart deleted file mode 100644 index da9c17b8..00000000 --- a/example/cars_server.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; -import 'package:pedantic/pedantic.dart'; - -import 'cars_server/controller.dart'; -import 'cars_server/dao.dart'; -import 'cars_server/model.dart'; - -void main() async { - final addr = InternetAddress.loopbackIPv4; - final port = 8080; - await createServer(addr, port); - print('Listening on ${addr.host}:$port'); -} - -Future createServer(InternetAddress addr, int port) async { - final models = ModelDAO(); - [ - Model('1')..name = 'Roadster', - Model('2')..name = 'Model S', - Model('3')..name = 'Model X', - Model('4')..name = 'Model 3', - Model('5')..name = 'X1', - Model('6')..name = 'X3', - Model('7')..name = 'X5', - ].forEach(models.insert); - - final cities = CityDAO(); - [ - City('1')..name = 'Munich', - City('2')..name = 'Palo Alto', - City('3')..name = 'Ingolstadt', - ].forEach(cities.insert); - - final companies = CompanyDAO(); - [ - Company('1') - ..name = 'Tesla' - ..headquarters = '2' - ..models.addAll(['1', '2', '3', '4']), - Company('2') - ..name = 'BMW' - ..headquarters = '1', - Company('3')..name = 'Audi', - Company('4')..name = 'Toyota', - ].forEach(companies.insert); - - final pagination = FixedSizePage(1); - - final controller = CarsController({ - 'companies': companies, - 'cities': cities, - 'models': models, - 'jobs': JobDAO() - }, pagination); - - final httpServer = await HttpServer.bind(addr, port); - final urlDesign = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); - final jsonApiServer = JsonApiServer(urlDesign, controller, - documentFactory: - ServerDocumentFactory(urlDesign, pagination: pagination)); - - unawaited(httpServer.forEach(jsonApiServer.serve)); - return httpServer; -} diff --git a/example/cars_server/collection.dart b/example/cars_server/collection.dart deleted file mode 100644 index 5d23a007..00000000 --- a/example/cars_server/collection.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// A collection of elements (e.g. resources) returned by the server. -class Collection { - final Iterable elements; - - /// Total count of the elements on the server. May be null. - final int totalCount; - - Collection(this.elements, [this.totalCount]); -} diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart deleted file mode 100644 index 33af552c..00000000 --- a/example/cars_server/controller.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:uuid/uuid.dart'; - -import 'dao.dart'; -import 'job_queue.dart'; - -class CarsController - implements JsonApiController { - final Map _dao; - - final PaginationStrategy _pagination; - - CarsController(this._dao, this._pagination); - - @override - JsonApiResponse fetchCollection(String type, JsonApiRequest request) { - final page = Page.fromUri(request.requestedUri); - final dao = _getDaoOrThrow(type); - final collection = - dao.fetchCollection(_pagination.limit(page), _pagination.offset(page)); - return CollectionResponse(collection.elements.map(dao.toResource), - total: collection.totalCount); - } - - @override - JsonApiResponse fetchRelated( - String type, String id, String relationship, JsonApiRequest request) { - final res = _fetchResourceOrThrow(type, id); - final page = Page.fromUri(request.requestedUri); - 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(relationship)) { - final relationships = res.toMany[relationship]; - final resources = relationships - .skip(_pagination.offset(page)) - .take(_pagination.limit(page)) - .map((id) => _dao[id.type].fetchByIdAsResource(id.id)); - return RelatedCollectionResponse(resources, total: relationships.length); - } - return ErrorResponse.notFound( - [JsonApiError(detail: 'Relationship not found')]); - } - - @override - JsonApiResponse fetchResource( - String type, String id, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - final obj = dao.fetchById(id); - final include = Include.fromUri(request.requestedUri); - - if (obj == null) { - return ErrorResponse.notFound( - [JsonApiError(detail: 'Resource not found')]); - } - if (obj is Job && obj.resource != null) { - return SeeOtherResponse(obj.resource); - } - - final fetchById = (Identifier _) => _dao[_.type].fetchByIdAsResource(_.id); - - final res = dao.toResource(obj); - - var filter = _filter(res.toMany, include.contains); - var followedBy = _filter(res.toOne, include.contains) - .values - .map(fetchById) - .followedBy(filter.values.expand((_) => _.map(fetchById))); - return ResourceResponse(res, included: followedBy); - } - - @override - JsonApiResponse fetchRelationship( - String type, String id, String relationship, JsonApiRequest request) { - final res = _fetchResourceOrThrow(type, id); - - if (res.toOne.containsKey(relationship)) { - return ToOneResponse(type, id, relationship, res.toOne[relationship]); - } - - if (res.toMany.containsKey(relationship)) { - return ToManyResponse(type, id, relationship, res.toMany[relationship]); - } - return ErrorResponse.notFound( - [JsonApiError(detail: 'Relationship not found')]); - } - - @override - JsonApiResponse deleteResource( - String type, String id, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - final res = dao.fetchByIdAsResource(id); - if (res == null) { - throw ErrorResponse.notFound( - [JsonApiError(detail: 'Resource not found')]); - } - final dependenciesCount = dao.deleteById(id); - if (dependenciesCount == 0) { - return NoContentResponse(); - } - return MetaResponse({'dependenciesCount': dependenciesCount}); - } - - @override - JsonApiResponse createResource( - String type, Resource resource, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - _throwIfIncompatibleTypes(type, resource); - - if (resource.id != null) { - if (dao.fetchById(resource.id) != null) { - return ErrorResponse.conflict( - [JsonApiError(detail: 'Resource already exists')]); - } - dao.insert(dao.create(resource)); - return NoContentResponse(); - } - - final created = dao.create(Resource(resource.type, Uuid().v4(), - attributes: resource.attributes, - toMany: resource.toMany, - toOne: resource.toOne)); - - if (type == 'models') { - // Insertion is artificially delayed - final job = Job(Future.delayed(Duration(milliseconds: 100), () { - dao.insert(created); - return dao.toResource(created); - })); - _dao['jobs'].insert(job); - return AcceptedResponse(_dao['jobs'].toResource(job)); - } - - dao.insert(created); - - return ResourceCreatedResponse(dao.toResource(created)); - } - - @override - JsonApiResponse updateResource( - String type, String id, Resource resource, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - _throwIfIncompatibleTypes(type, resource); - if (dao.fetchById(id) == null) { - return ErrorResponse.notFound( - [JsonApiError(detail: 'Resource not found')]); - } - final updated = dao.update(id, resource); - if (updated == null) { - return NoContentResponse(); - } - return ResourceUpdatedResponse(updated); - } - - @override - JsonApiResponse replaceToOne(String type, String id, String relationship, - Identifier identifier, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - dao.replaceToOne(id, relationship, identifier); - return NoContentResponse(); - } - - @override - JsonApiResponse replaceToMany(String type, String id, String relationship, - List identifiers, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - dao.replaceToMany(id, relationship, identifiers); - return NoContentResponse(); - } - - @override - JsonApiResponse addToMany(String type, String id, String relationship, - List identifiers, JsonApiRequest request) { - final dao = _getDaoOrThrow(type); - - return ToManyResponse( - type, id, relationship, dao.addToMany(id, relationship, identifiers)); - } - - void _throwIfIncompatibleTypes(String type, Resource resource) { - if (type != resource.type) { - throw ErrorResponse.conflict([JsonApiError(detail: 'Incompatible type')]); - } - } - - DAO _getDaoOrThrow(String type) { - if (_dao.containsKey(type)) return _dao[type]; - - throw ErrorResponse.notFound( - [JsonApiError(detail: 'Unknown resource type ${type}')]); - } - - 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')]); - } - return resource; - } - - Map _filter(Map map, bool Function(T t) f) => - {...map}..removeWhere((k, _) => !f(k)); -} diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart deleted file mode 100644 index 2e0158fb..00000000 --- a/example/cars_server/dao.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:json_api/document.dart'; - -import 'collection.dart'; -import 'job_queue.dart'; -import 'model.dart'; - -abstract class DAO { - final _collection = {}; - - int get length => _collection.length; - - Resource toResource(T t); - - T create(Resource resource); - - T fetchById(String id) => _collection[id]; - - Resource fetchByIdAsResource(String id) => - _collection.containsKey(id) ? toResource(_collection[id]) : null; - - void insert(T t); // => collection[t.id] = t; - - Collection fetchCollection(int limit, int offset) => Collection( - _collection.values.skip(offset).take(limit).toList(), _collection.length); - - /// Returns the number of depending objects the entity had - int deleteById(String id) { - _collection.remove(id); - return 0; - } - - Resource update(String id, Resource resource) { - throw UnimplementedError(); - } - - void replaceToOne(String id, String relationship, Identifier identifier) { - throw UnimplementedError(); - } - - void replaceToMany( - String id, String relationship, Iterable identifiers) { - throw UnimplementedError(); - } - - List addToMany( - String id, String relationship, Iterable identifiers) { - throw UnimplementedError(); - } -} - -class ModelDAO extends DAO { - @override - Resource toResource(Model _) => - Resource('models', _.id, attributes: {'name': _.name}); - - @override - void insert(Model model) => _collection[model.id] = model; - - @override - Model create(Resource r) { - return Model(r.id)..name = r.attributes['name']; - } - - @override - Resource update(String id, Resource resource) { - _collection[id].name = resource.attributes['name']; - return null; - } -} - -class CityDAO extends DAO { - @override - Resource toResource(City _) => - Resource('cities', _.id, attributes: {'name': _.name}); - - @override - void insert(City city) => _collection[city.id] = city; - - @override - City create(Resource r) { - return City(r.id)..name = r.attributes['name']; - } -} - -class CompanyDAO extends DAO { - @override - Resource toResource(Company company) => - Resource('companies', company.id, attributes: { - 'name': company.name, - 'nasdaq': company.nasdaq, - 'updatedAt': company.updatedAt.toIso8601String() - }, toOne: { - 'hq': company.headquarters == null - ? null - : Identifier('cities', company.headquarters) - }, toMany: { - 'models': company.models.map((_) => Identifier('models', _)).toList() - }); - - @override - void insert(Company company) { - company.updatedAt = DateTime.now(); - _collection[company.id] = company; - } - - @override - Company create(Resource r) { - return Company(r.id) - ..name = r.attributes['name'] - ..updatedAt = DateTime.now(); - } - - @override - int deleteById(String id) { - final company = fetchById(id); - var deps = company.headquarters == null ? 0 : 1; - deps += company.models.length; - _collection.remove(id); - return deps; - } - - @override - Resource update(String id, Resource resource) { - // TODO: What if Resource type or id is changed? - final company = _collection[id]; - if (resource.attributes.containsKey('name')) { - company.name = resource.attributes['name']; - } - if (resource.attributes.containsKey('nasdaq')) { - company.nasdaq = resource.attributes['nasdaq']; - } - if (resource.toOne.containsKey('hq')) { - company.headquarters = resource.toOne['hq']?.id; - } - if (resource.toMany.containsKey('models')) { - company.models.clear(); - company.models.addAll(resource.toMany['models'].map((_) => _.id)); - } - company.updatedAt = DateTime.now(); - return toResource(company); - } - - @override - void replaceToOne(String id, String relationship, Identifier identifier) { - final company = _collection[id]; - switch (relationship) { - case 'hq': - company.headquarters = identifier?.id; - } - } - - @override - void replaceToMany( - String id, String relationship, Iterable identifiers) { - final company = _collection[id]; - switch (relationship) { - case 'models': - company.models.clear(); - company.models.addAll(identifiers.map((_) => _.id)); - } - } - - @override - List addToMany( - String id, String relationship, Iterable identifiers) { - final company = _collection[id]; - switch (relationship) { - case 'models': - company.models.addAll(identifiers.map((_) => _.id)); - return company.models.map((_) => Identifier('models', _)).toList(); - } - throw ArgumentError(); - } -} - -class JobDAO extends DAO { - @override - Job create(Resource resource) { - throw UnsupportedError('Jobs are created internally'); - } - - @override - void insert(Job job) => _collection[job.id] = job; - - @override - Resource toResource(Job job) => Resource('jobs', job.id); -} diff --git a/example/cars_server/job_queue.dart b/example/cars_server/job_queue.dart deleted file mode 100644 index 64c3f30d..00000000 --- a/example/cars_server/job_queue.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:uuid/uuid.dart'; - -class Job { - final String id; - - String get status => resource == null ? 'pending' : 'complete'; - Resource resource; - - Job(Future create) : id = Uuid().v4() { - create.then((_) => resource = _); - } -} diff --git a/example/cars_server/model.dart b/example/cars_server/model.dart deleted file mode 100644 index 6772e8b0..00000000 --- a/example/cars_server/model.dart +++ /dev/null @@ -1,29 +0,0 @@ -class Company { - final String id; - String headquarters; - final models = {}; - - /// Company name - String name; - - /// NASDAQ symbol - String nasdaq; - - DateTime updatedAt = DateTime.now(); - - Company(this.id); -} - -class City { - final String id; - String name; - - City(this.id); -} - -class Model { - final String id; - String name; - - Model(this.id); -} diff --git a/example/fetch_collection.dart b/example/fetch_collection.dart index 48c6838b..0a15770a 100644 --- a/example/fetch_collection.dart +++ b/example/fetch_collection.dart @@ -1,7 +1,7 @@ import 'package:http/http.dart'; import 'package:json_api/client.dart'; -/// Start `dart example/cars_server.dart` first +/// Start `dart example/server.dart` first void main() async { final httpClient = Client(); final jsonApiClient = JsonApiClient(httpClient); diff --git a/example/server.dart b/example/server.dart new file mode 100644 index 00000000..a608b3da --- /dev/null +++ b/example/server.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/http_handler.dart'; +import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:uuid/uuid.dart'; + +/// This example shows how to build a simple CRUD server on top of Dart Shelf +void main() async { + final host = 'localhost'; + final port = 8080; + final baseUri = Uri(scheme: 'http', host: host, port: port); + final jsonApiHandler = createHttpHandler(ShelfRequestResponseConverter(), + CRUDController(), PathBasedUrlDesign(baseUri)); + + await serve(jsonApiHandler, host, port); + print('Serving at $baseUri'); +} + +class ShelfRequestResponseConverter + implements HttpMessageConverter { + @override + FutureOr createResponse( + int statusCode, String body, Map headers) => + Response(statusCode, body: body, headers: headers); + + @override + FutureOr getBody(Request request) => request.readAsString(); + + @override + FutureOr getMethod(Request request) => request.method; + + @override + FutureOr getUri(Request request) => request.requestedUri; +} + +class CRUDController implements JsonApiController { + final store = >{}; + + @override + FutureOr createResource( + Request request, String type, Resource resource) { + if (resource.type != type) { + return ErrorResponse.conflict( + [JsonApiError(detail: 'Incompatible type')]); + } + final repo = _repo(type); + if (resource.id != null) { + if (repo.containsKey(resource.id)) { + return ErrorResponse.conflict( + [JsonApiError(detail: 'Resource already exists')]); + } + repo[resource.id] = resource; + return NoContentResponse(); + } + final id = Uuid().v4(); + repo[id] = resource.withId(id); + return ResourceCreatedResponse(repo[id]); + } + + @override + FutureOr fetchResource( + Request request, String type, String id) { + final repo = _repo(type); + if (repo.containsKey(id)) { + return ResourceResponse(repo[id]); + } + return ErrorResponse.notFound( + [JsonApiError(detail: 'Resource not found', status: '404')]); + } + + @override + FutureOr addToRelationship( + Request request, String type, String id, String relationship) { + // TODO: implement addToRelationship + return null; + } + + @override + FutureOr deleteFromRelationship( + Request request, String type, String id, String relationship) { + // TODO: implement deleteFromRelationship + return null; + } + + @override + FutureOr deleteResource( + Request request, String type, String id) { + final repo = _repo(type); + if (!repo.containsKey(id)) { + return ErrorResponse.notFound( + [JsonApiError(detail: 'Resource not found')]); + } + final resource = repo[id]; + repo.remove(id); + final relationships = {...resource.toOne, ...resource.toMany}; + if (relationships.isNotEmpty) { + return MetaResponse({'relationships': relationships.length}); + } + return NoContentResponse(); + } + + @override + FutureOr fetchCollection(Request request, String type) { + final repo = _repo(type); + return CollectionResponse(repo.values); + } + + @override + FutureOr fetchRelated( + Request request, String type, String id, String relationship) { + final resource = _repo(type)[id]; + if (resource == null) { + return ErrorResponse.notFound( + [JsonApiError(detail: 'Resource not found')]); + } + if (resource.toOne.containsKey(relationship)) { + final related = resource.toOne[relationship]; + if (related == null) { + return RelatedResourceResponse(null); + } + return RelatedResourceResponse(_repo(related.type)[related.id]); + } + if (resource.toMany.containsKey(relationship)) { + final related = resource.toMany[relationship]; + return RelatedCollectionResponse(related.map((r) => _repo(r.type)[r.id])); + } + return ErrorResponse.notFound( + [JsonApiError(detail: 'Relatioship not found')]); + } + + @override + FutureOr fetchRelationship( + Request request, String type, String id, String relationship) { + // TODO: implement fetchRelationship + return null; + } + + @override + FutureOr updateResource( + Request request, String type, String id) { + // TODO: implement updateResource + return null; + } + + @override + FutureOr updateToMany( + Request request, String type, String id, String relationship) { + // TODO: implement updateToMany + return null; + } + + @override + FutureOr updateToOne( + Request request, String type, String id, String relationship) { + // TODO: implement updateToOne + return null; + } + + Map _repo(String type) { + store.putIfAbsent(type, () => {}); + return store[type]; + } +} diff --git a/lib/server.dart b/lib/server.dart index 43aa295e..37e825ba 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,15 +1,10 @@ -/// The JSON:API Server. +/// # The JSON:API Server /// -/// **WARNING!** -/// -/// **The server is still under development! Not for production!** -/// -/// **The API is not stable!** +/// The server API is not stable. Expect breaking changes. library server; +export 'package:json_api/src/server/http_handler.dart'; export 'package:json_api/src/server/json_api_controller.dart'; -export 'package:json_api/src/server/json_api_request.dart'; -export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination/fixed_size_page.dart'; export 'package:json_api/src/server/pagination/pagination_strategy.dart'; export 'package:json_api/src/server/response/accepted_response.dart'; @@ -26,6 +21,5 @@ export 'package:json_api/src/server/response/resource_updated_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/routing/route.dart'; -export 'package:json_api/src/server/routing/route_factory.dart'; export 'package:json_api/src/server/server_document_factory.dart'; +export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 8cbf9daf..ea2ace26 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -15,7 +15,7 @@ import 'package:json_api/src/client/status_code.dart'; /// import 'package:http/http.dart'; /// import 'package:json_api/client.dart'; /// -/// /// Start `dart example/cars_server.dart` first! +/// /// Start `dart example/server.dart` first! /// void main() async { /// final httpClient = Client(); /// final jsonApiClient = JsonApiClient(httpClient); diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 2e0711d6..f63e1ac9 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,3 +1,5 @@ +import 'package:json_api/document.dart'; + /// Resource identifier /// /// Together with [Resource] forms the core of the Document model. @@ -16,6 +18,9 @@ class Identifier { ArgumentError.checkNotNull(type, 'type'); } + static Identifier of(Resource resource) => + Identifier(resource.type, resource.id); + /// Returns true if the two identifiers have the same [type] and [id] bool equals(Identifier other) => other != null && diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 3cfcbef0..81c8d38e 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -45,4 +45,7 @@ class Resource { @override String toString() => 'Resource(${type}:${id})'; + + Resource withId(String id) => + Resource(type, id, attributes: attributes, toOne: toOne, toMany: toMany); } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index f89be68f..c7c3fa88 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -10,9 +10,10 @@ class ResourceData extends PrimaryData { ResourceData(this.resourceObject, {Iterable included, Map links}) - : super(included: included, links: {...?resourceObject.links, ...?links}); + : super(included: included, links: {...?resourceObject?.links, ...?links}); static ResourceData fromJson(Object json) { + print(json); if (json is Map) { final included = json['included']; final resources = []; diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart new file mode 100644 index 00000000..2f1cd95d --- /dev/null +++ b/lib/src/server/http_handler.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/pagination/no_pagination.dart'; +import 'package:json_api/src/server/pagination/pagination_strategy.dart'; +import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/server/target.dart'; +import 'package:json_api/url_design.dart'; + +typedef HttpHandler = Future Function( + Request request); + +HttpHandler createHttpHandler( + HttpMessageConverter converter, + JsonApiController controller, + UrlDesign urlDesign, + {PaginationStrategy pagination = const NoPagination()}) { + const targetFactory = TargetFactory(); + const requestFactory = ControllerRequestFactory(); + final docFactory = ServerDocumentFactory(urlDesign, pagination: pagination); + + return (Request request) async { + final uri = await converter.getUri(request); + final method = await converter.getMethod(request); + final body = await converter.getBody(request); + final target = urlDesign.match(uri, targetFactory); + final requestDocument = body.isEmpty ? null : json.decode(body); + final response = await target + .getRequest(method, requestFactory) + .call(controller, requestDocument, request); + return converter.createResponse( + response.statusCode, + json.encode(response.buildDocument(docFactory, uri)), + response.buildHeaders(urlDesign)); + }; +} + +abstract class HttpMessageConverter { + FutureOr getMethod(Request request); + + FutureOr getUri(Request request); + + FutureOr getBody(Request request); + + FutureOr createResponse( + int statusCode, String body, Map headers); +} diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index 7b28bac1..9a2b017e 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -1,32 +1,224 @@ +import 'dart:async'; + 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/server/json_api_request.dart'; +import 'package:json_api/src/server/response/json_api_response.dart'; +import 'package:json_api/src/server/target.dart'; + +abstract class JsonApiController { + FutureOr fetchCollection(R request, String type); + + FutureOr fetchResource(R request, String type, String id); + + FutureOr fetchRelated( + R request, String type, String id, String relationship); + + FutureOr fetchRelationship( + R request, String type, String id, String relationship); + + FutureOr deleteResource( + R request, String type, String id); + + FutureOr createResource( + R request, String type, Resource resource); + + FutureOr updateResource( + R request, String type, String id); + + FutureOr updateToOne( + R request, String type, String id, String relationship); + + FutureOr updateToMany( + R request, String type, String id, String relationship); + + FutureOr deleteFromRelationship( + R request, String type, String id, String relationship); + + FutureOr addToRelationship( + R request, String type, String id, String relationship); +} + +abstract class ControllerRequest { + /// Calls the appropriate method of the controller + FutureOr call( + JsonApiController controller, Object jsonPayload, R request); +} + +class ControllerRequestFactory implements RequestFactory { + const ControllerRequestFactory(); + + @override + ControllerRequest addToRelationship(RelationshipTarget target) => + _AddToRelationship(target); + + @override + ControllerRequest createResource(CollectionTarget target) => + CreateResource(target); + + @override + ControllerRequest deleteFromRelationship(RelationshipTarget target) => + _DeleteFromRelationship(target); + + @override + ControllerRequest deleteResource(ResourceTarget target) => + _DeleteResource(target); + + @override + ControllerRequest fetchCollection(CollectionTarget target) => + _FetchCollection(target); + + @override + ControllerRequest fetchRelated(RelatedTarget target) => _FetchRelated(target); + + @override + ControllerRequest fetchRelationship(RelationshipTarget target) => + _FetchRelationship(target); + + @override + ControllerRequest fetchResource(ResourceTarget target) => + _FetchResource(target); + + @override + ControllerRequest invalid(Target target, String method) => + _InvalidRequest(target, method); + + @override + ControllerRequest updateRelationship(RelationshipTarget target) => + _UpdateRelationship(target); + + @override + ControllerRequest updateResource(ResourceTarget target) => + _UpdateResource(target); +} + +class _AddToRelationship implements ControllerRequest { + final RelationshipTarget target; + + _AddToRelationship(this.target); -abstract class JsonApiController { - Response fetchCollection(String type, Request request); + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} + +class _DeleteFromRelationship implements ControllerRequest { + _DeleteFromRelationship(RelationshipTarget target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} + +class _UpdateResource implements ControllerRequest { + final ResourceTarget target; + + _UpdateResource(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} + +class CreateResource implements ControllerRequest { + final CollectionTarget target; + + CreateResource(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.createResource( + request, target.type, ResourceData.fromJson(jsonPayload).unwrap()); +} + +class _DeleteResource implements ControllerRequest { + final ResourceTarget target; + + _DeleteResource(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteResource(request, target.type, target.id); +} + +class _FetchRelationship implements ControllerRequest { + final RelationshipTarget target; + + _FetchRelationship(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} + +class _FetchRelated implements ControllerRequest { + final RelatedTarget target; - Response fetchResource(String type, String id, Request request); + _FetchRelated(this.target); - Response fetchRelated( - String type, String id, String relationship, Request request); + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelated( + request, target.type, target.id, target.relationship); +} - Response fetchRelationship( - String type, String id, String relationship, Request request); +class _FetchResource implements ControllerRequest { + final ResourceTarget target; - Response deleteResource(String type, String id, Request request); + _FetchResource(this.target); - Response createResource(String type, Resource resource, Request request); + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchResource(request, target.type, target.id); +} - Response updateResource( - String type, String id, Resource resource, Request request); +class _FetchCollection implements ControllerRequest { + final CollectionTarget target; + + _FetchCollection(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchCollection(request, target.type); +} + +class _UpdateRelationship implements ControllerRequest { + final RelationshipTarget target; + + _UpdateRelationship(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + return null; + } +} - Response replaceToOne(String type, String id, String relationship, - Identifier identifier, Request request); +class _InvalidRequest implements ControllerRequest { + final Target target; + final String method; - Response replaceToMany(String type, String id, String relationship, - List identifiers, Request request); + _InvalidRequest(this.target, this.method); - Response addToMany(String type, String id, String relationship, - List identifiers, Request request); + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } } diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart deleted file mode 100644 index 953e6be8..00000000 --- a/lib/src/server/json_api_request.dart +++ /dev/null @@ -1,13 +0,0 @@ -/// JSON:API HTTP request -class JsonApiRequest { - JsonApiRequest(this.method, this.requestedUri, this.body); - - /// Requested URI - final Uri requestedUri; - - /// JSON-decoded body, may be null - final Object body; - - /// HTTP method - final String method; -} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart deleted file mode 100644 index 166494b0..00000000 --- a/lib/src/server/json_api_server.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/response/error_response.dart'; -import 'package:json_api/src/server/response/json_api_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 JsonApiServer { - final UrlDesign urlDesign; - final JsonApiController controller; - final ServerDocumentFactory documentFactory; - final String allowOrigin; - final RouteFactory routeMapper; - - JsonApiServer(this.urlDesign, this.controller, - {this.allowOrigin = '*', ServerDocumentFactory documentFactory}) - : routeMapper = RouteFactory(), - documentFactory = documentFactory ?? ServerDocumentFactory(urlDesign); - - Future serve(HttpRequest request) async { - final response = await _call(controller, request); - - _setStatus(request, response); - _setHeaders(request, response); - _writeBody(request, response); - - return request.response.close(); - } - - Future _call( - JsonApiController controller, HttpRequest request) async { - final body = await _getBody(request); - final jsonApiRequest = - JsonApiRequest(request.method, request.requestedUri, body); - try { - return await urlDesign - .match(request.requestedUri, routeMapper) - .call(controller, jsonApiRequest); - } on ErrorResponse catch (error) { - return error; - } - } - - void _writeBody(HttpRequest request, JsonApiResponse response) { - final doc = response.buildDocument(documentFactory, request.requestedUri); - if (doc != null) request.response.write(json.encode(doc)); - } - - void _setStatus(HttpRequest request, JsonApiResponse response) { - request.response.statusCode = response.status; - } - - void _setHeaders(HttpRequest request, JsonApiResponse response) { - final add = request.response.headers.add; - response.getHeaders(urlDesign).forEach(add); - if (allowOrigin != null) add('Access-Control-Allow-Origin', allowOrigin); - } - - Future _getBody(HttpRequest request) async { - // https://github.com/dart-lang/sdk/issues/36900 - final body = await request.cast>().transform(utf8.decoder).join(); - return (body.isNotEmpty) ? json.decode(body) : null; - } -} diff --git a/lib/src/server/response/accepted_response.dart b/lib/src/server/response/accepted_response.dart index 7e7015c2..a1748b85 100644 --- a/lib/src/server/response/accepted_response.dart +++ b/lib/src/server/response/accepted_response.dart @@ -3,7 +3,7 @@ import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; import 'package:json_api/url_design.dart'; -class AcceptedResponse extends JsonApiResponse { +class AcceptedResponse extends ControllerResponse { final Resource resource; AcceptedResponse(this.resource) : super(202); @@ -14,8 +14,8 @@ class AcceptedResponse extends JsonApiResponse { factory.makeResourceDocument(self, resource); @override - Map getHeaders(UrlFactory urlFactory) => { - ...super.getHeaders(urlFactory), + Map buildHeaders(UrlFactory urlFactory) => { + ...super.buildHeaders(urlFactory), 'Content-Location': urlFactory.resource(resource.type, resource.id).toString(), }; diff --git a/lib/src/server/response/collection_response.dart b/lib/src/server/response/collection_response.dart index d4bc329d..38969bb3 100644 --- a/lib/src/server/response/collection_response.dart +++ b/lib/src/server/response/collection_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class CollectionResponse extends JsonApiResponse { +class CollectionResponse extends ControllerResponse { final Iterable collection; final Iterable included; final int total; diff --git a/lib/src/server/response/error_response.dart b/lib/src/server/response/error_response.dart index 2c55ccaf..3d48ab08 100644 --- a/lib/src/server/response/error_response.dart +++ b/lib/src/server/response/error_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class ErrorResponse extends JsonApiResponse { +class ErrorResponse extends ControllerResponse { final Iterable errors; const ErrorResponse(int status, this.errors) : super(status); diff --git a/lib/src/server/response/json_api_response.dart b/lib/src/server/response/json_api_response.dart index dd725791..b04b99cd 100644 --- a/lib/src/server/response/json_api_response.dart +++ b/lib/src/server/response/json_api_response.dart @@ -2,13 +2,13 @@ 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 JsonApiResponse { - final int status; +abstract class ControllerResponse { + final int statusCode; - const JsonApiResponse(this.status); + const ControllerResponse(this.statusCode); Document buildDocument(ServerDocumentFactory factory, Uri self); - Map getHeaders(UrlFactory urlFactory) => + Map buildHeaders(UrlFactory urlFactory) => {'Content-Type': Document.contentType}; } diff --git a/lib/src/server/response/meta_response.dart b/lib/src/server/response/meta_response.dart index 9cc0b539..0a341bef 100644 --- a/lib/src/server/response/meta_response.dart +++ b/lib/src/server/response/meta_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class MetaResponse extends JsonApiResponse { +class MetaResponse extends ControllerResponse { final Map meta; MetaResponse(this.meta) : super(200); diff --git a/lib/src/server/response/no_content_response.dart b/lib/src/server/response/no_content_response.dart index 355cd891..378a424c 100644 --- a/lib/src/server/response/no_content_response.dart +++ b/lib/src/server/response/no_content_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class NoContentResponse extends JsonApiResponse { +class NoContentResponse extends ControllerResponse { const NoContentResponse() : super(204); @override diff --git a/lib/src/server/response/related_collection_response.dart b/lib/src/server/response/related_collection_response.dart index fe312a90..251930f1 100644 --- a/lib/src/server/response/related_collection_response.dart +++ b/lib/src/server/response/related_collection_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class RelatedCollectionResponse extends JsonApiResponse { +class RelatedCollectionResponse extends ControllerResponse { final Iterable collection; final Iterable included; final int total; diff --git a/lib/src/server/response/related_resource_response.dart b/lib/src/server/response/related_resource_response.dart index 562719f3..0a36f357 100644 --- a/lib/src/server/response/related_resource_response.dart +++ b/lib/src/server/response/related_resource_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class RelatedResourceResponse extends JsonApiResponse { +class RelatedResourceResponse extends ControllerResponse { final Resource resource; final Iterable included; diff --git a/lib/src/server/response/resource_created_response.dart b/lib/src/server/response/resource_created_response.dart index 591997cc..f41c2259 100644 --- a/lib/src/server/response/resource_created_response.dart +++ b/lib/src/server/response/resource_created_response.dart @@ -3,10 +3,12 @@ import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; import 'package:json_api/url_design.dart'; -class ResourceCreatedResponse extends JsonApiResponse { +class ResourceCreatedResponse extends ControllerResponse { final Resource resource; - ResourceCreatedResponse(this.resource) : super(201); + ResourceCreatedResponse(this.resource) : super(201) { + ArgumentError.checkNotNull(resource.id, 'resource.id'); + } @override Document buildDocument( @@ -14,8 +16,8 @@ class ResourceCreatedResponse extends JsonApiResponse { builder.makeResourceDocument(self, resource); @override - Map getHeaders(UrlFactory urlFactory) => { - ...super.getHeaders(urlFactory), + Map buildHeaders(UrlFactory urlFactory) => { + ...super.buildHeaders(urlFactory), 'Location': urlFactory.resource(resource.type, resource.id).toString() }; } diff --git a/lib/src/server/response/resource_response.dart b/lib/src/server/response/resource_response.dart index 909335e5..70fd5b45 100644 --- a/lib/src/server/response/resource_response.dart +++ b/lib/src/server/response/resource_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class ResourceResponse extends JsonApiResponse { +class ResourceResponse extends ControllerResponse { final Resource resource; final Iterable included; diff --git a/lib/src/server/response/resource_updated_response.dart b/lib/src/server/response/resource_updated_response.dart index 3db232e3..b33f39a0 100644 --- a/lib/src/server/response/resource_updated_response.dart +++ b/lib/src/server/response/resource_updated_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class ResourceUpdatedResponse extends JsonApiResponse { +class ResourceUpdatedResponse extends ControllerResponse { final Resource resource; ResourceUpdatedResponse(this.resource) : super(200); diff --git a/lib/src/server/response/see_other_response.dart b/lib/src/server/response/see_other_response.dart index 4b26f072..c0b49ab4 100644 --- a/lib/src/server/response/see_other_response.dart +++ b/lib/src/server/response/see_other_response.dart @@ -3,17 +3,18 @@ import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; import 'package:json_api/url_design.dart'; -class SeeOtherResponse extends JsonApiResponse { - final Resource resource; +class SeeOtherResponse extends ControllerResponse { + final String type; + final String id; - SeeOtherResponse(this.resource) : super(303); + SeeOtherResponse(this.type, this.id) : super(303); @override Document buildDocument(ServerDocumentFactory builder, Uri self) => null; @override - Map getHeaders(UrlFactory urlFactory) => { - ...super.getHeaders(urlFactory), - 'Location': urlFactory.resource(resource.type, resource.id).toString() + Map buildHeaders(UrlFactory urlFactory) => { + ...super.buildHeaders(urlFactory), + 'Location': urlFactory.resource(type, id).toString() }; } diff --git a/lib/src/server/response/to_many_response.dart b/lib/src/server/response/to_many_response.dart index 5cd2ec37..c22df59a 100644 --- a/lib/src/server/response/to_many_response.dart +++ b/lib/src/server/response/to_many_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class ToManyResponse extends JsonApiResponse { +class ToManyResponse extends ControllerResponse { final Iterable collection; final String type; final String id; diff --git a/lib/src/server/response/to_one_response.dart b/lib/src/server/response/to_one_response.dart index 1aa895c2..37dc76c6 100644 --- a/lib/src/server/response/to_one_response.dart +++ b/lib/src/server/response/to_one_response.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; -class ToOneResponse extends JsonApiResponse { +class ToOneResponse extends ControllerResponse { final String type; final String id; final String relationship; diff --git a/lib/src/server/routing/route.dart b/lib/src/server/routing/route.dart deleted file mode 100644 index 88152674..00000000 --- a/lib/src/server/routing/route.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/json_api_request.dart'; - -abstract class Route { - Response call( - JsonApiController controller, Request request); -} - -class InvalidRoute implements Route { - InvalidRoute(); - - @override - Response call( - JsonApiController controller, Request request) => - null; -} - -class ResourceRoute implements Route { - final String type; - final String id; - - ResourceRoute(this.type, this.id); - - @override - Response call( - JsonApiController controller, Request request) { - final method = HttpMethod(request.method); - if (method.isGet) { - return controller.fetchResource(type, id, request); - } - if (method.isDelete) { - return controller.deleteResource(type, id, request); - } - if (method.isPatch) { - return controller.updateResource( - type, id, ResourceData.fromJson(request.body).unwrap(), request); - } - return null; - } -} - -class CollectionRoute implements Route { - final String type; - - CollectionRoute(this.type); - - @override - Response call( - JsonApiController controller, Request request) { - final method = HttpMethod(request.method); - if (method.isGet) { - return controller.fetchCollection(type, request); - } - if (method.isPost) { - return controller.createResource( - type, ResourceData.fromJson(request.body).unwrap(), request); - } - return null; - } -} - -class RelatedRoute implements Route { - final String type; - final String id; - final String relationship; - - const RelatedRoute(this.type, this.id, this.relationship); - - @override - Response call( - JsonApiController controller, Request request) { - final method = HttpMethod(request.method); - - if (method.isGet) { - return controller.fetchRelated(type, id, relationship, request); - } - return null; - } -} - -class RelationshipRoute implements Route { - final String type; - final String id; - final String relationship; - - RelationshipRoute(this.type, this.id, this.relationship); - - @override - Response call( - JsonApiController controller, Request request) { - final method = HttpMethod(request.method); - - if (method.isGet) { - return controller.fetchRelationship(type, id, relationship, request); - } - if (method.isPatch) { - final rel = Relationship.fromJson(request.body); - if (rel is ToOne) { - return controller.replaceToOne( - type, id, relationship, rel.unwrap(), request); - } - if (rel is ToMany) { - return controller.replaceToMany( - type, id, relationship, rel.unwrap(), request); - } - } - if (method.isPost) { - return controller.addToMany(type, id, relationship, - ToMany.fromJson(request.body).unwrap(), request); - } - return null; - } -} - -class HttpMethod { - final String _method; - - HttpMethod(String method) : _method = method.toUpperCase(); - - bool get isGet => _method == 'GET'; - - bool get isPost => _method == 'POST'; - - bool get isPatch => _method == 'PATCH'; - - bool get isDelete => _method == 'DELETE'; -} diff --git a/lib/src/server/routing/route_factory.dart b/lib/src/server/routing/route_factory.dart deleted file mode 100644 index b8b911ee..00000000 --- a/lib/src/server/routing/route_factory.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/server/routing/route.dart'; -import 'package:json_api/url_design.dart'; - -class RouteFactory implements MatchCase { - const RouteFactory(); - - @override - Route 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_document_factory.dart b/lib/src/server/server_document_factory.dart index 2b6a4d65..6696222f 100644 --- a/lib/src/server/server_document_factory.dart +++ b/lib/src/server/server_document_factory.dart @@ -6,11 +6,11 @@ import 'package:json_api/src/server/pagination/pagination_strategy.dart'; import 'package:json_api/url_design.dart'; class ServerDocumentFactory { - final UrlFactory _url; + final UrlFactory _urlFactory; final PaginationStrategy _pagination; final Api _api; - ServerDocumentFactory(this._url, + ServerDocumentFactory(this._urlFactory, {Api api, PaginationStrategy pagination = const NoPagination()}) : _api = api, _pagination = pagination; @@ -51,7 +51,7 @@ class ServerDocumentFactory { Document makeRelatedResourceDocument( Uri self, Resource resource, {Iterable included}) => Document( - ResourceData(_resourceObject(resource), + ResourceData(nullable(_resourceObject)(resource), links: {'self': Link(self)}, included: included?.map(_resourceObject)), api: _api); @@ -68,7 +68,7 @@ class ServerDocumentFactory { identifiers.map(IdentifierObject.fromIdentifier), links: { 'self': Link(self), - 'related': Link(_url.related(type, id, relationship)) + 'related': Link(_urlFactory.related(type, id, relationship)) }, ), api: _api); @@ -81,7 +81,7 @@ class ServerDocumentFactory { nullable(IdentifierObject.fromIdentifier)(identifier), links: { 'self': Link(self), - 'related': Link(_url.related(type, id, relationship)) + 'related': Link(_urlFactory.related(type, id, relationship)) }, ), api: _api); @@ -97,8 +97,8 @@ class ServerDocumentFactory { ToOne( nullable(IdentifierObject.fromIdentifier)(v), links: { - 'self': Link(_url.relationship(r.type, r.id, k)), - 'related': Link(_url.related(r.type, r.id, k)) + 'self': Link(_urlFactory.relationship(r.type, r.id, k)), + 'related': Link(_urlFactory.related(r.type, r.id, k)) }, ))), ...r.toMany.map((k, v) => MapEntry( @@ -106,12 +106,12 @@ class ServerDocumentFactory { ToMany( v.map(IdentifierObject.fromIdentifier), links: { - 'self': Link(_url.relationship(r.type, r.id, k)), - 'related': Link(_url.related(r.type, r.id, k)) + 'self': Link(_urlFactory.relationship(r.type, r.id, k)), + 'related': Link(_urlFactory.related(r.type, r.id, k)) }, ))) }, links: { - 'self': Link(_url.resource(r.type, r.id)) + 'self': Link(_urlFactory.resource(r.type, r.id)) }); Map _navigation(Uri uri, int total) { diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart new file mode 100644 index 00000000..7ca910ec --- /dev/null +++ b/lib/src/server/target.dart @@ -0,0 +1,175 @@ +import 'package:json_api/url_design.dart'; + +/// Creates JSON:API requests. +abstract class RequestFactory { + /// Returns an object representing a GET request to a resource URI + R fetchResource(ResourceTarget target); + + /// Returns an object representing a DELETE request to a resource URI + R deleteResource(ResourceTarget target); + + /// Returns an object representing a PATCH request to a resource URI + R updateResource(ResourceTarget target); + + /// Returns an object representing a GET request to a resource collection URI + R fetchCollection(CollectionTarget target); + + /// Returns an object representing a POST request to a resource collection URI + R createResource(CollectionTarget target); + + /// Returns an object representing a GET request to a related resource URI + R fetchRelated(RelatedTarget target); + + /// Returns an object representing a GET request to a relationship URI + R fetchRelationship(RelationshipTarget target); + + /// Returns an object representing a PATCH request to a relationship URI + R updateRelationship(RelationshipTarget target); + + /// Returns an object representing a POST request to a relationship URI + R addToRelationship(RelationshipTarget target); + + /// Returns an object representing a DELETE request to a relationship URI + R deleteFromRelationship(RelationshipTarget target); + + /// Returns an object representing a request with a [method] which is not + /// allowed by the [target]. Most likely, this should lead to either + /// `405 Method Not Allowed` or `400 Bad Request`. + R invalid(Target target, String method); +} + +/// The target of a JSON:API request URI. The URI target and the request method +/// uniquely identify the meaning of the JSON:API request. +abstract class Target { + /// Returns the request corresponding to the request [method]. + R getRequest(String method, RequestFactory factory); +} + +/// Request URI target which is not recognized by the URL Design. +class UnmatchedTarget implements Target { + UnmatchedTarget(); + + @override + R getRequest(String method, RequestFactory factory) => + factory.invalid(this, method); +} + +/// The target of a URI referring to a single resource +class ResourceTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + ResourceTarget(this.type, this.id); + + @override + R getRequest(String method, RequestFactory factory) { + switch (method.toUpperCase()) { + case 'GET': + return factory.fetchResource(this); + case 'DELETE': + return factory.deleteResource(this); + case 'PATCH': + return factory.updateResource(this); + default: + return factory.invalid(this, method); + } + } +} + +/// The target of a URI referring a resource collection +class CollectionTarget implements Target { + /// Resource type + final String type; + + CollectionTarget(this.type); + + @override + R getRequest(String method, RequestFactory factory) { + switch (method.toUpperCase()) { + case 'GET': + return factory.fetchCollection(this); + case 'POST': + return factory.createResource(this); + default: + return factory.invalid(this, method); + } + } +} + +/// The target of a URI referring a related resource or collection +class RelatedTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelatedTarget(this.type, this.id, this.relationship); + + @override + R getRequest(String method, RequestFactory factory) { + switch (method.toUpperCase()) { + case 'GET': + return factory.fetchRelated(this); + default: + return factory.invalid(this, method); + } + } +} + +/// The target of a URI referring a relationship +class RelationshipTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + RelationshipTarget(this.type, this.id, this.relationship); + + @override + R getRequest(String method, RequestFactory factory) { + switch (method.toUpperCase()) { + case 'GET': + return factory.fetchRelationship(this); + case 'PATCH': + return factory.updateRelationship(this); + case 'POST': + return factory.addToRelationship(this); + case 'DELETE': + return factory.deleteFromRelationship(this); + default: + return factory.invalid(this, method); + } + } +} + +class TargetFactory implements MatchCase { + const TargetFactory(); + + @override + Target unmatched() => UnmatchedTarget(); + + @override + Target collection(String type) => CollectionTarget(type); + + @override + Target related(String type, String id, String relationship) => + RelatedTarget(type, id, relationship); + + @override + Target relationship(String type, String id, String relationship) => + RelationshipTarget(type, id, relationship); + + @override + Target resource(String type, String id) => ResourceTarget(type, id); +} diff --git a/lib/src/url_design/path_based_url_design.dart b/lib/src/url_design/path_based_url_design.dart index 3705d669..64e37498 100644 --- a/lib/src/url_design/path_based_url_design.dart +++ b/lib/src/url_design/path_based_url_design.dart @@ -14,6 +14,9 @@ class PathBasedUrlDesign implements UrlDesign { PathBasedUrlDesign(this.base, {this.matchBase = false}); + /// Creates an instance with "/" as the base URI. + static UrlDesign relative() => PathBasedUrlDesign(Uri()); + /// Returns a URL for the primary resource collection of type [type] @override Uri collection(String type) => _appendToBase([type]); diff --git a/test/functional/create_test.dart b/test/functional/create_test.dart index f9e10d08..fd8426ff 100644 --- a/test/functional/create_test.dart +++ b/test/functional/create_test.dart @@ -1,25 +1,31 @@ -import 'dart:async'; import 'dart:io'; import 'package:http/http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void main() async { HttpServer server; Client httpClient; JsonApiClient client; + final host = 'localhost'; final port = 8081; - final url = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); + final urlDesign = + PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); setUp(() async { httpClient = Client(); client = JsonApiClient(httpClient); - server = await createServer(InternetAddress.loopbackIPv4, port); + final handler = createHttpHandler( + ShelfRequestResponseConverter(), CRUDController(), urlDesign); + + server = await serve(handler, host, port); }); tearDown(() async { @@ -42,7 +48,8 @@ void main() async { test('201 Created', () async { final newYork = Resource('cities', null, attributes: {'name': 'New York'}); - final r0 = await client.createResource(url.collection('cities'), newYork); + final r0 = + await client.createResource(urlDesign.collection('cities'), newYork); expect(r0.status, 201); expect(r0.isSuccessful, true); @@ -53,7 +60,7 @@ void main() async { // Make sure the resource is available final r1 = await client - .fetchResource(url.resource('cities', r0.data.unwrap().id)); + .fetchResource(urlDesign.resource('cities', r0.data.unwrap().id)); expect(r1.data.resourceObject.attributes['name'], 'New York'); }); @@ -62,33 +69,34 @@ void main() async { /// the server MUST return a 202 Accepted status code. /// /// https://jsonapi.org/format/#crud-creating-responses-202 - test('202 Acepted', () async { - final roadster2020 = - Resource('models', null, attributes: {'name': 'Roadster 2020'}); - final r0 = - await client.createResource(url.collection('models'), roadster2020); - - expect(r0.status, 202); - expect(r0.isSuccessful, false); // neither success - expect(r0.isFailed, false); // nor failure yet - expect(r0.isAsync, true); // yay async! - expect(r0.document, isNull); - expect(r0.asyncDocument, isNotNull); - expect(r0.asyncData.unwrap().type, 'jobs'); - expect(r0.location, isNull); - expect(r0.contentLocation, isNotNull); - - final r1 = await client.fetchResource(r0.contentLocation); - expect(r1.status, 200); - expect(r1.data.unwrap().type, 'jobs'); - - await Future.delayed(Duration(milliseconds: 100)); - - // When it's done, this will be the created resource - final r2 = await client.fetchResource(r0.contentLocation); - expect(r2.data.unwrap().type, 'models'); - expect(r2.data.unwrap().attributes['name'], 'Roadster 2020'); - }); +// test('202 Accepted', () async { +// final roadster2020 = +// Resource('models', null, attributes: {'name': 'Roadster 2020'}); +// final r0 = await client.createResource( +// urlDesign.collection('models'), roadster2020, +// headers: {'Prefer': 'return-asynch'}); +// +// expect(r0.status, 202); +// expect(r0.isSuccessful, false); // neither success +// expect(r0.isFailed, false); // nor failure yet +// expect(r0.isAsync, true); // yay async! +// expect(r0.document, isNull); +// expect(r0.asyncDocument, isNotNull); +// expect(r0.asyncData.unwrap().type, 'jobs'); +// expect(r0.location, isNull); +// expect(r0.contentLocation, isNotNull); +// +// final r1 = await client.fetchResource(r0.contentLocation); +// expect(r1.status, 200); +// expect(r1.data.unwrap().type, 'jobs'); +// +// await Future.delayed(Duration(milliseconds: 100)); +// +// // When it's done, this will be the created resource +// final r2 = await client.fetchResource(r0.contentLocation); +// expect(r2.data.unwrap().type, 'models'); +// expect(r2.data.unwrap().attributes['name'], 'Roadster 2020'); +// }); /// If a POST query did include a Client-Generated ID and the requested /// resource has been created successfully, the server MUST return either @@ -99,14 +107,16 @@ void main() async { test('204 No Content', () async { final newYork = Resource('cities', '555', attributes: {'name': 'New York'}); - final r0 = await client.createResource(url.collection('cities'), newYork); + final r0 = + await client.createResource(urlDesign.collection('cities'), newYork); expect(r0.status, 204); expect(r0.isSuccessful, true); expect(r0.document, isNull); // Make sure the resource is available - final r1 = await client.fetchResource(url.resource('cities', '555')); + final r1 = + await client.fetchResource(urlDesign.resource('cities', '555')); expect(r1.data.unwrap().attributes['name'], 'New York'); }); @@ -116,11 +126,17 @@ void main() async { /// https://jsonapi.org/format/#crud-creating-responses-409 test('409 Conflict - Resource already exists', () async { final newYork = Resource('cities', '1', attributes: {'name': 'New York'}); - final r0 = await client.createResource(url.collection('cities'), newYork); + final r0 = + await client.createResource(urlDesign.collection('cities'), newYork); - expect(r0.status, 409); - expect(r0.isSuccessful, false); - expect(r0.document.errors.first.detail, 'Resource already exists'); + expect(r0.isSuccessful, true); + + final r1 = + await client.createResource(urlDesign.collection('cities'), newYork); + + expect(r1.status, 409); + expect(r1.isSuccessful, false); + expect(r1.document.errors.first.detail, 'Resource already exists'); }); /// A server MUST return 409 Conflict when processing a POST query in @@ -131,8 +147,8 @@ void main() async { test('409 Conflict - Incompatible type', () async { final newYork = Resource('cities', '555', attributes: {'name': 'New York'}); - final r0 = - await client.createResource(url.collection('companies'), newYork); + final r0 = await client.createResource( + urlDesign.collection('companies'), newYork); expect(r0.status, 409); expect(r0.isSuccessful, false); diff --git a/test/functional/delete_test.dart b/test/functional/delete_test.dart index aaec0434..b31218c1 100644 --- a/test/functional/delete_test.dart +++ b/test/functional/delete_test.dart @@ -2,22 +2,30 @@ import 'dart:io'; import 'package:http/http.dart'; import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void main() async { HttpServer server; Client httpClient; JsonApiClient client; - final port = 8082; - final url = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); + final host = 'localhost'; + final port = 8081; + final urlDesign = + PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); setUp(() async { httpClient = Client(); client = JsonApiClient(httpClient); - server = await createServer(InternetAddress.loopbackIPv4, port); + final handler = createHttpHandler( + ShelfRequestResponseConverter(), CRUDController(), urlDesign); + + server = await serve(handler, host, port); }); tearDown(() async { @@ -31,15 +39,21 @@ void main() async { /// /// https://jsonapi.org/format/#crud-deleting-responses-204 test('204 No Content', () async { - final r0 = await client.deleteResource(url.resource('models', '1')); + final apple = Resource('apples', '1'); + final r0 = + await client.createResource(urlDesign.collection('apples'), apple); - expect(r0.status, 204); expect(r0.isSuccessful, true); - expect(r0.document, isNull); + + final r1 = await client.deleteResource(urlDesign.resource('apples', '1')); + + expect(r1.status, 204); + expect(r1.isSuccessful, true); + expect(r1.document, isNull); // Make sure the resource is not available anymore - final r1 = await client.fetchResource(url.resource('models', '1')); - expect(r1.status, 404); + final r2 = await client.fetchResource(urlDesign.resource('apples', '1')); + expect(r2.status, 404); }); /// A server MUST return a 200 OK status code if a deletion query @@ -47,15 +61,22 @@ void main() async { /// /// https://jsonapi.org/format/#crud-deleting-responses-200 test('200 OK', () async { - final r0 = await client.deleteResource(url.resource('companies', '1')); + final apple = Resource('apples', '1', + toOne: {'origin': Identifier('countries', '2')}); + final r0 = + await client.createResource(urlDesign.collection('apples'), apple); - expect(r0.status, 200); expect(r0.isSuccessful, true); - expect(r0.document.meta['dependenciesCount'], 5); + + final r1 = await client.deleteResource(urlDesign.resource('apples', '1')); + + expect(r1.status, 200); + expect(r1.isSuccessful, true); + expect(r1.document.meta['relationships'], 1); // Make sure the resource is not available anymore - final r1 = await client.fetchResource(url.resource('companies', '1')); - expect(r1.status, 404); + final r2 = await client.fetchResource(urlDesign.resource('apples', '1')); + expect(r2.status, 404); }); /// https://jsonapi.org/format/#crud-deleting-responses-404 @@ -63,7 +84,8 @@ void main() async { /// A server SHOULD return a 404 Not Found status code if a deletion query /// fails due to the resource not existing. test('404 Not Found', () async { - final r0 = await client.deleteResource(url.resource('models', '555')); + final r0 = + await client.deleteResource(urlDesign.resource('models', '555')); expect(r0.status, 404); }); }, testOn: 'vm'); diff --git a/test/functional/fetch_test.dart b/test/functional/fetch_test.dart index 403538a4..0139c3e1 100644 --- a/test/functional/fetch_test.dart +++ b/test/functional/fetch_test.dart @@ -3,23 +3,76 @@ import 'dart:io'; import 'package:http/http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void main() async { HttpServer server; Client httpClient; JsonApiClient client; - final port = 8083; - final url = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); + final host = 'localhost'; + final port = 8081; + final urlDesign = + PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); + + void seedData() async { + final fowler = + Resource('people', '1', attributes: {'name': 'Martin Fowler'}); + final beck = Resource('people', '2', attributes: {'name': 'Kent Beck'}); + final martin = + Resource('people', '3', attributes: {'name': 'Robert C. Matin'}); + final norton = + Resource('people', '4', attributes: {'name': 'Peter Norton'}); + final microsoft = + Resource('companies', '1', attributes: {'name': 'Microsoft Press'}); + final addison = Resource('companies', '2', + attributes: {'name': 'Addison-Wesley Professional'}); + final ibmGuide = Resource('books', '1', attributes: { + 'title': "The Peter Norton Programmer's Guide to the IBM PC" + }, toMany: { + 'authors': [Identifier.of(norton)] + }, toOne: { + 'publisher': Identifier.of(microsoft) + }); + final refactoring = Resource('books', '2', attributes: { + 'title': 'Refactoring' + }, toMany: { + 'authors': [Identifier.of(fowler), Identifier.of(beck)] + }, toOne: { + 'publisher': Identifier.of(addison) + }); + + final incomplete = Resource('books', '10', + attributes: {'title': 'Incomplete book'}, + toMany: {'authors': []}, + toOne: {'publisher': null}); + + await for (final r in Stream.fromIterable([ + fowler, + beck, + martin, + norton, + microsoft, + ibmGuide, + refactoring, + incomplete + ])) { + await client.createResource(urlDesign.collection(r.type), r); + } + } setUp(() async { httpClient = Client(); client = JsonApiClient(httpClient); - server = await createServer(InternetAddress.loopbackIPv4, port); + final handler = createHttpHandler( + ShelfRequestResponseConverter(), CRUDController(), urlDesign); + + server = await serve(handler, host, port); + await seedData(); }); tearDown(() async { @@ -28,227 +81,324 @@ void main() async { }); group('collection', () { - test('resource collection', () async { - final uri = url.collection('companies'); - final r = await client.fetchCollection(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - final resObj = r.data.collection.first; - expect(resObj.attributes['name'], 'Tesla'); - expect(resObj.self.uri, url.resource('companies', '1')); - expect(resObj.relationships['hq'].related.uri, - url.related('companies', '1', 'hq')); - expect(resObj.relationships['hq'].self.uri, - url.relationship('companies', '1', 'hq')); - expect(r.data.self.uri, uri); + /// https://jsonapi.org/format/#fetching-resources-responses + /// + /// A server MUST respond to a successful request to fetch a + /// resource collection with an array of resource objects or an + /// empty array ([]) as the response document’s primary data. + test('empty primary collection', () async { + final r0 = await client.fetchCollection(urlDesign.collection('unicorns')); + + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.collection.length, 0); }); - test('resource collection traversal', () async { - final uri = - url.collection('companies').replace(queryParameters: {'foo': 'bar'}); - - final r0 = await client.fetchCollection(uri); - final somePage = r0.data; - - expect(somePage.next.uri.queryParameters['foo'], 'bar', - reason: 'query parameters must be preserved'); - - final r1 = await client.fetchCollection(somePage.next.uri); - final secondPage = r1.data; - expect(secondPage.collection.first.attributes['name'], 'BMW'); - expect(secondPage.self.uri, somePage.next.uri); - - expect(secondPage.last.uri.queryParameters['foo'], 'bar', - reason: 'query parameters must be preserved'); - - final r2 = await client.fetchCollection(secondPage.last.uri); - final lastPage = r2.data; - expect(lastPage.collection.first.attributes['name'], 'Toyota'); - expect(lastPage.self.uri, secondPage.last.uri); + test('non-empty primary collection', () async { + final r0 = await client.fetchCollection(urlDesign.collection('people')); - expect(lastPage.prev.uri.queryParameters['foo'], 'bar', - reason: 'query parameters must be preserved'); - - final r3 = await client.fetchCollection(lastPage.prev.uri); - final secondToLastPage = r3.data; - expect(secondToLastPage.collection.first.attributes['name'], 'Audi'); - expect(secondToLastPage.self.uri, lastPage.prev.uri); - - expect(secondToLastPage.first.uri.queryParameters['foo'], 'bar', - reason: 'query parameters must be preserved'); - - final r4 = await client.fetchCollection(secondToLastPage.first.uri); - final firstPage = r4.data; - expect(firstPage.collection.first.attributes['name'], 'Tesla'); - expect(firstPage.self.uri, secondToLastPage.first.uri); - }); - - test('related collection', () async { - final uri = url.related('companies', '1', 'models'); - final r = await client.fetchCollection(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.collection.first.attributes['name'], 'Roadster'); - expect(r.data.self.uri, uri); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.collection.length, 4); }); - test('related collection travesal', () async { - final uri = url.related('companies', '1', 'models'); - final r0 = await client.fetchCollection(uri); - final firstPage = r0.data; - expect(firstPage.collection.length, 1); + test('empty related collection', () async { + final r0 = await client + .fetchCollection(urlDesign.related('books', '10', 'authors')); - final r1 = await client.fetchCollection(firstPage.last.uri); - final lastPage = r1.data; - expect(lastPage.collection.length, 1); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.collection.length, 0); }); - test('404', () async { - final r = await client.fetchCollection(url.collection('unicorns')); - expect(r.status, 404); - expect(r.isSuccessful, false); - expect(r.document.errors.first.detail, 'Unknown resource type unicorns'); - }); - }, testOn: 'vm'); - - group('single resource', () { - test('single resource', () async { - final uri = url.resource('models', '1'); - final r = await client.fetchResource(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().attributes['name'], 'Roadster'); - expect(r.data.self.uri, uri); - }); + test('non-empty related collection', () async { + final r0 = await client + .fetchCollection(urlDesign.related('books', '2', 'authors')); - test('404 on type', () async { - final r = await client.fetchResource(url.resource('unicorns', '1')); - expect(r.status, 404); - expect(r.isSuccessful, false); - }); - - test('404 on id', () async { - final r = await client.fetchResource(url.resource('models', '555')); - expect(r.status, 404); - expect(r.isSuccessful, false); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.collection.length, 2); + expect(r0.document.data.collection.first.attributes['name'], + 'Martin Fowler'); + expect(r0.document.data.collection.last.attributes['name'], 'Kent Beck'); }); }, testOn: 'vm'); - group('related resource', () { - test('related resource', () async { - final uri = url.related('companies', '1', 'hq'); - final r = await client.fetchResource(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().attributes['name'], 'Palo Alto'); - expect(r.data.self.uri, uri); - }); - - test('404 on type', () async { - final r = await client.fetchResource(url.related('unicorns', '1', 'hq')); - expect(r.status, 404); - expect(r.isSuccessful, false); + group('resource', () { + /// A server MUST respond to a successful request to fetch an + /// individual resource or resource collection with a 200 OK response. + test('primary resource', () async { + final r0 = await client.fetchResource(urlDesign.resource('people', '1')); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.unwrap().attributes['name'], 'Martin Fowler'); + expect(r0.document.data.unwrap().type, 'people'); }); - test('404 on id', () async { - final r = await client.fetchResource(url.related('models', '555', 'hq')); - expect(r.status, 404); - expect(r.isSuccessful, false); + test('primary resource not found', () async { + final r0 = + await client.fetchResource(urlDesign.resource('unicorns', '555')); + expect(r0.status, 404); + expect(r0.isSuccessful, false); + expect(r0.document.errors.first.detail, 'Resource not found'); }); - test('404 on relationship', () async { - final r = - await client.fetchResource(url.related('companies', '1', 'unicorn')); - expect(r.status, 404); - expect(r.isSuccessful, false); - }); - }, testOn: 'vm'); - - group('relationships', () { - test('to-one', () async { - final uri = url.relationship('companies', '1', 'hq'); - final r = await client.fetchToOne(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().type, 'cities'); - expect(r.data.self.uri, uri); - expect(r.data.related.uri.toString(), - 'http://localhost:$port/companies/1/hq'); - }); - - test('empty to-one', () async { - final uri = url.relationship('companies', '3', 'hq'); - final r = await client.fetchToOne(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap(), isNull); - expect(r.data.self.uri, uri); - expect(r.data.related.uri, url.related('companies', '3', 'hq')); - }); - - test('generic to-one', () async { - final uri = url.relationship('companies', '1', 'hq'); - final r = await client.fetchRelationship(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data, TypeMatcher()); - expect((r.data as ToOne).unwrap().type, 'cities'); - expect(r.data.self.uri, uri); - expect(r.data.related.uri, url.related('companies', '1', 'hq')); + test('related resource', () async { + final r0 = await client + .fetchResource(urlDesign.related('books', '1', 'publisher')); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.unwrap().attributes['name'], 'Microsoft Press'); + expect(r0.document.data.unwrap().type, 'companies'); }); - test('to-many', () async { - final uri = url.relationship('companies', '1', 'models'); - final r = await client.fetchToMany(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.identifiers.first.type, 'models'); - expect(r.data.self.uri, uri); - expect(r.data.related.uri, url.related('companies', '1', 'models')); + test('null related resource', () async { + final r0 = await client + .fetchResource(urlDesign.related('books', '10', 'publisher')); + expect(r0.status, 200); + expect(r0.isSuccessful, true); + expect(r0.document.data.unwrap(), null); }); - test('empty to-many', () async { - final uri = url.relationship('companies', '3', 'models'); - final r = await client.fetchToMany(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.identifiers, isEmpty); - expect(r.data.self.uri, uri); - expect(r.data.related.uri, url.related('companies', '3', 'models')); + test('related resource not found (primary resouce not found)', () async { + final r0 = await client + .fetchResource(urlDesign.related('unicorns', '1', 'owner')); + expect(r0.status, 404); + expect(r0.isSuccessful, false); + expect(r0.document.errors.first.detail, 'Resource not found'); }); - test('generic to-many', () async { - final uri = url.relationship('companies', '1', 'models'); - final r = await client.fetchRelationship(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data, TypeMatcher()); - expect((r.data as ToMany).identifiers.first.type, 'models'); - expect(r.data.self.uri, uri); - expect(r.data.related.uri, url.related('companies', '1', 'models')); + test('related resource not found (relationship not found)', () async { + final r0 = await client + .fetchResource(urlDesign.related('people', '1', 'unicorn')); + expect(r0.status, 404); + expect(r0.isSuccessful, false); + expect(r0.document.errors.first.detail, 'Relatioship not found'); }); }, testOn: 'vm'); - group('compound document', () { - test('single resource compound document', () async { - final uri = url.resource('companies', '1'); - final r = - await client.fetchResource(uri, parameters: Include(['models'])); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().attributes['name'], 'Tesla'); - expect(r.data.included.length, 4); - expect(r.data.included.last.type, 'models'); - expect(r.data.included.last.attributes['name'], 'Model 3'); - }); - - test('"included" member should not present if not requested', () async { - final uri = url.resource('companies', '1'); - final r = await client.fetchResource(uri); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().attributes['name'], 'Tesla'); - expect(r.data.included, null); - }); - }, testOn: 'vm'); +// group('collection', () { +// test('resource collection', () async { +// final uri = url.collection('companies'); +// final r = await client.fetchCollection(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// final resObj = r.data.collection.first; +// expect(resObj.attributes['name'], 'Tesla'); +// expect(resObj.self.uri, url.resource('companies', '1')); +// expect(resObj.relationships['hq'].related.uri, +// url.related('companies', '1', 'hq')); +// expect(resObj.relationships['hq'].self.uri, +// url.relationship('companies', '1', 'hq')); +// expect(r.data.self.uri, uri); +// }); +// +// test('resource collection traversal', () async { +// final uri = +// url.collection('companies').replace(queryParameters: {'foo': 'bar'}); +// +// final r0 = await client.fetchCollection(uri); +// final somePage = r0.data; +// +// expect(somePage.next.uri.queryParameters['foo'], 'bar', +// reason: 'query parameters must be preserved'); +// +// final r1 = await client.fetchCollection(somePage.next.uri); +// final secondPage = r1.data; +// expect(secondPage.collection.first.attributes['name'], 'BMW'); +// expect(secondPage.self.uri, somePage.next.uri); +// +// expect(secondPage.last.uri.queryParameters['foo'], 'bar', +// reason: 'query parameters must be preserved'); +// +// final r2 = await client.fetchCollection(secondPage.last.uri); +// final lastPage = r2.data; +// expect(lastPage.collection.first.attributes['name'], 'Toyota'); +// expect(lastPage.self.uri, secondPage.last.uri); +// +// expect(lastPage.prev.uri.queryParameters['foo'], 'bar', +// reason: 'query parameters must be preserved'); +// +// final r3 = await client.fetchCollection(lastPage.prev.uri); +// final secondToLastPage = r3.data; +// expect(secondToLastPage.collection.first.attributes['name'], 'Audi'); +// expect(secondToLastPage.self.uri, lastPage.prev.uri); +// +// expect(secondToLastPage.first.uri.queryParameters['foo'], 'bar', +// reason: 'query parameters must be preserved'); +// +// final r4 = await client.fetchCollection(secondToLastPage.first.uri); +// final firstPage = r4.data; +// expect(firstPage.collection.first.attributes['name'], 'Tesla'); +// expect(firstPage.self.uri, secondToLastPage.first.uri); +// }); +// +// test('related collection', () async { +// final uri = url.related('companies', '1', 'models'); +// final r = await client.fetchCollection(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.collection.first.attributes['name'], 'Roadster'); +// expect(r.data.self.uri, uri); +// }); +// +// test('related collection traversal', () async { +// final uri = url.related('companies', '1', 'models'); +// final r0 = await client.fetchCollection(uri); +// final firstPage = r0.data; +// expect(firstPage.collection.length, 1); +// +// final r1 = await client.fetchCollection(firstPage.last.uri); +// final lastPage = r1.data; +// expect(lastPage.collection.length, 1); +// }); +// +// test('404', () async { +// final r = await client.fetchCollection(url.collection('unicorns')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// expect(r.document.errors.first.detail, 'Unknown resource type unicorns'); +// }); +// }, testOn: 'vm'); +// +// group('single resource', () { +// test('single resource', () async { +// final uri = url.resource('models', '1'); +// final r = await client.fetchResource(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap().attributes['name'], 'Roadster'); +// expect(r.data.self.uri, uri); +// }); +// +// test('404 on type', () async { +// final r = await client.fetchResource(url.resource('unicorns', '1')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// }); +// +// test('404 on id', () async { +// final r = await client.fetchResource(url.resource('models', '555')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// }); +// }, testOn: 'vm'); +// +// group('related resource', () { +// test('related resource', () async { +// final uri = url.related('companies', '1', 'hq'); +// final r = await client.fetchResource(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap().attributes['name'], 'Palo Alto'); +// expect(r.data.self.uri, uri); +// }); +// +// test('404 on type', () async { +// final r = await client.fetchResource(url.related('unicorns', '1', 'hq')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// }); +// +// test('404 on id', () async { +// final r = await client.fetchResource(url.related('models', '555', 'hq')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// }); +// +// test('404 on relationship', () async { +// final r = +// await client.fetchResource(url.related('companies', '1', 'unicorn')); +// expect(r.status, 404); +// expect(r.isSuccessful, false); +// }); +// }, testOn: 'vm'); +// +// group('relationships', () { +// test('to-one', () async { +// final uri = url.relationship('companies', '1', 'hq'); +// final r = await client.fetchToOne(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap().type, 'cities'); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri.toString(), +// 'http://localhost:$port/companies/1/hq'); +// }); +// +// test('empty to-one', () async { +// final uri = url.relationship('companies', '3', 'hq'); +// final r = await client.fetchToOne(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap(), isNull); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri, url.related('companies', '3', 'hq')); +// }); +// +// test('generic to-one', () async { +// final uri = url.relationship('companies', '1', 'hq'); +// final r = await client.fetchRelationship(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data, TypeMatcher()); +// expect((r.data as ToOne).unwrap().type, 'cities'); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri, url.related('companies', '1', 'hq')); +// }); +// +// test('to-many', () async { +// final uri = url.relationship('companies', '1', 'models'); +// final r = await client.fetchToMany(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.identifiers.first.type, 'models'); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri, url.related('companies', '1', 'models')); +// }); +// +// test('empty to-many', () async { +// final uri = url.relationship('companies', '3', 'models'); +// final r = await client.fetchToMany(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.identifiers, isEmpty); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri, url.related('companies', '3', 'models')); +// }); +// +// test('generic to-many', () async { +// final uri = url.relationship('companies', '1', 'models'); +// final r = await client.fetchRelationship(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data, TypeMatcher()); +// expect((r.data as ToMany).identifiers.first.type, 'models'); +// expect(r.data.self.uri, uri); +// expect(r.data.related.uri, url.related('companies', '1', 'models')); +// }); +// }, testOn: 'vm'); +// +// group('compound document', () { +// test('single resource compound document', () async { +// final uri = url.resource('companies', '1'); +// final r = +// await client.fetchResource(uri, parameters: Include(['models'])); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap().attributes['name'], 'Tesla'); +// expect(r.data.included.length, 4); +// expect(r.data.included.last.type, 'models'); +// expect(r.data.included.last.attributes['name'], 'Model 3'); +// }); +// +// test('"included" member should not present if not requested', () async { +// final uri = url.resource('companies', '1'); +// final r = await client.fetchResource(uri); +// expect(r.status, 200); +// expect(r.isSuccessful, true); +// expect(r.data.unwrap().attributes['name'], 'Tesla'); +// expect(r.data.included, null); +// }); +// }, testOn: 'vm'); } diff --git a/test/functional/hooks_test.dart b/test/functional/hooks_test.dart index 6f627628..196b25a3 100644 --- a/test/functional/hooks_test.dart +++ b/test/functional/hooks_test.dart @@ -2,27 +2,35 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void main() async { + http.Request request; + http.Response response; HttpServer server; http.Client httpClient; JsonApiClient client; - http.Request request; - http.Response response; - final port = 8083; - final urlDesign = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); + final host = 'localhost'; + final port = 8081; + final urlDesign = + PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); setUp(() async { httpClient = http.Client(); - client = JsonApiClient(httpClient, onHttpCall: (req, resp) { - request = req; - response = resp; + client = JsonApiClient(httpClient, onHttpCall: (rq, rs) { + request = rq; + response = rs; }); - server = await createServer(InternetAddress.loopbackIPv4, port); + final handler = createHttpHandler( + ShelfRequestResponseConverter(), CRUDController(), urlDesign); + + server = await serve(handler, host, port); }); tearDown(() async { @@ -32,11 +40,12 @@ void main() async { group('hooks', () { test('onHttpCall gets called', () async { - await client.fetchCollection(urlDesign.collection('companies')); + await client.createResource( + urlDesign.collection('apples'), Resource('apples', '1')); expect(request, isNotNull); expect(response, isNotNull); - expect(response.body, isNotEmpty); + expect(response.statusCode, 204); }); }, testOn: 'vm'); } diff --git a/test/functional/test_server.dart b/test/functional/test_server.dart index 223e47f8..129a38fa 100644 --- a/test/functional/test_server.dart +++ b/test/functional/test_server.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:stream_channel/stream_channel.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void hybridMain(StreamChannel channel, Object message) async { final port = 8080; diff --git a/test/functional/update_test.dart b/test/functional/update_test.dart index a6407473..79af109d 100644 --- a/test/functional/update_test.dart +++ b/test/functional/update_test.dart @@ -6,7 +6,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/url_design.dart'; import 'package:test/test.dart'; -import '../../example/cars_server.dart'; +import '../../example/server.dart'; void main() async { HttpServer server; diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 65d7d11d..ac1ba414 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -20,6 +20,13 @@ void main() { expect(data.unwrap().id, isNull); }); + test('Can decode a related resource which is null', () { + final data = ResourceData.fromJson(json.decode(json.encode({ + 'data': null + }))); + expect(data.unwrap(), null); + }); + test('Inherits links from ResourceObject', () { final res = ResourceObject('apples', '1', links: { 'foo': Link(Uri.parse('/foo')), From 043914ab5f938a8087988b19cea178e4aed741dd Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 12 Jan 2020 14:14:03 -0800 Subject: [PATCH 02/99] WIP --- example/README.md | 2 +- .../crud_controller.dart} | 113 +++-- example/server/server.dart | 20 + .../shelf_request_response_converter.dart | 21 + lib/client.dart | 1 + lib/src/client/json_api_client.dart | 56 ++- lib/src/client/url_aware_client.dart | 173 ++++++++ lib/src/document/identifier.dart | 3 + lib/src/document/resource.dart | 41 +- lib/src/document/resource_data.dart | 1 - lib/src/server/json_api_controller.dart | 66 +-- .../response/resource_created_response.dart | 2 +- lib/src/server/server_document_factory.dart | 14 + test/functional/browser_compat_test.dart | 39 +- test/functional/client/crud_test.dart | 213 +++++++++ test/functional/create_test.dart | 158 ------- test/functional/delete_test.dart | 92 ---- test/functional/fetch_test.dart | 404 ------------------ test/functional/hooks_test.dart | 51 --- test/functional/server.dart | 20 + test/functional/test_server.dart | 11 - test/functional/update_test.dart | 274 ------------ test/unit/document/resource_test.dart | 6 + 23 files changed, 658 insertions(+), 1123 deletions(-) rename example/{server.dart => server/crud_controller.dart} (55%) create mode 100644 example/server/server.dart create mode 100644 example/server/shelf_request_response_converter.dart create mode 100644 lib/src/client/url_aware_client.dart create mode 100644 test/functional/client/crud_test.dart delete mode 100644 test/functional/create_test.dart delete mode 100644 test/functional/delete_test.dart delete mode 100644 test/functional/fetch_test.dart delete mode 100644 test/functional/hooks_test.dart create mode 100644 test/functional/server.dart delete mode 100644 test/functional/test_server.dart delete mode 100644 test/functional/update_test.dart diff --git a/example/README.md b/example/README.md index c66508be..557a1555 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,6 @@ # JSON:API examples -## [Server](./server.dart) +## [Server](server/server.dart) This is a simple JSON:API server which is used in the tests. It provides an API to a collection to car companies and models. You can run it locally to play around. diff --git a/example/server.dart b/example/server/crud_controller.dart similarity index 55% rename from example/server.dart rename to example/server/crud_controller.dart index a608b3da..da7f0a66 100644 --- a/example/server.dart +++ b/example/server/crud_controller.dart @@ -2,44 +2,15 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/http_handler.dart'; -import 'package:json_api/url_design.dart'; import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:uuid/uuid.dart'; - -/// This example shows how to build a simple CRUD server on top of Dart Shelf -void main() async { - final host = 'localhost'; - final port = 8080; - final baseUri = Uri(scheme: 'http', host: host, port: port); - final jsonApiHandler = createHttpHandler(ShelfRequestResponseConverter(), - CRUDController(), PathBasedUrlDesign(baseUri)); - - await serve(jsonApiHandler, host, port); - print('Serving at $baseUri'); -} - -class ShelfRequestResponseConverter - implements HttpMessageConverter { - @override - FutureOr createResponse( - int statusCode, String body, Map headers) => - Response(statusCode, body: body, headers: headers); - - @override - FutureOr getBody(Request request) => request.readAsString(); - - @override - FutureOr getMethod(Request request) => request.method; - - @override - FutureOr getUri(Request request) => request.requestedUri; -} class CRUDController implements JsonApiController { + final String Function() generateId; + final store = >{}; + CRUDController(this.generateId); + @override FutureOr createResource( Request request, String type, Resource resource) { @@ -56,8 +27,8 @@ class CRUDController implements JsonApiController { repo[resource.id] = resource; return NoContentResponse(); } - final id = Uuid().v4(); - repo[id] = resource.withId(id); + final id = generateId(); + repo[id] = resource.replace(id: id); return ResourceCreatedResponse(repo[id]); } @@ -73,17 +44,30 @@ class CRUDController implements JsonApiController { } @override - FutureOr addToRelationship( - Request request, String type, String id, String relationship) { - // TODO: implement addToRelationship - return null; + FutureOr addToRelationship(Request request, String type, + String id, String relationship, Iterable identifiers) { + final resource = _repo(type)[id]; + final ids = [...resource.toMany[relationship], ...identifiers]; + _repo(type)[id] = + resource.replace(toMany: {...resource.toMany, relationship: ids}); + return ToManyResponse(type, id, relationship, ids); } @override FutureOr deleteFromRelationship( - Request request, String type, String id, String relationship) { - // TODO: implement deleteFromRelationship - return null; + Request request, + String type, + String id, + String relationship, + Iterable identifiers) { + final resource = _repo(type)[id]; + final rel = [...resource.toMany[relationship]]; + rel.removeWhere(identifiers.contains); + final toMany = {...resource.toMany}; + toMany[relationship] = rel; + _repo(type)[id] = resource.replace(toMany: toMany); + + return ToManyResponse(type, id, relationship, rel); } @override @@ -125,8 +109,8 @@ class CRUDController implements JsonApiController { return RelatedResourceResponse(_repo(related.type)[related.id]); } if (resource.toMany.containsKey(relationship)) { - final related = resource.toMany[relationship]; - return RelatedCollectionResponse(related.map((r) => _repo(r.type)[r.id])); + return RelatedCollectionResponse( + resource.toMany[relationship].map((r) => _repo(r.type)[r.id])); } return ErrorResponse.notFound( [JsonApiError(detail: 'Relatioship not found')]); @@ -135,29 +119,44 @@ class CRUDController implements JsonApiController { @override FutureOr fetchRelationship( Request request, String type, String id, String relationship) { - // TODO: implement fetchRelationship - return null; + final r = _repo(type)[id]; + if (r.toOne.containsKey(relationship)) { + return ToOneResponse(type, id, relationship, r.toOne[relationship]); + } + if (r.toMany.containsKey(relationship)) { + return ToManyResponse(type, id, relationship, r.toMany[relationship]); + } + return ErrorResponse.notFound( + [JsonApiError(detail: 'Relationship not found')]); } @override FutureOr updateResource( - Request request, String type, String id) { - // TODO: implement updateResource - return null; + Request request, String type, String id, Resource resource) { + final current = _repo(type)[id]; + if (resource.hasAllMembersOf(current)) { + _repo(type)[id] = resource; + return NoContentResponse(); + } + _repo(type)[id] = resource.withExtraMembersFrom(current); + return ResourceUpdatedResponse(_repo(type)[id]); } @override - FutureOr updateToMany( - Request request, String type, String id, String relationship) { - // TODO: implement updateToMany - return null; + FutureOr replaceToMany(Request request, String type, + String id, String relationship, Iterable identifiers) { + final resource = _repo(type)[id]; + final toMany = {...resource.toMany, relationship: identifiers.toList()}; + _repo(type)[id] = resource.replace(toMany: toMany); + return ToManyResponse(type, id, relationship, identifiers); } @override - FutureOr updateToOne( - Request request, String type, String id, String relationship) { - // TODO: implement updateToOne - return null; + FutureOr replaceToOne(Request request, String type, + String id, String relationship, Identifier identifier) { + _repo(type)[id] = + _repo(type)[id].replace(toOne: {relationship: identifier}); + return NoContentResponse(); } Map _repo(String type) { diff --git a/example/server/server.dart b/example/server/server.dart new file mode 100644 index 00000000..a4439f0a --- /dev/null +++ b/example/server/server.dart @@ -0,0 +1,20 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/http_handler.dart'; +import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:uuid/uuid.dart'; + +import 'crud_controller.dart'; +import 'shelf_request_response_converter.dart'; + +/// This example shows how to build a simple CRUD server on top of Dart Shelf +void main() async { + final host = 'localhost'; + final port = 8080; + final baseUri = Uri(scheme: 'http', host: host, port: port); + final jsonApiHandler = createHttpHandler(ShelfRequestResponseConverter(), + CRUDController(Uuid().v4), PathBasedUrlDesign(baseUri)); + + await serve(jsonApiHandler, host, port); + print('Serving at $baseUri'); +} diff --git a/example/server/shelf_request_response_converter.dart b/example/server/shelf_request_response_converter.dart new file mode 100644 index 00000000..6faf23a9 --- /dev/null +++ b/example/server/shelf_request_response_converter.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:json_api/server.dart'; +import 'package:shelf/shelf.dart'; + +class ShelfRequestResponseConverter + implements HttpMessageConverter { + @override + FutureOr createResponse( + int statusCode, String body, Map headers) => + Response(statusCode, body: body, headers: headers); + + @override + FutureOr getBody(Request request) => request.readAsString(); + + @override + FutureOr getMethod(Request request) => request.method; + + @override + FutureOr getUri(Request request) => request.requestedUri; +} diff --git a/lib/client.dart b/lib/client.dart index 558911af..fd8f362c 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -4,3 +4,4 @@ export 'package:json_api/src/client/client_document_factory.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; +export 'package:json_api/src/client/url_aware_client.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index ea2ace26..add204a8 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -35,7 +35,7 @@ class JsonApiClient { /// Creates an instance of JSON:API client. /// You have to create and pass an instance of the [httpClient] yourself. - /// Do not forget to call [httpClient.close()] when you're done using + /// Do not forget to call [httpClient.close] when you're done using /// the JSON:API client. /// The [onHttpCall] hook, if passed, gets called when an http response is /// received from the HTTP Client. @@ -126,6 +126,17 @@ class JsonApiClient { Future> deleteToOne(Uri uri, {Map headers}) => replaceToOne(uri, null, headers: headers); + /// Removes the [identifiers] from the to-many relationship. + /// + /// https://jsonapi.org/format/#crud-updating-to-many-relationships + Future> deleteFromToMany( + Uri uri, Iterable identifiers, + {Map headers}) => + _call( + _deleteWithBody( + uri, headers, _factory.makeToManyDocument(identifiers)), + ToMany.fromJson); + /// Replaces a to-many relationship with the given set of [identifiers]. /// /// The server MUST either completely replace every member of the relationship, @@ -133,34 +144,35 @@ class JsonApiClient { /// or return a 403 Forbidden response if complete replacement is not allowed by the server. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToMany(Uri uri, List identifiers, + Future> replaceToMany( + Uri uri, Iterable identifiers, {Map headers}) => _call(_patch(uri, headers, _factory.makeToManyDocument(identifiers)), ToMany.fromJson); /// Adds the given set of [identifiers] to a to-many relationship. /// - /// The server MUST add the specified members to the relationship - /// unless they are already present. - /// If a given type and id is already in the relationship, the server MUST NOT add it again. - /// - /// Note: This matches the semantics of databases that use foreign keys - /// for has-many relationships. Document-based storage should check - /// the has-many relationship before appending to avoid duplicates. - /// - /// If all of the specified resources can be added to, or are already present in, - /// the relationship then the server MUST return a successful response. - /// - /// Note: This approach ensures that a query is successful if the server’s state - /// matches the requested state, and helps avoid pointless race conditions - /// caused by multiple clients making the same changes to a relationship. + /// https://jsonapi.org/format/#crud-updating-to-many-relationships + @Deprecated('Use addToRelationship()') + Future> addToMany(Uri uri, Iterable identifiers, + {Map headers}) => + addToRelationship(uri, identifiers, headers: headers); + + /// Adds the given set of [identifiers] to a to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToMany(Uri uri, List identifiers, + Future> addToRelationship( + Uri uri, Iterable identifiers, {Map headers}) => _call(_post(uri, headers, _factory.makeToManyDocument(identifiers)), ToMany.fromJson); + /// Closes the internal HTTP client. You have to either call this method or + /// close the client yourself. + /// + /// See [httpClient.close] + void close() => httpClient.close(); + http.Request _get(Uri uri, Map headers, QueryParameters queryParameters) => http.Request( @@ -186,6 +198,16 @@ class JsonApiClient { 'Accept': Document.contentType, }); + http.Request _deleteWithBody( + Uri uri, Map headers, Document doc) => + http.Request('DELETE', uri) + ..headers.addAll({ + ...headers ?? {}, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, + }) + ..body = json.encode(doc); + http.Request _patch(uri, Map headers, Document doc) => http.Request('PATCH', uri) ..headers.addAll({ diff --git a/lib/src/client/url_aware_client.dart b/lib/src/client/url_aware_client.dart new file mode 100644 index 00000000..f603f158 --- /dev/null +++ b/lib/src/client/url_aware_client.dart @@ -0,0 +1,173 @@ +import 'package:http/http.dart' as http; +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/url_design.dart'; + +/// A wrapper over [JsonApiClient] making use of the given UrlFactory. +/// This wrapper reduces the boilerplate code but is not as flexible +/// as [JsonApiClient]. +class UrlAwareClient { + final JsonApiClient _client; + final UrlFactory _url; + + UrlAwareClient(UrlFactory urlFactory, + {JsonApiClient jsonApiClient, http.Client httpClient}) + : this._(jsonApiClient ?? JsonApiClient(httpClient ?? http.Client()), + urlFactory); + + UrlAwareClient._(this._client, this._url); + + /// Creates a new resource. The resource will be added to a collection + /// according to its type. + /// + /// https://jsonapi.org/format/#crud-creating + Future> createResource(Resource resource, + {Map headers}) => + _client.createResource(_url.collection(resource.type), resource, + headers: headers); + + /// Fetches a single resource + /// Use [headers] to pass extra HTTP headers. + /// Use [parameters] to specify extra query parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchResource(String type, String id, + {Map headers, QueryParameters parameters}) => + _client.fetchResource(_url.resource(type, id), + headers: headers, parameters: parameters); + + /// Fetches a resource collection . + /// Use [headers] to pass extra HTTP headers. + /// Use [parameters] to specify extra query parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchCollection(String type, + {Map headers, QueryParameters parameters}) => + _client.fetchCollection(_url.collection(type), + headers: headers, parameters: parameters); + + /// Fetches a related resource. + /// Use [headers] to pass extra HTTP headers. + /// Use [parameters] to specify extra query parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchRelatedResource( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchResource(_url.related(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a related resource collection. + /// Use [headers] to pass extra HTTP headers. + /// Use [parameters] to specify extra query parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchRelatedCollection( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchCollection(_url.related(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a to-one relationship + /// Use [headers] to pass extra HTTP headers. + /// Use [queryParameters] to specify extra request parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchToOne( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchToOne(_url.relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// 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. + /// Use [parameters] to specify extra query parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchRelationship( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchRelationship(_url.relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a to-many relationship + /// Use [headers] to pass extra HTTP headers. + /// Use [queryParameters] to specify extra request parameters, such as: + /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) + /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) + Future> fetchToMany( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchToMany(_url.relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// Deletes the resource referenced by [type] and [id]. + /// + /// https://jsonapi.org/format/#crud-deleting + Future deleteResource(String type, String id, + {Map headers}) => + _client.deleteResource(_url.resource(type, id), headers: headers); + + /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] + /// with id = null. + Future> deleteToOne( + String type, String id, String relationship, + {Map headers}) => + _client.deleteToOne(_url.relationship(type, id, relationship), + headers: headers); + + /// Removes the [identifiers] from the to-many relationship. + /// + /// https://jsonapi.org/format/#crud-updating-to-many-relationships + Future> deleteFromToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.deleteFromToMany( + _url.relationship(type, id, relationship), identifiers, + headers: headers); + + /// Updates the [resource]. + /// + /// https://jsonapi.org/format/#crud-updating + Future> updateResource(Resource resource, + {Map headers}) => + _client.updateResource( + _url.resource(resource.type, resource.id), resource, + headers: headers); + + /// Adds the given set of [identifiers] to a to-many relationship. + /// + /// https://jsonapi.org/format/#crud-updating-to-many-relationships + Future> addToRelationship(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.addToRelationship( + _url.relationship(type, id, relationship), identifiers, + headers: headers); + + /// Replaces a to-many relationship with the given set of [identifiers]. + /// + /// https://jsonapi.org/format/#crud-updating-to-many-relationships + Future> replaceToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.replaceToMany( + _url.relationship(type, id, relationship), identifiers, + headers: headers); + + /// Updates a to-one relationship via PATCH query + /// + /// https://jsonapi.org/format/#crud-updating-to-one-relationships + Future> replaceToOne( + String type, String id, String relationship, Identifier identifier, + {Map headers}) => + _client.replaceToOne( + _url.relationship(type, id, relationship), identifier, + headers: headers); + + /// Closes the internal client. You have to either call this method or + /// close the client yourself. + void close() => _client.close(); +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index f63e1ac9..001f0116 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -30,4 +30,7 @@ class Identifier { @override String toString() => 'Identifier($type:$id)'; + + @override + bool operator ==(other) => equals(other); } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 81c8d38e..29af67a4 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -36,6 +36,9 @@ class Resource { ArgumentError.checkNotNull(type, 'type'); } + /// Resource type and id combined + String get key => '$type:$id'; + Identifier toIdentifier() { if (id == null) { throw StateError('Can not create an Identifier with id==null'); @@ -44,8 +47,40 @@ class Resource { } @override - String toString() => 'Resource(${type}:${id})'; + String toString() => 'Resource($key $attributes)'; + + /// Returns true if this resource has the same [key] and all [attributes] + /// and relationships as the [other] (not necessarily with the same values). + /// This method can be used to chose between 200 and 204 in PATCH requests. + /// See https://jsonapi.org/format/#crud-updating-responses + bool hasAllMembersOf(Resource other) => + other.key == key && + other.attributes.keys.every(attributes.containsKey) && + other.toOne.keys.every(toOne.containsKey) && + other.toMany.keys.every(toMany.containsKey); + + /// Adds all attributes and relationships from the [other] resource which + /// are not present in this resource. Returns a new instance. + Resource withExtraMembersFrom(Resource other) => Resource(type, id, + attributes: _merge(other.attributes, attributes), + toOne: _merge(other.toOne, toOne), + toMany: _merge(other.toMany, toMany)); - Resource withId(String id) => - Resource(type, id, attributes: attributes, toOne: toOne, toMany: toMany); + /// Creates a new instance of the resource with replaced properties + Resource replace( + {String type, + String id, + Map attributes, + Map toOne, + Map> toMany}) => + Resource(type ?? this.type, id ?? this.id, + attributes: attributes ?? this.attributes, + toOne: toOne ?? this.toOne, + toMany: toMany ?? this.toMany); + + Map _merge(Map source, Map dest) { + final copy = {...dest}; + source.forEach((k, v) => copy.putIfAbsent(k, () => v)); + return copy; + } } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 9fe9cbfc..2fc58170 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -15,7 +15,6 @@ class ResourceData extends PrimaryData { included: included, links: {...?resourceObject?.links, ...?links}); static ResourceData fromJson(Object json) { - print(json); if (json is Map) { final included = json['included']; final resources = []; diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index 9a2b017e..43b494ef 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -22,19 +22,19 @@ abstract class JsonApiController { R request, String type, Resource resource); FutureOr updateResource( - R request, String type, String id); + R request, String type, String id, Resource resource); - FutureOr updateToOne( - R request, String type, String id, String relationship); + FutureOr replaceToOne(R request, String type, String id, + String relationship, Identifier identifier); - FutureOr updateToMany( - R request, String type, String id, String relationship); + FutureOr replaceToMany(R request, String type, String id, + String relationship, Iterable identifiers); - FutureOr deleteFromRelationship( - R request, String type, String id, String relationship); + FutureOr deleteFromRelationship(R request, String type, + String id, String relationship, Iterable identifiers); - FutureOr addToRelationship( - R request, String type, String id, String relationship); + FutureOr addToRelationship(R request, String type, + String id, String relationship, Iterable identifiers); } abstract class ControllerRequest { @@ -52,7 +52,7 @@ class ControllerRequestFactory implements RequestFactory { @override ControllerRequest createResource(CollectionTarget target) => - CreateResource(target); + _CreateResource(target); @override ControllerRequest deleteFromRelationship(RelationshipTarget target) => @@ -97,21 +97,21 @@ class _AddToRelationship implements ControllerRequest { @override FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } + JsonApiController controller, Object jsonPayload, R request) => + controller.addToRelationship(request, target.type, target.id, + target.relationship, ToMany.fromJson(jsonPayload).unwrap()); } class _DeleteFromRelationship implements ControllerRequest { - _DeleteFromRelationship(RelationshipTarget target); + final RelationshipTarget target; + + _DeleteFromRelationship(this.target); @override FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteFromRelationship(request, target.type, target.id, + target.relationship, ToMany.fromJson(jsonPayload).unwrap()); } class _UpdateResource implements ControllerRequest { @@ -121,16 +121,15 @@ class _UpdateResource implements ControllerRequest { @override FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } + JsonApiController controller, Object jsonPayload, R request) => + controller.updateResource(request, target.type, target.id, + ResourceData.fromJson(jsonPayload).unwrap()); } -class CreateResource implements ControllerRequest { +class _CreateResource implements ControllerRequest { final CollectionTarget target; - CreateResource(this.target); + _CreateResource(this.target); @override FutureOr call( @@ -157,10 +156,9 @@ class _FetchRelationship implements ControllerRequest { @override FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelationship( + request, target.type, target.id, target.relationship); } class _FetchRelated implements ControllerRequest { @@ -205,7 +203,15 @@ class _UpdateRelationship implements ControllerRequest { @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) { - return null; + final relationship = Relationship.fromJson(jsonPayload); + if (relationship is ToOne) { + return controller.replaceToOne(request, target.type, target.id, + target.relationship, relationship.unwrap()); + } + if (relationship is ToMany) { + return controller.replaceToMany(request, target.type, target.id, + target.relationship, relationship.unwrap()); + } } } diff --git a/lib/src/server/response/resource_created_response.dart b/lib/src/server/response/resource_created_response.dart index f41c2259..82897838 100644 --- a/lib/src/server/response/resource_created_response.dart +++ b/lib/src/server/response/resource_created_response.dart @@ -13,7 +13,7 @@ class ResourceCreatedResponse extends ControllerResponse { @override Document buildDocument( ServerDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource); + builder.makeCreatedResourceDocument(resource); @override Map buildHeaders(UrlFactory urlFactory) => { diff --git a/lib/src/server/server_document_factory.dart b/lib/src/server/server_document_factory.dart index 6696222f..d9a41c16 100644 --- a/lib/src/server/server_document_factory.dart +++ b/lib/src/server/server_document_factory.dart @@ -47,6 +47,20 @@ class ServerDocumentFactory { included: included?.map(_resourceObject)), api: _api); + /// A document containing a single (primary) resource which has been created + /// on the server. The difference with [makeResourceDocument] is that this + /// method generates the `self` link to match the `location` header. + /// + /// This is the quote from the documentation: + /// > If the resource object returned by the response contains a self key + /// > in its links member and a Location header is provided, the value of + /// > the self member MUST match the value of the Location header. + /// + /// See https://jsonapi.org/format/#crud-creating-responses-201 + Document makeCreatedResourceDocument(Resource resource) => + makeResourceDocument( + _urlFactory.resource(resource.type, resource.id), resource); + /// A document containing a single related resource Document makeRelatedResourceDocument( Uri self, Resource resource, {Iterable included}) => diff --git a/test/functional/browser_compat_test.dart b/test/functional/browser_compat_test.dart index 27943bf8..adead7b0 100644 --- a/test/functional/browser_compat_test.dart +++ b/test/functional/browser_compat_test.dart @@ -1,29 +1,22 @@ -import 'package:http/http.dart' as http; +import 'dart:io'; + import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/src/url_design/path_based_url_design.dart'; import 'package:test/test.dart'; +/// Make sure [JsonApiClient] can be used in a browser void main() async { - http.Request request; - http.Response response; - final httpClient = http.Client(); - final client = JsonApiClient(httpClient, onHttpCall: (req, resp) { - request = req; - response = resp; - }); - - test('can fetch collection', () async { - final channel = spawnHybridUri('test_server.dart'); - final port = await channel.stream.first; - final r = await client - .fetchCollection(Uri.parse('http://localhost:$port/companies')); - - httpClient.close(); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.data.unwrap().first.attributes['name'], 'Tesla'); - - expect(request, isNotNull); - expect(response, isNotNull); - expect(response.body, isNotEmpty); + test('can create and fetch a resource', () async { + final uri = Uri.parse('http://localhost:8080'); + final channel = spawnHybridUri('server.dart', message: uri); + final HttpServer server = await channel.stream.first; + final client = UrlAwareClient(PathBasedUrlDesign(uri)); + await client.createResource( + Resource('messages', '1', attributes: {'text': 'Hello World'})); + final r = await client.fetchResource('messages', '1'); + expect(r.data.unwrap().attributes['text'], 'Hello World'); + client.close(); + await server.close(); }, testOn: 'browser'); } diff --git a/test/functional/client/crud_test.dart b/test/functional/client/crud_test.dart new file mode 100644 index 00000000..3c0a84c5 --- /dev/null +++ b/test/functional/client/crud_test.dart @@ -0,0 +1,213 @@ +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../example/server/crud_controller.dart'; +import '../../../example/server/shelf_request_response_converter.dart'; + +/// Basic CRUD operations +void main() async { + HttpServer server; + UrlAwareClient client; + final host = 'localhost'; + final port = 8081; + final design = + PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); + final people = [ + 'Erich Gamma', + 'Richard Helm', + 'Ralph Johnson', + 'John Vlissides', + ] + .map((name) => name.split(' ')) + .map((name) => Resource('people', Uuid().v4(), + attributes: {'firstName': name.first, 'lastName': name.last})) + .toList(); + + final publisher = Resource('companies', Uuid().v4(), + attributes: {'name': 'Addison-Wesley'}); + + final book = Resource('books', Uuid().v4(), + attributes: {'title': 'Design Patterns'}, + toOne: {'publisher': Identifier.of(publisher)}, + toMany: {'authors': people.map(Identifier.of).toList()}); + + setUp(() async { + client = UrlAwareClient(design); + final handler = createHttpHandler( + ShelfRequestResponseConverter(), CRUDController(Uuid().v4), design); + + server = await serve(handler, host, port); + + await for (final resource + in Stream.fromIterable([...people, publisher, book])) { + await client.createResource(resource); + } + }); + + tearDown(() async { + client.close(); + await server.close(); + }); + + group('Fetch', () { + test('a primary resource', () async { + final r = await client.fetchResource(book.type, book.id); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().attributes['title'], 'Design Patterns'); + expect(r.data.unwrap().toOne['publisher'].type, publisher.type); + expect(r.data.unwrap().toOne['publisher'].id, publisher.id); + expect(r.data.unwrap().toMany['authors'].length, 4); + expect(r.data.unwrap().toMany['authors'].first.type, 'people'); + expect(r.data.unwrap().toMany['authors'].last.type, 'people'); + }); + + test('a primary collection', () async { + final r = await client.fetchCollection('people'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 4); + expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); + expect(r.data.unwrap().first.attributes['lastName'], 'Gamma'); + }); + + test('a related resource', () async { + final r = + await client.fetchRelatedResource(book.type, book.id, 'publisher'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().attributes['name'], 'Addison-Wesley'); + }); + + test('a related collection', () async { + final r = + await client.fetchRelatedCollection(book.type, book.id, 'authors'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 4); + expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); + expect(r.data.unwrap().first.attributes['lastName'], 'Gamma'); + }); + + test('a to-one relationship', () async { + final r = await client.fetchToOne(book.type, book.id, 'publisher'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().type, publisher.type); + expect(r.data.unwrap().id, publisher.id); + }); + + test('a generic to-one relationship', () async { + final r = await client.fetchRelationship(book.type, book.id, 'publisher'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + + final data = r.data; + if (data is ToOne) { + expect(data.unwrap().type, publisher.type); + expect(data.unwrap().id, publisher.id); + } else { + fail('data is not ToOne'); + } + }); + + test('a to-many relationship', () async { + final r = await client.fetchToMany(book.type, book.id, 'authors'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 4); + expect(r.data.unwrap().first.type, people.first.type); + expect(r.data.unwrap().first.id, people.first.id); + }); + + test('a generic to-many relationship', () async { + final r = await client.fetchRelationship(book.type, book.id, 'authors'); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + final data = r.data; + if (data is ToMany) { + expect(data.unwrap().length, 4); + expect(data.unwrap().first.type, people.first.type); + expect(data.unwrap().first.id, people.first.id); + } else { + fail('data is not ToMany'); + } + }); + }, testOn: 'vm'); + + group('Delete', () { + test('a primary resource', () async { + await client.deleteResource(book.type, book.id); + + final r = await client.fetchResource(book.type, book.id); + expect(r.status, 404); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + }); + + test('a to-one relationship', () async { + await client.deleteToOne(book.type, book.id, 'publisher'); + + final r = await client.fetchResource(book.type, book.id); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().toOne['publisher'], isNull); + }); + + test('in a to-many relationship', () async { + await client.deleteFromToMany( + book.type, book.id, 'authors', people.take(2).map(Identifier.of)); + + final r = await client.fetchToMany(book.type, book.id, 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().last.id, people.last.id); + }); + }, testOn: 'vm'); + + group('Update', () { + test('a primary resource', () async { + await client.updateResource(book.replace(attributes: {'pageCount': 416})); + + final r = await client.fetchResource(book.type, book.id); + expect(r.status, 200); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().attributes['pageCount'], 416); + }); + + test('to-one relationship', () async { + await client.replaceToOne( + book.type, book.id, 'publisher', Identifier('companies', '100')); + + final r = await client.fetchResource(book.type, book.id); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().toOne['publisher'].id, '100'); + }); + + test('a to-many relationship by adding more identifiers', () async { + await client.addToRelationship( + book.type, book.id, 'authors', [Identifier('people', '100')]); + + final r = await client.fetchToMany(book.type, book.id, 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 5); + expect(r.data.unwrap().last.id, '100'); + }); + + test('a to-many relationship by replacing', () async { + await client.replaceToMany( + book.type, book.id, 'authors', [Identifier('people', '100')]); + + final r = await client.fetchToMany(book.type, book.id, 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.data.unwrap().length, 1); + expect(r.data.unwrap().first.id, '100'); + }); + }, testOn: 'vm'); +} diff --git a/test/functional/create_test.dart b/test/functional/create_test.dart deleted file mode 100644 index fd8426ff..00000000 --- a/test/functional/create_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../example/server.dart'; - -void main() async { - HttpServer server; - Client httpClient; - JsonApiClient client; - final host = 'localhost'; - final port = 8081; - final urlDesign = - PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); - - setUp(() async { - httpClient = Client(); - client = JsonApiClient(httpClient); - final handler = createHttpHandler( - ShelfRequestResponseConverter(), CRUDController(), urlDesign); - - server = await serve(handler, host, port); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - group('resource', () { - /// If a POST query did not include a Client-Generated ID and the requested - /// resource has been created successfully, the server MUST return a 201 Created status code. - /// - /// The response SHOULD include a Location header identifying the location of the newly created resource. - /// - /// The response MUST also include a document that contains the primary resource created. - /// - /// If the resource object returned by the response contains a self key in its links member - /// and a Location header is provided, the value of the self member MUST match the value of the Location header. - /// - /// https://jsonapi.org/format/#crud-creating-responses-201 - test('201 Created', () async { - final newYork = - Resource('cities', null, attributes: {'name': 'New York'}); - final r0 = - await client.createResource(urlDesign.collection('cities'), newYork); - - expect(r0.status, 201); - expect(r0.isSuccessful, true); - expect(r0.data.unwrap().id, isNotEmpty); - expect(r0.data.unwrap().type, 'cities'); - expect(r0.data.unwrap().attributes['name'], 'New York'); - expect(r0.location, isNotNull); - - // Make sure the resource is available - final r1 = await client - .fetchResource(urlDesign.resource('cities', r0.data.unwrap().id)); - expect(r1.data.resourceObject.attributes['name'], 'New York'); - }); - - /// If a query to create a resource has been accepted for processing, - /// but the processing has not been completed by the time the server responds, - /// the server MUST return a 202 Accepted status code. - /// - /// https://jsonapi.org/format/#crud-creating-responses-202 -// test('202 Accepted', () async { -// final roadster2020 = -// Resource('models', null, attributes: {'name': 'Roadster 2020'}); -// final r0 = await client.createResource( -// urlDesign.collection('models'), roadster2020, -// headers: {'Prefer': 'return-asynch'}); -// -// expect(r0.status, 202); -// expect(r0.isSuccessful, false); // neither success -// expect(r0.isFailed, false); // nor failure yet -// expect(r0.isAsync, true); // yay async! -// expect(r0.document, isNull); -// expect(r0.asyncDocument, isNotNull); -// expect(r0.asyncData.unwrap().type, 'jobs'); -// expect(r0.location, isNull); -// expect(r0.contentLocation, isNotNull); -// -// final r1 = await client.fetchResource(r0.contentLocation); -// expect(r1.status, 200); -// expect(r1.data.unwrap().type, 'jobs'); -// -// await Future.delayed(Duration(milliseconds: 100)); -// -// // When it's done, this will be the created resource -// final r2 = await client.fetchResource(r0.contentLocation); -// expect(r2.data.unwrap().type, 'models'); -// expect(r2.data.unwrap().attributes['name'], 'Roadster 2020'); -// }); - - /// If a POST query did include a Client-Generated ID and the requested - /// resource has been created successfully, the server MUST return either - /// a 201 Created status code and response document (as described above) - /// or a 204 No Content status code with no response document. - /// - /// https://jsonapi.org/format/#crud-creating-responses-204 - test('204 No Content', () async { - final newYork = - Resource('cities', '555', attributes: {'name': 'New York'}); - final r0 = - await client.createResource(urlDesign.collection('cities'), newYork); - - expect(r0.status, 204); - expect(r0.isSuccessful, true); - expect(r0.document, isNull); - - // Make sure the resource is available - final r1 = - await client.fetchResource(urlDesign.resource('cities', '555')); - expect(r1.data.unwrap().attributes['name'], 'New York'); - }); - - /// A server MUST return 409 Conflict when processing a POST query to - /// create a resource with a client-generated ID that already exists. - /// - /// https://jsonapi.org/format/#crud-creating-responses-409 - test('409 Conflict - Resource already exists', () async { - final newYork = Resource('cities', '1', attributes: {'name': 'New York'}); - final r0 = - await client.createResource(urlDesign.collection('cities'), newYork); - - expect(r0.isSuccessful, true); - - final r1 = - await client.createResource(urlDesign.collection('cities'), newYork); - - expect(r1.status, 409); - expect(r1.isSuccessful, false); - expect(r1.document.errors.first.detail, 'Resource already exists'); - }); - - /// A server MUST return 409 Conflict when processing a POST query in - /// which the resource object’s type is not among the type(s) that - /// constitute the collection represented by the endpoint. - /// - /// https://jsonapi.org/format/#crud-creating-responses-409 - test('409 Conflict - Incompatible type', () async { - final newYork = - Resource('cities', '555', attributes: {'name': 'New York'}); - final r0 = await client.createResource( - urlDesign.collection('companies'), newYork); - - expect(r0.status, 409); - expect(r0.isSuccessful, false); - expect(r0.document.errors.first.detail, 'Incompatible type'); - }); - }, testOn: 'vm'); -} diff --git a/test/functional/delete_test.dart b/test/functional/delete_test.dart deleted file mode 100644 index b31218c1..00000000 --- a/test/functional/delete_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../example/server.dart'; - -void main() async { - HttpServer server; - Client httpClient; - JsonApiClient client; - final host = 'localhost'; - final port = 8081; - final urlDesign = - PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); - - setUp(() async { - httpClient = Client(); - client = JsonApiClient(httpClient); - final handler = createHttpHandler( - ShelfRequestResponseConverter(), CRUDController(), urlDesign); - - server = await serve(handler, host, port); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - group('resource', () { - /// A server MUST return a 204 No Content status code if a deletion query - /// is successful and no content is returned. - /// - /// https://jsonapi.org/format/#crud-deleting-responses-204 - test('204 No Content', () async { - final apple = Resource('apples', '1'); - final r0 = - await client.createResource(urlDesign.collection('apples'), apple); - - expect(r0.isSuccessful, true); - - final r1 = await client.deleteResource(urlDesign.resource('apples', '1')); - - expect(r1.status, 204); - expect(r1.isSuccessful, true); - expect(r1.document, isNull); - - // Make sure the resource is not available anymore - final r2 = await client.fetchResource(urlDesign.resource('apples', '1')); - expect(r2.status, 404); - }); - - /// A server MUST return a 200 OK status code if a deletion query - /// is successful and the server responds with only top-level meta data. - /// - /// https://jsonapi.org/format/#crud-deleting-responses-200 - test('200 OK', () async { - final apple = Resource('apples', '1', - toOne: {'origin': Identifier('countries', '2')}); - final r0 = - await client.createResource(urlDesign.collection('apples'), apple); - - expect(r0.isSuccessful, true); - - final r1 = await client.deleteResource(urlDesign.resource('apples', '1')); - - expect(r1.status, 200); - expect(r1.isSuccessful, true); - expect(r1.document.meta['relationships'], 1); - - // Make sure the resource is not available anymore - final r2 = await client.fetchResource(urlDesign.resource('apples', '1')); - expect(r2.status, 404); - }); - - /// https://jsonapi.org/format/#crud-deleting-responses-404 - /// - /// A server SHOULD return a 404 Not Found status code if a deletion query - /// fails due to the resource not existing. - test('404 Not Found', () async { - final r0 = - await client.deleteResource(urlDesign.resource('models', '555')); - expect(r0.status, 404); - }); - }, testOn: 'vm'); -} diff --git a/test/functional/fetch_test.dart b/test/functional/fetch_test.dart deleted file mode 100644 index 0139c3e1..00000000 --- a/test/functional/fetch_test.dart +++ /dev/null @@ -1,404 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../example/server.dart'; - -void main() async { - HttpServer server; - Client httpClient; - JsonApiClient client; - final host = 'localhost'; - final port = 8081; - final urlDesign = - PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); - - void seedData() async { - final fowler = - Resource('people', '1', attributes: {'name': 'Martin Fowler'}); - final beck = Resource('people', '2', attributes: {'name': 'Kent Beck'}); - final martin = - Resource('people', '3', attributes: {'name': 'Robert C. Matin'}); - final norton = - Resource('people', '4', attributes: {'name': 'Peter Norton'}); - final microsoft = - Resource('companies', '1', attributes: {'name': 'Microsoft Press'}); - final addison = Resource('companies', '2', - attributes: {'name': 'Addison-Wesley Professional'}); - final ibmGuide = Resource('books', '1', attributes: { - 'title': "The Peter Norton Programmer's Guide to the IBM PC" - }, toMany: { - 'authors': [Identifier.of(norton)] - }, toOne: { - 'publisher': Identifier.of(microsoft) - }); - final refactoring = Resource('books', '2', attributes: { - 'title': 'Refactoring' - }, toMany: { - 'authors': [Identifier.of(fowler), Identifier.of(beck)] - }, toOne: { - 'publisher': Identifier.of(addison) - }); - - final incomplete = Resource('books', '10', - attributes: {'title': 'Incomplete book'}, - toMany: {'authors': []}, - toOne: {'publisher': null}); - - await for (final r in Stream.fromIterable([ - fowler, - beck, - martin, - norton, - microsoft, - ibmGuide, - refactoring, - incomplete - ])) { - await client.createResource(urlDesign.collection(r.type), r); - } - } - - setUp(() async { - httpClient = Client(); - client = JsonApiClient(httpClient); - final handler = createHttpHandler( - ShelfRequestResponseConverter(), CRUDController(), urlDesign); - - server = await serve(handler, host, port); - await seedData(); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - group('collection', () { - /// https://jsonapi.org/format/#fetching-resources-responses - /// - /// A server MUST respond to a successful request to fetch a - /// resource collection with an array of resource objects or an - /// empty array ([]) as the response document’s primary data. - test('empty primary collection', () async { - final r0 = await client.fetchCollection(urlDesign.collection('unicorns')); - - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.collection.length, 0); - }); - - test('non-empty primary collection', () async { - final r0 = await client.fetchCollection(urlDesign.collection('people')); - - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.collection.length, 4); - }); - - test('empty related collection', () async { - final r0 = await client - .fetchCollection(urlDesign.related('books', '10', 'authors')); - - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.collection.length, 0); - }); - - test('non-empty related collection', () async { - final r0 = await client - .fetchCollection(urlDesign.related('books', '2', 'authors')); - - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.collection.length, 2); - expect(r0.document.data.collection.first.attributes['name'], - 'Martin Fowler'); - expect(r0.document.data.collection.last.attributes['name'], 'Kent Beck'); - }); - }, testOn: 'vm'); - - group('resource', () { - /// A server MUST respond to a successful request to fetch an - /// individual resource or resource collection with a 200 OK response. - test('primary resource', () async { - final r0 = await client.fetchResource(urlDesign.resource('people', '1')); - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.unwrap().attributes['name'], 'Martin Fowler'); - expect(r0.document.data.unwrap().type, 'people'); - }); - - test('primary resource not found', () async { - final r0 = - await client.fetchResource(urlDesign.resource('unicorns', '555')); - expect(r0.status, 404); - expect(r0.isSuccessful, false); - expect(r0.document.errors.first.detail, 'Resource not found'); - }); - - test('related resource', () async { - final r0 = await client - .fetchResource(urlDesign.related('books', '1', 'publisher')); - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.unwrap().attributes['name'], 'Microsoft Press'); - expect(r0.document.data.unwrap().type, 'companies'); - }); - - test('null related resource', () async { - final r0 = await client - .fetchResource(urlDesign.related('books', '10', 'publisher')); - expect(r0.status, 200); - expect(r0.isSuccessful, true); - expect(r0.document.data.unwrap(), null); - }); - - test('related resource not found (primary resouce not found)', () async { - final r0 = await client - .fetchResource(urlDesign.related('unicorns', '1', 'owner')); - expect(r0.status, 404); - expect(r0.isSuccessful, false); - expect(r0.document.errors.first.detail, 'Resource not found'); - }); - - test('related resource not found (relationship not found)', () async { - final r0 = await client - .fetchResource(urlDesign.related('people', '1', 'unicorn')); - expect(r0.status, 404); - expect(r0.isSuccessful, false); - expect(r0.document.errors.first.detail, 'Relatioship not found'); - }); - }, testOn: 'vm'); - -// group('collection', () { -// test('resource collection', () async { -// final uri = url.collection('companies'); -// final r = await client.fetchCollection(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// final resObj = r.data.collection.first; -// expect(resObj.attributes['name'], 'Tesla'); -// expect(resObj.self.uri, url.resource('companies', '1')); -// expect(resObj.relationships['hq'].related.uri, -// url.related('companies', '1', 'hq')); -// expect(resObj.relationships['hq'].self.uri, -// url.relationship('companies', '1', 'hq')); -// expect(r.data.self.uri, uri); -// }); -// -// test('resource collection traversal', () async { -// final uri = -// url.collection('companies').replace(queryParameters: {'foo': 'bar'}); -// -// final r0 = await client.fetchCollection(uri); -// final somePage = r0.data; -// -// expect(somePage.next.uri.queryParameters['foo'], 'bar', -// reason: 'query parameters must be preserved'); -// -// final r1 = await client.fetchCollection(somePage.next.uri); -// final secondPage = r1.data; -// expect(secondPage.collection.first.attributes['name'], 'BMW'); -// expect(secondPage.self.uri, somePage.next.uri); -// -// expect(secondPage.last.uri.queryParameters['foo'], 'bar', -// reason: 'query parameters must be preserved'); -// -// final r2 = await client.fetchCollection(secondPage.last.uri); -// final lastPage = r2.data; -// expect(lastPage.collection.first.attributes['name'], 'Toyota'); -// expect(lastPage.self.uri, secondPage.last.uri); -// -// expect(lastPage.prev.uri.queryParameters['foo'], 'bar', -// reason: 'query parameters must be preserved'); -// -// final r3 = await client.fetchCollection(lastPage.prev.uri); -// final secondToLastPage = r3.data; -// expect(secondToLastPage.collection.first.attributes['name'], 'Audi'); -// expect(secondToLastPage.self.uri, lastPage.prev.uri); -// -// expect(secondToLastPage.first.uri.queryParameters['foo'], 'bar', -// reason: 'query parameters must be preserved'); -// -// final r4 = await client.fetchCollection(secondToLastPage.first.uri); -// final firstPage = r4.data; -// expect(firstPage.collection.first.attributes['name'], 'Tesla'); -// expect(firstPage.self.uri, secondToLastPage.first.uri); -// }); -// -// test('related collection', () async { -// final uri = url.related('companies', '1', 'models'); -// final r = await client.fetchCollection(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.collection.first.attributes['name'], 'Roadster'); -// expect(r.data.self.uri, uri); -// }); -// -// test('related collection traversal', () async { -// final uri = url.related('companies', '1', 'models'); -// final r0 = await client.fetchCollection(uri); -// final firstPage = r0.data; -// expect(firstPage.collection.length, 1); -// -// final r1 = await client.fetchCollection(firstPage.last.uri); -// final lastPage = r1.data; -// expect(lastPage.collection.length, 1); -// }); -// -// test('404', () async { -// final r = await client.fetchCollection(url.collection('unicorns')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// expect(r.document.errors.first.detail, 'Unknown resource type unicorns'); -// }); -// }, testOn: 'vm'); -// -// group('single resource', () { -// test('single resource', () async { -// final uri = url.resource('models', '1'); -// final r = await client.fetchResource(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap().attributes['name'], 'Roadster'); -// expect(r.data.self.uri, uri); -// }); -// -// test('404 on type', () async { -// final r = await client.fetchResource(url.resource('unicorns', '1')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// }); -// -// test('404 on id', () async { -// final r = await client.fetchResource(url.resource('models', '555')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// }); -// }, testOn: 'vm'); -// -// group('related resource', () { -// test('related resource', () async { -// final uri = url.related('companies', '1', 'hq'); -// final r = await client.fetchResource(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap().attributes['name'], 'Palo Alto'); -// expect(r.data.self.uri, uri); -// }); -// -// test('404 on type', () async { -// final r = await client.fetchResource(url.related('unicorns', '1', 'hq')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// }); -// -// test('404 on id', () async { -// final r = await client.fetchResource(url.related('models', '555', 'hq')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// }); -// -// test('404 on relationship', () async { -// final r = -// await client.fetchResource(url.related('companies', '1', 'unicorn')); -// expect(r.status, 404); -// expect(r.isSuccessful, false); -// }); -// }, testOn: 'vm'); -// -// group('relationships', () { -// test('to-one', () async { -// final uri = url.relationship('companies', '1', 'hq'); -// final r = await client.fetchToOne(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap().type, 'cities'); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri.toString(), -// 'http://localhost:$port/companies/1/hq'); -// }); -// -// test('empty to-one', () async { -// final uri = url.relationship('companies', '3', 'hq'); -// final r = await client.fetchToOne(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap(), isNull); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri, url.related('companies', '3', 'hq')); -// }); -// -// test('generic to-one', () async { -// final uri = url.relationship('companies', '1', 'hq'); -// final r = await client.fetchRelationship(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data, TypeMatcher()); -// expect((r.data as ToOne).unwrap().type, 'cities'); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri, url.related('companies', '1', 'hq')); -// }); -// -// test('to-many', () async { -// final uri = url.relationship('companies', '1', 'models'); -// final r = await client.fetchToMany(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.identifiers.first.type, 'models'); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri, url.related('companies', '1', 'models')); -// }); -// -// test('empty to-many', () async { -// final uri = url.relationship('companies', '3', 'models'); -// final r = await client.fetchToMany(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.identifiers, isEmpty); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri, url.related('companies', '3', 'models')); -// }); -// -// test('generic to-many', () async { -// final uri = url.relationship('companies', '1', 'models'); -// final r = await client.fetchRelationship(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data, TypeMatcher()); -// expect((r.data as ToMany).identifiers.first.type, 'models'); -// expect(r.data.self.uri, uri); -// expect(r.data.related.uri, url.related('companies', '1', 'models')); -// }); -// }, testOn: 'vm'); -// -// group('compound document', () { -// test('single resource compound document', () async { -// final uri = url.resource('companies', '1'); -// final r = -// await client.fetchResource(uri, parameters: Include(['models'])); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap().attributes['name'], 'Tesla'); -// expect(r.data.included.length, 4); -// expect(r.data.included.last.type, 'models'); -// expect(r.data.included.last.attributes['name'], 'Model 3'); -// }); -// -// test('"included" member should not present if not requested', () async { -// final uri = url.resource('companies', '1'); -// final r = await client.fetchResource(uri); -// expect(r.status, 200); -// expect(r.isSuccessful, true); -// expect(r.data.unwrap().attributes['name'], 'Tesla'); -// expect(r.data.included, null); -// }); -// }, testOn: 'vm'); -} diff --git a/test/functional/hooks_test.dart b/test/functional/hooks_test.dart deleted file mode 100644 index 196b25a3..00000000 --- a/test/functional/hooks_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../example/server.dart'; - -void main() async { - http.Request request; - http.Response response; - HttpServer server; - http.Client httpClient; - JsonApiClient client; - final host = 'localhost'; - final port = 8081; - final urlDesign = - PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); - - setUp(() async { - httpClient = http.Client(); - client = JsonApiClient(httpClient, onHttpCall: (rq, rs) { - request = rq; - response = rs; - }); - final handler = createHttpHandler( - ShelfRequestResponseConverter(), CRUDController(), urlDesign); - - server = await serve(handler, host, port); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - group('hooks', () { - test('onHttpCall gets called', () async { - await client.createResource( - urlDesign.collection('apples'), Resource('apples', '1')); - - expect(request, isNotNull); - expect(response, isNotNull); - expect(response.statusCode, 204); - }); - }, testOn: 'vm'); -} diff --git a/test/functional/server.dart b/test/functional/server.dart new file mode 100644 index 00000000..e4d5bd5b --- /dev/null +++ b/test/functional/server.dart @@ -0,0 +1,20 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/url_design.dart'; +import 'package:shelf/shelf_io.dart' as shelf; +import 'package:stream_channel/stream_channel.dart'; +import 'package:uuid/uuid.dart'; + +import '../../example/server/crud_controller.dart'; +import '../../example/server/shelf_request_response_converter.dart'; + +void hybridMain(StreamChannel channel, Object uri) async { + if (uri is Uri) { + channel.sink.add(await shelf.serve( + createHttpHandler(ShelfRequestResponseConverter(), + CRUDController(Uuid().v4), PathBasedUrlDesign(uri)), + uri.host, + uri.port)); + return; + } + throw ArgumentError.value(uri); +} diff --git a/test/functional/test_server.dart b/test/functional/test_server.dart deleted file mode 100644 index 129a38fa..00000000 --- a/test/functional/test_server.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'dart:io'; - -import 'package:stream_channel/stream_channel.dart'; - -import '../../example/server.dart'; - -void hybridMain(StreamChannel channel, Object message) async { - final port = 8080; - await createServer(InternetAddress.loopbackIPv4, port); - channel.sink.add(port); -} diff --git a/test/functional/update_test.dart b/test/functional/update_test.dart deleted file mode 100644 index 79af109d..00000000 --- a/test/functional/update_test.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/url_design.dart'; -import 'package:test/test.dart'; - -import '../../example/server.dart'; - -void main() async { - HttpServer server; - Client httpClient; - JsonApiClient client; - final port = 8084; - final url = PathBasedUrlDesign(Uri.parse('http://localhost:$port')); - - setUp(() async { - httpClient = Client(); - client = JsonApiClient(httpClient); - server = await createServer(InternetAddress.loopbackIPv4, port); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - /// Updating a Resource’s Attributes - /// ================================ - /// - /// Any or all of a resource’s attributes MAY be included - /// in the resource object included in a PATCH query. - /// - /// If a query does not include all of the attributes for a resource, - /// the server MUST interpret the missing attributes as if they were - /// included with their current values. The server MUST NOT interpret - /// missing attributes as null values. - /// - /// Updating a Resource’s Relationships - /// =================================== - /// - /// Any or all of a resource’s relationships MAY be included - /// in the resource object included in a PATCH query. - /// - /// If a query does not include all of the relationships for a resource, - /// the server MUST interpret the missing relationships as if they were - /// included with their current values. It MUST NOT interpret them - /// as null or empty values. - /// - /// If a relationship is provided in the relationships member - /// of a resource object in a PATCH query, its value MUST be - /// a relationship object with a data member. - /// The relationship’s value will be replaced with the value specified in this member. - group('resource', () { - /// If a server accepts an update but also changes the resource(s) - /// in ways other than those specified by the query (for example, - /// updating the updated-at attribute or a computed sha), - /// it MUST return a 200 OK response. - /// - /// The response document MUST include a representation of the - /// updated resource(s) as if a GET query was made to the query URL. - /// - /// A server MUST return a 200 OK status code if an update is successful, - /// the client’s current attributes remain up to date, and the server responds - /// only with top-level meta data. In this case the server MUST NOT include - /// a representation of the updated resource(s). - /// - /// https://jsonapi.org/format/#crud-updating-responses-200 - test('200 OK', () async { - final r0 = await client.fetchResource(url.resource('companies', '1')); - final original = r0.document.data.unwrap(); - - expect(original.attributes['name'], 'Tesla'); - expect(original.attributes['nasdaq'], isNull); - expect(original.toMany['models'].length, 4); - - final modified = Resource(original.type, original.id, - attributes: {...original.attributes} - ..['nasdaq'] = 'TSLA' - ..remove('name'), - toMany: {...original.toMany}..['models'].removeLast(), - toOne: {...original.toOne}..['headquarters'] = null); - - final r1 = - await client.updateResource(url.resource('companies', '1'), modified); - final updated = r1.document.data.unwrap(); - - expect(r1.status, 200); - expect(updated.attributes['name'], 'Tesla'); - expect(updated.attributes['nasdaq'], 'TSLA'); - expect(updated.toMany['models'].length, 3); - expect(updated.toOne['headquarters'], isNull); - expect( - updated.attributes['updatedAt'] != original.attributes['updatedAt'], - true); - }); - - /// If an update is successful and the server doesn’t update any attributes - /// besides those provided, the server MUST return either - /// a 200 OK status code and response document (as described above) - /// or a 204 No Content status code with no response document. - /// - /// https://jsonapi.org/format/#crud-updating-responses-204 - test('204 No Content', () async { - final r0 = await client.fetchResource(url.resource('models', '3')); - final original = r0.document.data.unwrap(); - - expect(original.attributes['name'], 'Model X'); - - final modified = Resource(original.type, original.id, - attributes: {...original.attributes}..['name'] = 'Model XXX', - toOne: original.toOne, - toMany: original.toMany); - - final r1 = - await client.updateResource(url.resource('models', '3'), modified); - expect(r1.status, 204); - expect(r1.document, isNull); - - final r2 = await client.fetchResource(url.resource('models', '3')); - - expect(r2.data.unwrap().attributes['name'], 'Model XXX'); - }); - - /// A server MAY return 409 Conflict when processing a PATCH query - /// to update a resource if that update would violate other - /// server-enforced constraints (such as a uniqueness constraint - /// on a property other than id). - /// - /// A server MUST return 409 Conflict when processing a PATCH query - /// in which the resource object’s type and id do not match the server’s endpoint. - /// - /// https://jsonapi.org/format/#crud-updating-responses-409 - test('409 Conflict - Endpoint mismatch', () async { - final r0 = await client.fetchResource(url.resource('models', '3')); - final original = r0.document.data.unwrap(); - - final r1 = - await client.updateResource(url.resource('companies', '1'), original); - expect(r1.status, 409); - expect(r1.document.errors.first.detail, 'Incompatible type'); - }); - }, testOn: 'vm'); - - /// Updating Relationships - /// ====================== - /// - /// Although relationships can be modified along with resources (as described above), - /// JSON:API also supports updating of relationships independently at URLs from relationship links. - /// - /// Note: Relationships are updated without exposing the underlying server semantics, - /// such as foreign keys. Furthermore, relationships can be updated without necessarily - /// affecting the related resources. For example, if an article has many authors, - /// it is possible to remove one of the authors from the article without deleting the person itself. - /// Similarly, if an article has many tags, it is possible to add or remove tags. - /// Under the hood on the server, the first of these examples - /// might be implemented with a foreign key, while the second - /// could be implemented with a join table, but the JSON:API protocol would be the same in both cases. - /// - /// Note: A server may choose to delete the underlying resource - /// if a relationship is deleted (as a garbage collection measure). - /// - /// https://jsonapi.org/format/#crud-updating-relationships - group('relationship', () { - /// Updating To-One Relationships - /// ============================= - /// - /// A server MUST respond to PATCH requests to a URL from a to-one - /// relationship link as described below. - /// - /// The PATCH query MUST include a top-level member named data containing one of: - /// - a resource identifier object corresponding to the new related resource. - /// - null, to remove the relationship. - group('to-one', () { - group('replace', () { - test('204 No Content', () async { - final relationship = url.relationship('companies', '1', 'hq'); - final r0 = await client.fetchToOne(relationship); - final original = r0.document.data.unwrap(); - expect(original.id, '2'); - - final r1 = await client.replaceToOne( - relationship, Identifier(original.type, '1')); - expect(r1.status, 204); - - final r2 = await client.fetchToOne(relationship); - final updated = r2.document.data.unwrap(); - expect(updated.type, original.type); - expect(updated.id, '1'); - }); - }); - - group('remove', () { - test('204 No Content', () async { - final relationship = url.relationship('companies', '1', 'hq'); - - final r0 = await client.fetchToOne(relationship); - final original = r0.document.data.unwrap(); - expect(original.id, '2'); - - final r1 = await client.deleteToOne(relationship); - expect(r1.status, 204); - - final r2 = await client.fetchToOne(relationship); - expect(r2.document.data.unwrap(), isNull); - }); - }); - }, testOn: 'vm'); - - /// Updating To-Many Relationships - /// ============================== - /// - /// A server MUST respond to PATCH, POST, and DELETE requests to a URL - /// from a to-many relationship link as described below. - /// - /// For all query types, the body MUST contain a data member - /// whose value is an empty array or an array of resource identifier objects. - group('to-many', () { - /// If a client makes a PATCH query to a URL from a to-many relationship link, - /// the server MUST either completely replace every member of the relationship, - /// return an appropriate error response if some resources can not be - /// found or accessed, or return a 403 Forbidden response if complete replacement - /// is not allowed by the server. - group('replace', () { - test('204 No Content', () async { - final relationship = url.relationship('companies', '1', 'models'); - final r0 = await client.fetchToMany(relationship); - final original = r0.data.identifiers.map((_) => _.id); - expect(original, ['1', '2', '3', '4']); - - final r1 = await client.replaceToMany(relationship, - [Identifier('models', '5'), Identifier('models', '6')]); - expect(r1.status, 204); - - final r2 = await client.fetchToMany(relationship); - final updated = r2.data.identifiers.map((_) => _.id); - expect(updated, ['5', '6']); - }); - }); - - /// If a client makes a POST query to a URL from a relationship link, - /// the server MUST add the specified members to the relationship - /// unless they are already present. - /// If a given type and id is already in the relationship, the server MUST NOT add it again. - /// - /// Note: This matches the semantics of databases that use foreign keys - /// for has-many relationships. Document-based storage should check - /// the has-many relationship before appending to avoid duplicates. - /// - /// If all of the specified resources can be added to, or are already present in, - /// the relationship then the server MUST return a successful response. - /// - /// Note: This approach ensures that a query is successful if the server’s state - /// matches the requested state, and helps avoid pointless race conditions - /// caused by multiple clients making the same changes to a relationship. - group('add', () { - test('200 OK', () async { - final models = url.relationship('companies', '1', 'models'); - final r0 = await client.fetchToMany(models); - final original = r0.data.identifiers.map((_) => _.id); - expect(original, ['1', '2', '3', '4']); - - final r1 = await client.addToMany( - models, [Identifier('models', '1'), Identifier('models', '5')]); - expect(r1.status, 200); - - final updated = r1.data.identifiers.map((_) => _.id); - expect(updated, ['1', '2', '3', '4', '5']); - }); - }); - }); - }, testOn: 'vm'); -} diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 520f3694..d0bb1d5c 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -5,9 +5,15 @@ void main() { test('Can not create Identifier when id==null', () { expect(() => Resource('type', null).toIdentifier(), throwsStateError); }); + test('Can create Identifier', () { final id = Resource('apples', '123').toIdentifier(); expect(id.type, 'apples'); expect(id.id, '123'); }); + + test('Has key', () { + expect(Resource('apples', '123').key, 'apples:123'); + expect(Resource('apples', null).key, 'apples:null'); + }); } From 5d90bbe6d82b69d7e6392c5bebace31fbb763c87 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 12 Jan 2020 21:55:17 -0800 Subject: [PATCH 03/99] WIP --- .gitignore | 4 ++ example/fetch_collection.dart | 19 ++++------ example/server/server.dart | 4 +- lib/src/client/json_api_client.dart | 2 +- lib/src/server/http_handler.dart | 10 +++-- lib/src/server/json_api_controller.dart | 20 ++++++++++ .../server/response/accepted_response.dart | 2 +- .../server/response/collection_response.dart | 5 +++ lib/src/server/response/error_response.dart | 5 +++ .../server/response/json_api_response.dart | 4 +- lib/src/server/response/meta_response.dart | 6 +++ .../server/response/no_content_response.dart | 8 +++- .../response/related_collection_response.dart | 6 +++ .../response/related_resource_response.dart | 6 +++ .../response/resource_created_response.dart | 2 +- .../server/response/resource_response.dart | 6 +++ .../response/resource_updated_response.dart | 6 +++ .../server/response/see_other_response.dart | 2 +- lib/src/server/response/to_many_response.dart | 6 +++ lib/src/server/response/to_one_response.dart | 6 +++ lib/src/server/target.dart | 38 ++++++++++++++++--- lib/src/url_design/path_based_url_design.dart | 2 +- lib/src/url_design/url_design.dart | 2 +- pubspec.yaml | 1 + test/functional/browser_compat_test.dart | 22 ----------- test/functional/{client => }/crud_test.dart | 4 +- test/functional/server.dart | 20 ---------- .../path_based_url_design_test.dart | 8 ++-- 28 files changed, 148 insertions(+), 78 deletions(-) delete mode 100644 test/functional/browser_compat_test.dart rename test/functional/{client => }/crud_test.dart (98%) delete mode 100644 test/functional/server.dart diff --git a/.gitignore b/.gitignore index f85956e4..363fa3b8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ pubspec.lock # Directory created by dartdoc # If you don't generate documentation locally you can remove this line. doc/api/ + +# Generated by test_coverage +test/.test_coverage.dart +coverage/ \ No newline at end of file diff --git a/example/fetch_collection.dart b/example/fetch_collection.dart index 0a15770a..e8fc60bc 100644 --- a/example/fetch_collection.dart +++ b/example/fetch_collection.dart @@ -1,15 +1,12 @@ -import 'package:http/http.dart'; import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/url_design.dart'; -/// Start `dart example/server.dart` first +/// Start `dart example/server/server.dart` first void main() async { - final httpClient = Client(); - final jsonApiClient = JsonApiClient(httpClient); - final url = Uri.parse('http://localhost:8080/companies'); - final response = await jsonApiClient.fetchCollection(url); - httpClient.close(); // Don't forget to close the http client - print('The collection page size is ${response.data.collection.length}'); - final resource = response.data.unwrap().first; - print('The last element is ${resource}'); - resource.attributes.forEach((k, v) => print('Attribute $k is $v')); + final url = Uri.parse('http://localhost:8080'); + final client = UrlAwareClient(PathBasedUrlDesign(url)); + await client.createResource( + Resource('messages', '1', attributes: {'text': 'Hello World'})); + client.close(); } diff --git a/example/server/server.dart b/example/server/server.dart index a4439f0a..08385b2f 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:json_api/server.dart'; import 'package:json_api/src/server/http_handler.dart'; import 'package:json_api/url_design.dart'; @@ -15,6 +17,6 @@ void main() async { final jsonApiHandler = createHttpHandler(ShelfRequestResponseConverter(), CRUDController(Uuid().v4), PathBasedUrlDesign(baseUri)); - await serve(jsonApiHandler, host, port); + await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); print('Serving at $baseUri'); } diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index add204a8..4fb1b5cc 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -15,7 +15,7 @@ import 'package:json_api/src/client/status_code.dart'; /// import 'package:http/http.dart'; /// import 'package:json_api/client.dart'; /// -/// /// Start `dart example/server.dart` first! +/// /// Start `dart example/hybrid_server.dart` first! /// void main() async { /// final httpClient = Client(); /// final jsonApiClient = JsonApiClient(httpClient); diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart index 2f1cd95d..4b47894e 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/http_handler.dart @@ -29,10 +29,12 @@ HttpHandler createHttpHandler( final response = await target .getRequest(method, requestFactory) .call(controller, requestDocument, request); - return converter.createResponse( - response.statusCode, - json.encode(response.buildDocument(docFactory, uri)), - response.buildHeaders(urlDesign)); + return converter.createResponse(response.statusCode, + json.encode(response.buildDocument(docFactory, uri)), { + ...response.buildHeaders(urlDesign), + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Request-Headers': 'X-PINGOTHER, Content-Type' + }); }; } diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index 43b494ef..8f632acf 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/target.dart'; @@ -88,6 +89,25 @@ class ControllerRequestFactory implements RequestFactory { @override ControllerRequest updateResource(ResourceTarget target) => _UpdateResource(target); + + @override + ControllerRequest options(Target target) { + return _Options(target); + } +} + +class _Options implements ControllerRequest { + final Target target; + + _Options(this.target); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + NoContentResponse(headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': target.allowedMethods.join(', ') + }); } class _AddToRelationship implements ControllerRequest { diff --git a/lib/src/server/response/accepted_response.dart b/lib/src/server/response/accepted_response.dart index a1748b85..65dcd348 100644 --- a/lib/src/server/response/accepted_response.dart +++ b/lib/src/server/response/accepted_response.dart @@ -15,7 +15,7 @@ class AcceptedResponse extends ControllerResponse { @override Map buildHeaders(UrlFactory urlFactory) => { - ...super.buildHeaders(urlFactory), + 'Content-Type': Document.contentType, 'Content-Location': urlFactory.resource(resource.type, resource.id).toString(), }; diff --git a/lib/src/server/response/collection_response.dart b/lib/src/server/response/collection_response.dart index 38969bb3..2b372ffb 100644 --- a/lib/src/server/response/collection_response.dart +++ b/lib/src/server/response/collection_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class CollectionResponse extends ControllerResponse { final Iterable collection; @@ -15,4 +16,8 @@ class CollectionResponse extends ControllerResponse { ServerDocumentFactory builder, Uri self) => builder.makeCollectionDocument(self, collection, included: included, total: total); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType }; } diff --git a/lib/src/server/response/error_response.dart b/lib/src/server/response/error_response.dart index 3d48ab08..f69f0b40 100644 --- a/lib/src/server/response/error_response.dart +++ b/lib/src/server/response/error_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class ErrorResponse extends ControllerResponse { final Iterable errors; @@ -20,4 +21,8 @@ class ErrorResponse extends ControllerResponse { const ErrorResponse.methodNotAllowed(this.errors) : super(405); const ErrorResponse.conflict(this.errors) : super(409); + + @override + Map buildHeaders(UrlFactory urlFactory) => { + 'Content-Type': Document.contentType }; } diff --git a/lib/src/server/response/json_api_response.dart b/lib/src/server/response/json_api_response.dart index b04b99cd..b3e58e4a 100644 --- a/lib/src/server/response/json_api_response.dart +++ b/lib/src/server/response/json_api_response.dart @@ -9,6 +9,6 @@ abstract class ControllerResponse { Document buildDocument(ServerDocumentFactory factory, Uri self); - Map buildHeaders(UrlFactory urlFactory) => - {'Content-Type': Document.contentType}; + Map buildHeaders(UrlFactory urlFactory); + } diff --git a/lib/src/server/response/meta_response.dart b/lib/src/server/response/meta_response.dart index 0a341bef..1b7ade76 100644 --- a/lib/src/server/response/meta_response.dart +++ b/lib/src/server/response/meta_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class MetaResponse extends ControllerResponse { final Map meta; @@ -10,4 +11,9 @@ class MetaResponse extends ControllerResponse { @override Document buildDocument(ServerDocumentFactory builder, Uri self) => builder.makeMetaDocument(meta); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/no_content_response.dart b/lib/src/server/response/no_content_response.dart index 378a424c..2e1084e1 100644 --- a/lib/src/server/response/no_content_response.dart +++ b/lib/src/server/response/no_content_response.dart @@ -1,12 +1,18 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/url_design.dart'; class NoContentResponse extends ControllerResponse { - const NoContentResponse() : super(204); + final Map headers; + + const NoContentResponse({this.headers = const {}}) : super(204); @override Document buildDocument( ServerDocumentFactory factory, Uri self) => null; + + @override + Map buildHeaders(UrlFactory urlFactory) => headers; } diff --git a/lib/src/server/response/related_collection_response.dart b/lib/src/server/response/related_collection_response.dart index 251930f1..932b3008 100644 --- a/lib/src/server/response/related_collection_response.dart +++ b/lib/src/server/response/related_collection_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class RelatedCollectionResponse extends ControllerResponse { final Iterable collection; @@ -14,4 +15,9 @@ class RelatedCollectionResponse extends ControllerResponse { Document buildDocument( ServerDocumentFactory builder, Uri self) => builder.makeRelatedCollectionDocument(self, collection, total: total); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/related_resource_response.dart b/lib/src/server/response/related_resource_response.dart index 0a36f357..34c2729d 100644 --- a/lib/src/server/response/related_resource_response.dart +++ b/lib/src/server/response/related_resource_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class RelatedResourceResponse extends ControllerResponse { final Resource resource; @@ -12,4 +13,9 @@ class RelatedResourceResponse extends ControllerResponse { Document buildDocument( ServerDocumentFactory builder, Uri self) => builder.makeRelatedResourceDocument(self, resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => { + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/resource_created_response.dart b/lib/src/server/response/resource_created_response.dart index 82897838..a3f8e942 100644 --- a/lib/src/server/response/resource_created_response.dart +++ b/lib/src/server/response/resource_created_response.dart @@ -17,7 +17,7 @@ class ResourceCreatedResponse extends ControllerResponse { @override Map buildHeaders(UrlFactory urlFactory) => { - ...super.buildHeaders(urlFactory), + 'Content-Type': Document.contentType, 'Location': urlFactory.resource(resource.type, resource.id).toString() }; } diff --git a/lib/src/server/response/resource_response.dart b/lib/src/server/response/resource_response.dart index 70fd5b45..bc973191 100644 --- a/lib/src/server/response/resource_response.dart +++ b/lib/src/server/response/resource_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class ResourceResponse extends ControllerResponse { final Resource resource; @@ -12,4 +13,9 @@ class ResourceResponse extends ControllerResponse { Document buildDocument( ServerDocumentFactory builder, Uri self) => builder.makeResourceDocument(self, resource, included: included); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/resource_updated_response.dart b/lib/src/server/response/resource_updated_response.dart index b33f39a0..bfbb6242 100644 --- a/lib/src/server/response/resource_updated_response.dart +++ b/lib/src/server/response/resource_updated_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class ResourceUpdatedResponse extends ControllerResponse { final Resource resource; @@ -11,4 +12,9 @@ class ResourceUpdatedResponse extends ControllerResponse { Document buildDocument( ServerDocumentFactory builder, Uri self) => builder.makeResourceDocument(self, resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => { + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/see_other_response.dart b/lib/src/server/response/see_other_response.dart index c0b49ab4..95aae304 100644 --- a/lib/src/server/response/see_other_response.dart +++ b/lib/src/server/response/see_other_response.dart @@ -14,7 +14,7 @@ class SeeOtherResponse extends ControllerResponse { @override Map buildHeaders(UrlFactory urlFactory) => { - ...super.buildHeaders(urlFactory), + 'Location': urlFactory.resource(type, id).toString() }; } diff --git a/lib/src/server/response/to_many_response.dart b/lib/src/server/response/to_many_response.dart index c22df59a..b58111e5 100644 --- a/lib/src/server/response/to_many_response.dart +++ b/lib/src/server/response/to_many_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class ToManyResponse extends ControllerResponse { final Iterable collection; @@ -14,4 +15,9 @@ class ToManyResponse extends ControllerResponse { @override Document buildDocument(ServerDocumentFactory builder, Uri self) => builder.makeToManyDocument(self, collection, type, id, relationship); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/response/to_one_response.dart b/lib/src/server/response/to_one_response.dart index 37dc76c6..c3372ee1 100644 --- a/lib/src/server/response/to_one_response.dart +++ b/lib/src/server/response/to_one_response.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response/json_api_response.dart'; import 'package:json_api/src/server/server_document_factory.dart'; +import 'package:json_api/src/url_design/url_design.dart'; class ToOneResponse extends ControllerResponse { final String type; @@ -14,4 +15,9 @@ class ToOneResponse extends ControllerResponse { @override Document buildDocument(ServerDocumentFactory builder, Uri self) => builder.makeToOneDocument(self, identifier, type, id, relationship); + + @override + Map buildHeaders(UrlFactory urlFactory) =>{ + 'Content-Type': Document.contentType + }; } diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index 7ca910ec..af6d4e7e 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -36,18 +36,27 @@ abstract class RequestFactory { /// allowed by the [target]. Most likely, this should lead to either /// `405 Method Not Allowed` or `400 Bad Request`. R invalid(Target target, String method); + + /// Returns and object representing an OPTIONS request to the target + R options(Target target); } /// The target of a JSON:API request URI. The URI target and the request method /// uniquely identify the meaning of the JSON:API request. abstract class Target { + List get allowedMethods; + /// Returns the request corresponding to the request [method]. R getRequest(String method, RequestFactory factory); } /// Request URI target which is not recognized by the URL Design. class UnmatchedTarget implements Target { - UnmatchedTarget(); + final Uri uri; + @override + final allowedMethods = const ['OPTONS']; + + const UnmatchedTarget(this.uri); @override R getRequest(String method, RequestFactory factory) => @@ -62,7 +71,10 @@ class ResourceTarget implements Target { /// Resource id final String id; - ResourceTarget(this.type, this.id); + @override + final allowedMethods = const ['GET', 'DELETE', 'PATCH', 'OPTONS']; + + const ResourceTarget(this.type, this.id); @override R getRequest(String method, RequestFactory factory) { @@ -73,6 +85,8 @@ class ResourceTarget implements Target { return factory.deleteResource(this); case 'PATCH': return factory.updateResource(this); + case 'OPTIONS': + return factory.options(this); default: return factory.invalid(this, method); } @@ -84,7 +98,10 @@ class CollectionTarget implements Target { /// Resource type final String type; - CollectionTarget(this.type); + @override + final allowedMethods = const ['GET', 'POST', 'OPTONS']; + + const CollectionTarget(this.type); @override R getRequest(String method, RequestFactory factory) { @@ -93,6 +110,8 @@ class CollectionTarget implements Target { return factory.fetchCollection(this); case 'POST': return factory.createResource(this); + case 'OPTIONS': + return factory.options(this); default: return factory.invalid(this, method); } @@ -110,6 +129,9 @@ class RelatedTarget implements Target { /// Relationship name final String relationship; + @override + final allowedMethods = const ['GET', 'OPTONS']; + const RelatedTarget(this.type, this.id, this.relationship); @override @@ -117,6 +139,8 @@ class RelatedTarget implements Target { switch (method.toUpperCase()) { case 'GET': return factory.fetchRelated(this); + case 'OPTIONS': + return factory.options(this); default: return factory.invalid(this, method); } @@ -133,8 +157,10 @@ class RelationshipTarget implements Target { /// Relationship name final String relationship; + @override + final allowedMethods = const ['GET', 'PATCH', 'POST', 'DELETE', 'OPTONS']; - RelationshipTarget(this.type, this.id, this.relationship); + const RelationshipTarget(this.type, this.id, this.relationship); @override R getRequest(String method, RequestFactory factory) { @@ -147,6 +173,8 @@ class RelationshipTarget implements Target { return factory.addToRelationship(this); case 'DELETE': return factory.deleteFromRelationship(this); + case 'OPTIONS': + return factory.options(this); default: return factory.invalid(this, method); } @@ -157,7 +185,7 @@ class TargetFactory implements MatchCase { const TargetFactory(); @override - Target unmatched() => UnmatchedTarget(); + Target unmatched(Uri uri) => UnmatchedTarget(uri); @override Target collection(String type) => CollectionTarget(type); diff --git a/lib/src/url_design/path_based_url_design.dart b/lib/src/url_design/path_based_url_design.dart index 64e37498..e1b2924c 100644 --- a/lib/src/url_design/path_based_url_design.dart +++ b/lib/src/url_design/path_based_url_design.dart @@ -56,7 +56,7 @@ class PathBasedUrlDesign implements UrlDesign { return matchCase.relationship(seg[0], seg[1], seg[3]); } } - return matchCase.unmatched(); + return matchCase.unmatched(uri); } Uri _appendToBase(List segments) => diff --git a/lib/src/url_design/url_design.dart b/lib/src/url_design/url_design.dart index b13a60d0..5220bfa4 100644 --- a/lib/src/url_design/url_design.dart +++ b/lib/src/url_design/url_design.dart @@ -38,5 +38,5 @@ abstract class MatchCase { T related(String type, String id, String relationship); - T unmatched(); + T unmatched(Uri uri); } diff --git a/pubspec.yaml b/pubspec.yaml index e0f37482..aa2fc5bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,3 +12,4 @@ dev_dependencies: json_matcher: ^0.2.3 stream_channel: ^2.0.0 uuid: ^2.0.1 + test_coverage: ^0.4.0 diff --git a/test/functional/browser_compat_test.dart b/test/functional/browser_compat_test.dart deleted file mode 100644 index adead7b0..00000000 --- a/test/functional/browser_compat_test.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/src/url_design/path_based_url_design.dart'; -import 'package:test/test.dart'; - -/// Make sure [JsonApiClient] can be used in a browser -void main() async { - test('can create and fetch a resource', () async { - final uri = Uri.parse('http://localhost:8080'); - final channel = spawnHybridUri('server.dart', message: uri); - final HttpServer server = await channel.stream.first; - final client = UrlAwareClient(PathBasedUrlDesign(uri)); - await client.createResource( - Resource('messages', '1', attributes: {'text': 'Hello World'})); - final r = await client.fetchResource('messages', '1'); - expect(r.data.unwrap().attributes['text'], 'Hello World'); - client.close(); - await server.close(); - }, testOn: 'browser'); -} diff --git a/test/functional/client/crud_test.dart b/test/functional/crud_test.dart similarity index 98% rename from test/functional/client/crud_test.dart rename to test/functional/crud_test.dart index 3c0a84c5..861d24fe 100644 --- a/test/functional/client/crud_test.dart +++ b/test/functional/crud_test.dart @@ -8,8 +8,8 @@ import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; -import '../../../example/server/crud_controller.dart'; -import '../../../example/server/shelf_request_response_converter.dart'; +import '../../example/server/crud_controller.dart'; +import '../../example/server/shelf_request_response_converter.dart'; /// Basic CRUD operations void main() async { diff --git a/test/functional/server.dart b/test/functional/server.dart deleted file mode 100644 index e4d5bd5b..00000000 --- a/test/functional/server.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; -import 'package:shelf/shelf_io.dart' as shelf; -import 'package:stream_channel/stream_channel.dart'; -import 'package:uuid/uuid.dart'; - -import '../../example/server/crud_controller.dart'; -import '../../example/server/shelf_request_response_converter.dart'; - -void hybridMain(StreamChannel channel, Object uri) async { - if (uri is Uri) { - channel.sink.add(await shelf.serve( - createHttpHandler(ShelfRequestResponseConverter(), - CRUDController(Uuid().v4), PathBasedUrlDesign(uri)), - uri.host, - uri.port)); - return; - } - throw ArgumentError.value(uri); -} 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 4cd0ab79..53741a6e 100644 --- a/test/unit/url_design/path_based_url_design_test.dart +++ b/test/unit/url_design/path_based_url_design_test.dart @@ -58,26 +58,26 @@ void main() { test('Does not match collection URL with incorrect path', () { expect(routing.match(Uri.parse('http://example.com/foo/apples'), mapper), - 'unmatched'); + 'unmatched:http://example.com/foo/apples'); }); test('Does not match collection URL with incorrect host', () { expect(routing.match(Uri.parse('http://example.org/api/apples'), mapper), - 'unmatched'); + 'unmatched:http://example.org/api/apples'); }); test('Does not match collection URL with incorrect port', () { expect( routing.match( Uri.parse('http://example.com:8080/api/apples'), mapper), - 'unmatched'); + 'unmatched:http://example.com:8080/api/apples'); }); }); } class _Mapper implements MatchCase { @override - String unmatched() => 'unmatched'; + String unmatched(Uri uri) => 'unmatched:$uri'; @override String collection(String type) => 'collection:$type'; From 8ff57605cb9d159e731992cdbf35c9de22ccbbe4 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 13 Jan 2020 23:57:47 -0800 Subject: [PATCH 04/99] WIP --- example/server/crud_controller.dart | 80 +++-- lib/server.dart | 17 +- lib/src/server/controller/controller.dart | 37 +++ .../controller_request_factory.dart} | 104 ++----- lib/src/server/http_handler.dart | 7 +- lib/src/server/json_api_response.dart | 281 ++++++++++++++++++ .../server/response/accepted_response.dart | 22 -- .../server/response/collection_response.dart | 23 -- lib/src/server/response/error_response.dart | 28 -- .../server/response/json_api_response.dart | 14 - lib/src/server/response/meta_response.dart | 19 -- .../server/response/no_content_response.dart | 18 -- .../response/related_collection_response.dart | 23 -- .../response/related_resource_response.dart | 21 -- .../response/resource_created_response.dart | 23 -- .../server/response/resource_response.dart | 21 -- .../response/resource_updated_response.dart | 20 -- .../server/response/see_other_response.dart | 20 -- lib/src/server/response/to_many_response.dart | 23 -- lib/src/server/response/to_one_response.dart | 23 -- lib/src/server/target.dart | 27 +- test/unit/server/response_test.dart | 11 - 22 files changed, 390 insertions(+), 472 deletions(-) create mode 100644 lib/src/server/controller/controller.dart rename lib/src/server/{json_api_controller.dart => controller/controller_request_factory.dart} (57%) create mode 100644 lib/src/server/json_api_response.dart delete mode 100644 lib/src/server/response/accepted_response.dart delete mode 100644 lib/src/server/response/collection_response.dart delete mode 100644 lib/src/server/response/error_response.dart delete mode 100644 lib/src/server/response/json_api_response.dart delete mode 100644 lib/src/server/response/meta_response.dart delete mode 100644 lib/src/server/response/no_content_response.dart delete mode 100644 lib/src/server/response/related_collection_response.dart delete mode 100644 lib/src/server/response/related_resource_response.dart delete mode 100644 lib/src/server/response/resource_created_response.dart delete mode 100644 lib/src/server/response/resource_response.dart delete mode 100644 lib/src/server/response/resource_updated_response.dart delete mode 100644 lib/src/server/response/see_other_response.dart delete mode 100644 lib/src/server/response/to_many_response.dart delete mode 100644 lib/src/server/response/to_one_response.dart delete mode 100644 test/unit/server/response_test.dart diff --git a/example/server/crud_controller.dart b/example/server/crud_controller.dart index da7f0a66..cc7f4b07 100644 --- a/example/server/crud_controller.dart +++ b/example/server/crud_controller.dart @@ -4,7 +4,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; import 'package:shelf/shelf.dart'; -class CRUDController implements JsonApiController { +class CRUDController implements Controller { final String Function() generateId; final store = >{}; @@ -12,54 +12,50 @@ class CRUDController implements JsonApiController { CRUDController(this.generateId); @override - FutureOr createResource( + FutureOr createResource( Request request, String type, Resource resource) { if (resource.type != type) { - return ErrorResponse.conflict( + return JsonApiResponse.conflict( [JsonApiError(detail: 'Incompatible type')]); } final repo = _repo(type); if (resource.id != null) { if (repo.containsKey(resource.id)) { - return ErrorResponse.conflict( + return JsonApiResponse.conflict( [JsonApiError(detail: 'Resource already exists')]); } repo[resource.id] = resource; - return NoContentResponse(); + return JsonApiResponse.noContent(); } final id = generateId(); repo[id] = resource.replace(id: id); - return ResourceCreatedResponse(repo[id]); + return JsonApiResponse.resourceCreated(repo[id]); } @override - FutureOr fetchResource( + FutureOr fetchResource( Request request, String type, String id) { final repo = _repo(type); if (repo.containsKey(id)) { - return ResourceResponse(repo[id]); + return JsonApiResponse.resource(repo[id]); } - return ErrorResponse.notFound( + return JsonApiResponse.notFound( [JsonApiError(detail: 'Resource not found', status: '404')]); } @override - FutureOr addToRelationship(Request request, String type, + FutureOr addToRelationship(Request request, String type, String id, String relationship, Iterable identifiers) { final resource = _repo(type)[id]; final ids = [...resource.toMany[relationship], ...identifiers]; _repo(type)[id] = resource.replace(toMany: {...resource.toMany, relationship: ids}); - return ToManyResponse(type, id, relationship, ids); + return JsonApiResponse.toMany(type, id, relationship, ids); } @override - FutureOr deleteFromRelationship( - Request request, - String type, - String id, - String relationship, - Iterable identifiers) { + FutureOr deleteFromRelationship(Request request, String type, + String id, String relationship, Iterable identifiers) { final resource = _repo(type)[id]; final rel = [...resource.toMany[relationship]]; rel.removeWhere(identifiers.contains); @@ -67,96 +63,98 @@ class CRUDController implements JsonApiController { toMany[relationship] = rel; _repo(type)[id] = resource.replace(toMany: toMany); - return ToManyResponse(type, id, relationship, rel); + return JsonApiResponse.toMany(type, id, relationship, rel); } @override - FutureOr deleteResource( + FutureOr deleteResource( Request request, String type, String id) { final repo = _repo(type); if (!repo.containsKey(id)) { - return ErrorResponse.notFound( + return JsonApiResponse.notFound( [JsonApiError(detail: 'Resource not found')]); } final resource = repo[id]; repo.remove(id); final relationships = {...resource.toOne, ...resource.toMany}; if (relationships.isNotEmpty) { - return MetaResponse({'relationships': relationships.length}); + return JsonApiResponse.meta({'relationships': relationships.length}); } - return NoContentResponse(); + return JsonApiResponse.noContent(); } @override - FutureOr fetchCollection(Request request, String type) { + FutureOr fetchCollection(Request request, String type) { final repo = _repo(type); - return CollectionResponse(repo.values); + return JsonApiResponse.collection(repo.values); } @override - FutureOr fetchRelated( + FutureOr fetchRelated( Request request, String type, String id, String relationship) { final resource = _repo(type)[id]; if (resource == null) { - return ErrorResponse.notFound( + return JsonApiResponse.notFound( [JsonApiError(detail: 'Resource not found')]); } if (resource.toOne.containsKey(relationship)) { final related = resource.toOne[relationship]; if (related == null) { - return RelatedResourceResponse(null); + return JsonApiResponse.relatedResource(null); } - return RelatedResourceResponse(_repo(related.type)[related.id]); + return JsonApiResponse.relatedResource(_repo(related.type)[related.id]); } if (resource.toMany.containsKey(relationship)) { - return RelatedCollectionResponse( + return JsonApiResponse.relatedCollection( resource.toMany[relationship].map((r) => _repo(r.type)[r.id])); } - return ErrorResponse.notFound( + return JsonApiResponse.notFound( [JsonApiError(detail: 'Relatioship not found')]); } @override - FutureOr fetchRelationship( + FutureOr fetchRelationship( Request request, String type, String id, String relationship) { final r = _repo(type)[id]; if (r.toOne.containsKey(relationship)) { - return ToOneResponse(type, id, relationship, r.toOne[relationship]); + return JsonApiResponse.toOne( + type, id, relationship, r.toOne[relationship]); } if (r.toMany.containsKey(relationship)) { - return ToManyResponse(type, id, relationship, r.toMany[relationship]); + return JsonApiResponse.toMany( + type, id, relationship, r.toMany[relationship]); } - return ErrorResponse.notFound( + return JsonApiResponse.notFound( [JsonApiError(detail: 'Relationship not found')]); } @override - FutureOr updateResource( + FutureOr updateResource( Request request, String type, String id, Resource resource) { final current = _repo(type)[id]; if (resource.hasAllMembersOf(current)) { _repo(type)[id] = resource; - return NoContentResponse(); + return JsonApiResponse.noContent(); } _repo(type)[id] = resource.withExtraMembersFrom(current); - return ResourceUpdatedResponse(_repo(type)[id]); + return JsonApiResponse.resourceUpdated(_repo(type)[id]); } @override - FutureOr replaceToMany(Request request, String type, + FutureOr replaceToMany(Request request, String type, String id, String relationship, Iterable identifiers) { final resource = _repo(type)[id]; final toMany = {...resource.toMany, relationship: identifiers.toList()}; _repo(type)[id] = resource.replace(toMany: toMany); - return ToManyResponse(type, id, relationship, identifiers); + return JsonApiResponse.toMany(type, id, relationship, identifiers); } @override - FutureOr replaceToOne(Request request, String type, + FutureOr replaceToOne(Request request, String type, String id, String relationship, Identifier identifier) { _repo(type)[id] = _repo(type)[id].replace(toOne: {relationship: identifier}); - return NoContentResponse(); + return JsonApiResponse.noContent(); } Map _repo(String type) { diff --git a/lib/server.dart b/lib/server.dart index 37e825ba..29423b9e 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,23 +3,10 @@ /// The server API is not stable. Expect breaking changes. library server; +export 'package:json_api/src/server/controller/controller.dart'; export 'package:json_api/src/server/http_handler.dart'; -export 'package:json_api/src/server/json_api_controller.dart'; +export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/pagination/fixed_size_page.dart'; export 'package:json_api/src/server/pagination/pagination_strategy.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/json_api_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/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_document_factory.dart'; export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/server/controller/controller.dart b/lib/src/server/controller/controller.dart new file mode 100644 index 00000000..32f3b33a --- /dev/null +++ b/lib/src/server/controller/controller.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +abstract class Controller { + FutureOr fetchCollection(R request, String type); + + FutureOr fetchResource(R request, String type, String id); + + FutureOr fetchRelated( + R request, String type, String id, String relationship); + + FutureOr fetchRelationship( + R request, String type, String id, String relationship); + + FutureOr deleteResource(R request, String type, String id); + + FutureOr createResource( + R request, String type, Resource resource); + + FutureOr updateResource( + R request, String type, String id, Resource resource); + + FutureOr replaceToOne(R request, String type, String id, + String relationship, Identifier identifier); + + FutureOr replaceToMany(R request, String type, String id, + String relationship, Iterable identifiers); + + FutureOr deleteFromRelationship(R request, String type, + String id, String relationship, Iterable identifiers); + + FutureOr addToRelationship(R request, String type, String id, + String relationship, Iterable identifiers); +} diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/controller/controller_request_factory.dart similarity index 57% rename from lib/src/server/json_api_controller.dart rename to lib/src/server/controller/controller_request_factory.dart index 8f632acf..38456ba8 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/controller/controller_request_factory.dart @@ -1,47 +1,14 @@ import 'dart:async'; import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; +import 'package:json_api/src/server/controller/controller.dart'; +import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/target.dart'; -abstract class JsonApiController { - FutureOr fetchCollection(R request, String type); - - FutureOr fetchResource(R request, String type, String id); - - FutureOr fetchRelated( - R request, String type, String id, String relationship); - - FutureOr fetchRelationship( - R request, String type, String id, String relationship); - - FutureOr deleteResource( - R request, String type, String id); - - FutureOr createResource( - R request, String type, Resource resource); - - FutureOr updateResource( - R request, String type, String id, Resource resource); - - FutureOr replaceToOne(R request, String type, String id, - String relationship, Identifier identifier); - - FutureOr replaceToMany(R request, String type, String id, - String relationship, Iterable identifiers); - - FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers); - - FutureOr addToRelationship(R request, String type, - String id, String relationship, Iterable identifiers); -} - abstract class ControllerRequest { /// Calls the appropriate method of the controller - FutureOr call( - JsonApiController controller, Object jsonPayload, R request); + FutureOr call( + Controller controller, Object jsonPayload, R request); } class ControllerRequestFactory implements RequestFactory { @@ -89,25 +56,6 @@ class ControllerRequestFactory implements RequestFactory { @override ControllerRequest updateResource(ResourceTarget target) => _UpdateResource(target); - - @override - ControllerRequest options(Target target) { - return _Options(target); - } -} - -class _Options implements ControllerRequest { - final Target target; - - _Options(this.target); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - NoContentResponse(headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': target.allowedMethods.join(', ') - }); } class _AddToRelationship implements ControllerRequest { @@ -116,8 +64,8 @@ class _AddToRelationship implements ControllerRequest { _AddToRelationship(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.addToRelationship(request, target.type, target.id, target.relationship, ToMany.fromJson(jsonPayload).unwrap()); } @@ -128,8 +76,8 @@ class _DeleteFromRelationship implements ControllerRequest { _DeleteFromRelationship(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.deleteFromRelationship(request, target.type, target.id, target.relationship, ToMany.fromJson(jsonPayload).unwrap()); } @@ -140,8 +88,8 @@ class _UpdateResource implements ControllerRequest { _UpdateResource(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.updateResource(request, target.type, target.id, ResourceData.fromJson(jsonPayload).unwrap()); } @@ -152,8 +100,8 @@ class _CreateResource implements ControllerRequest { _CreateResource(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.createResource( request, target.type, ResourceData.fromJson(jsonPayload).unwrap()); } @@ -164,8 +112,8 @@ class _DeleteResource implements ControllerRequest { _DeleteResource(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.deleteResource(request, target.type, target.id); } @@ -175,8 +123,8 @@ class _FetchRelationship implements ControllerRequest { _FetchRelationship(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.fetchRelationship( request, target.type, target.id, target.relationship); } @@ -187,8 +135,8 @@ class _FetchRelated implements ControllerRequest { _FetchRelated(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.fetchRelated( request, target.type, target.id, target.relationship); } @@ -199,8 +147,8 @@ class _FetchResource implements ControllerRequest { _FetchResource(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.fetchResource(request, target.type, target.id); } @@ -210,8 +158,8 @@ class _FetchCollection implements ControllerRequest { _FetchCollection(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => + FutureOr call( + Controller controller, Object jsonPayload, R request) => controller.fetchCollection(request, target.type); } @@ -221,8 +169,8 @@ class _UpdateRelationship implements ControllerRequest { _UpdateRelationship(this.target); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { + FutureOr call( + Controller controller, Object jsonPayload, R request) { final relationship = Relationship.fromJson(jsonPayload); if (relationship is ToOne) { return controller.replaceToOne(request, target.type, target.id, @@ -242,8 +190,8 @@ class _InvalidRequest implements ControllerRequest { _InvalidRequest(this.target, this.method); @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { + FutureOr call( + Controller controller, Object jsonPayload, R request) { // TODO: implement call return null; } diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart index 4b47894e..759768a8 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/http_handler.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'dart:convert'; -import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/controller/controller.dart'; +import 'package:json_api/src/server/controller/controller_request_factory.dart'; import 'package:json_api/src/server/pagination/no_pagination.dart'; import 'package:json_api/src/server/pagination/pagination_strategy.dart'; import 'package:json_api/src/server/server_document_factory.dart'; @@ -13,7 +14,7 @@ typedef HttpHandler = Future Function( HttpHandler createHttpHandler( HttpMessageConverter converter, - JsonApiController controller, + Controller controller, UrlDesign urlDesign, {PaginationStrategy pagination = const NoPagination()}) { const targetFactory = TargetFactory(); @@ -33,7 +34,7 @@ HttpHandler createHttpHandler( json.encode(response.buildDocument(docFactory, uri)), { ...response.buildHeaders(urlDesign), 'Access-Control-Allow-Origin': '*', - 'Access-Control-Request-Headers': 'X-PINGOTHER, Content-Type' + 'Access-Control-Request-Headers': 'X-PINGOTHER, Content-Type' }); }; } diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart new file mode 100644 index 00000000..098a67c9 --- /dev/null +++ b/lib/src/server/json_api_response.dart @@ -0,0 +1,281 @@ +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 JsonApiResponse { + final int statusCode; + + const JsonApiResponse(this.statusCode); + + Document buildDocument(ServerDocumentFactory factory, Uri self); + + Map buildHeaders(UrlFactory urlFactory); + + static JsonApiResponse noContent() => _NoContent(); + + static JsonApiResponse accepted(Resource resource) => _Accepted(resource); + + static JsonApiResponse meta(Map meta) => _Meta(meta); + + static JsonApiResponse relatedCollection(Iterable collection, + {Iterable included, int total}) => + _RelatedCollection(collection, included: included, total: total); + + static JsonApiResponse collection(Iterable collection, + {Iterable included, int total}) => + _Collection(collection, included: included, total: total); + + static JsonApiResponse resource(Resource resource, + {Iterable included}) => + _Resource(resource, included: included); + + static JsonApiResponse relatedResource(Resource resource, + {Iterable included}) => + _RelatedResource(resource, included: included); + + static JsonApiResponse resourceCreated(Resource resource) => + _ResourceCreated(resource); + + static JsonApiResponse resourceUpdated(Resource resource) => + _ResourceUpdated(resource); + + static JsonApiResponse seeOther(String type, String id) => + _SeeOther(type, id); + + static JsonApiResponse toMany(String type, String id, String relationship, + Iterable identifiers) => + _ToMany(type, id, relationship, identifiers); + + static JsonApiResponse toOne( + String type, String id, String relationship, Identifier identifier) => + _ToOne(type, id, relationship, identifier); + + /// Generic error response + static JsonApiResponse error(int statusCode, Iterable errors) => + _Error(statusCode, errors); + + static JsonApiResponse notImplemented(Iterable errors) => + _Error(501, errors); + + static JsonApiResponse notFound(Iterable errors) => + _Error(404, errors); + + static JsonApiResponse badRequest(Iterable errors) => + _Error(400, errors); + + static JsonApiResponse methodNotAllowed(Iterable errors) => + _Error(405, errors); + + static JsonApiResponse conflict(Iterable errors) => + _Error(409, errors); +} + +class _NoContent extends JsonApiResponse { + const _NoContent() : super(204); + + @override + Document buildDocument( + ServerDocumentFactory factory, Uri self) => + null; + + @override + Map buildHeaders(UrlFactory urlFactory) => {}; +} + +class _Collection extends JsonApiResponse { + final Iterable collection; + final Iterable included; + final int total; + + const _Collection(this.collection, {this.included, this.total}) : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeCollectionDocument(self, collection, + included: included, total: total); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _Accepted extends JsonApiResponse { + final Resource resource; + + _Accepted(this.resource) : super(202); + + @override + Document buildDocument( + ServerDocumentFactory factory, Uri self) => + factory.makeResourceDocument(self, resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => { + 'Content-Type': Document.contentType, + 'Content-Location': + urlFactory.resource(resource.type, resource.id).toString(), + }; +} + +class _Error extends JsonApiResponse { + final Iterable errors; + + const _Error(int status, this.errors) : super(status); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeErrorDocument(errors); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _Meta extends JsonApiResponse { + final Map meta; + + _Meta(this.meta) : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeMetaDocument(meta); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _RelatedCollection extends JsonApiResponse { + final Iterable collection; + final Iterable included; + final int total; + + const _RelatedCollection(this.collection, {this.included, this.total}) + : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeRelatedCollectionDocument(self, collection, total: total); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _RelatedResource extends JsonApiResponse { + final Resource resource; + final Iterable included; + + const _RelatedResource(this.resource, {this.included}) : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeRelatedResourceDocument(self, resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _ResourceCreated extends JsonApiResponse { + final Resource resource; + + _ResourceCreated(this.resource) : super(201) { + ArgumentError.checkNotNull(resource.id, 'resource.id'); + } + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeCreatedResourceDocument(resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => { + 'Content-Type': Document.contentType, + 'Location': urlFactory.resource(resource.type, resource.id).toString() + }; +} + +class _Resource extends JsonApiResponse { + final Resource resource; + final Iterable included; + + const _Resource(this.resource, {this.included}) : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeResourceDocument(self, resource, included: included); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _ResourceUpdated extends JsonApiResponse { + final Resource resource; + + _ResourceUpdated(this.resource) : super(200); + + @override + Document buildDocument( + ServerDocumentFactory builder, Uri self) => + builder.makeResourceDocument(self, resource); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _SeeOther extends JsonApiResponse { + final String type; + final String id; + + _SeeOther(this.type, this.id) : super(303); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => null; + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Location': urlFactory.resource(type, id).toString()}; +} + +class _ToMany extends JsonApiResponse { + final Iterable collection; + final String type; + final String id; + final String relationship; + + const _ToMany(this.type, this.id, this.relationship, this.collection) + : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeToManyDocument(self, collection, type, id, relationship); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} + +class _ToOne extends JsonApiResponse { + final String type; + final String id; + final String relationship; + final Identifier identifier; + + const _ToOne(this.type, this.id, this.relationship, this.identifier) + : super(200); + + @override + Document buildDocument(ServerDocumentFactory builder, Uri self) => + builder.makeToOneDocument(self, identifier, type, id, relationship); + + @override + Map buildHeaders(UrlFactory urlFactory) => + {'Content-Type': Document.contentType}; +} diff --git a/lib/src/server/response/accepted_response.dart b/lib/src/server/response/accepted_response.dart deleted file mode 100644 index 65dcd348..00000000 --- a/lib/src/server/response/accepted_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; - -class AcceptedResponse extends ControllerResponse { - final Resource resource; - - AcceptedResponse(this.resource) : super(202); - - @override - Document buildDocument( - ServerDocumentFactory factory, Uri self) => - factory.makeResourceDocument(self, resource); - - @override - Map buildHeaders(UrlFactory urlFactory) => { - 'Content-Type': Document.contentType, - 'Content-Location': - urlFactory.resource(resource.type, resource.id).toString(), - }; -} diff --git a/lib/src/server/response/collection_response.dart b/lib/src/server/response/collection_response.dart deleted file mode 100644 index 2b372ffb..00000000 --- a/lib/src/server/response/collection_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class CollectionResponse extends ControllerResponse { - final Iterable collection; - final Iterable included; - final int total; - - const CollectionResponse(this.collection, {this.included, this.total}) - : super(200); - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeCollectionDocument(self, collection, - included: included, total: total); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType }; -} diff --git a/lib/src/server/response/error_response.dart b/lib/src/server/response/error_response.dart deleted file mode 100644 index f69f0b40..00000000 --- a/lib/src/server/response/error_response.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class ErrorResponse extends ControllerResponse { - final Iterable errors; - - const ErrorResponse(int status, this.errors) : super(status); - - @override - 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); - - @override - Map buildHeaders(UrlFactory urlFactory) => { - 'Content-Type': Document.contentType }; -} diff --git a/lib/src/server/response/json_api_response.dart b/lib/src/server/response/json_api_response.dart deleted file mode 100644 index b3e58e4a..00000000 --- a/lib/src/server/response/json_api_response.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 ControllerResponse { - final int statusCode; - - const ControllerResponse(this.statusCode); - - Document buildDocument(ServerDocumentFactory factory, Uri self); - - Map buildHeaders(UrlFactory urlFactory); - -} diff --git a/lib/src/server/response/meta_response.dart b/lib/src/server/response/meta_response.dart deleted file mode 100644 index 1b7ade76..00000000 --- a/lib/src/server/response/meta_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class MetaResponse extends ControllerResponse { - final Map meta; - - MetaResponse(this.meta) : super(200); - - @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => - builder.makeMetaDocument(meta); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/no_content_response.dart b/lib/src/server/response/no_content_response.dart deleted file mode 100644 index 2e1084e1..00000000 --- a/lib/src/server/response/no_content_response.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; - -class NoContentResponse extends ControllerResponse { - final Map headers; - - const NoContentResponse({this.headers = const {}}) : super(204); - - @override - Document buildDocument( - ServerDocumentFactory factory, Uri self) => - null; - - @override - Map buildHeaders(UrlFactory urlFactory) => headers; -} diff --git a/lib/src/server/response/related_collection_response.dart b/lib/src/server/response/related_collection_response.dart deleted file mode 100644 index 932b3008..00000000 --- a/lib/src/server/response/related_collection_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class RelatedCollectionResponse extends ControllerResponse { - final Iterable collection; - final Iterable included; - final int total; - - const RelatedCollectionResponse(this.collection, {this.included, this.total}) - : super(200); - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeRelatedCollectionDocument(self, collection, total: total); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/related_resource_response.dart b/lib/src/server/response/related_resource_response.dart deleted file mode 100644 index 34c2729d..00000000 --- a/lib/src/server/response/related_resource_response.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class RelatedResourceResponse extends ControllerResponse { - final Resource resource; - final Iterable included; - - const RelatedResourceResponse(this.resource, {this.included}) : super(200); - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeRelatedResourceDocument(self, resource); - - @override - Map buildHeaders(UrlFactory urlFactory) => { - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/resource_created_response.dart b/lib/src/server/response/resource_created_response.dart deleted file mode 100644 index a3f8e942..00000000 --- a/lib/src/server/response/resource_created_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; - -class ResourceCreatedResponse extends ControllerResponse { - final Resource resource; - - ResourceCreatedResponse(this.resource) : super(201) { - ArgumentError.checkNotNull(resource.id, 'resource.id'); - } - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeCreatedResourceDocument(resource); - - @override - Map buildHeaders(UrlFactory urlFactory) => { - 'Content-Type': Document.contentType, - 'Location': urlFactory.resource(resource.type, resource.id).toString() - }; -} diff --git a/lib/src/server/response/resource_response.dart b/lib/src/server/response/resource_response.dart deleted file mode 100644 index bc973191..00000000 --- a/lib/src/server/response/resource_response.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class ResourceResponse extends ControllerResponse { - final Resource resource; - final Iterable included; - - const ResourceResponse(this.resource, {this.included}) : super(200); - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource, included: included); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/resource_updated_response.dart b/lib/src/server/response/resource_updated_response.dart deleted file mode 100644 index bfbb6242..00000000 --- a/lib/src/server/response/resource_updated_response.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class ResourceUpdatedResponse extends ControllerResponse { - final Resource resource; - - ResourceUpdatedResponse(this.resource) : super(200); - - @override - Document buildDocument( - ServerDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource); - - @override - Map buildHeaders(UrlFactory urlFactory) => { - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/see_other_response.dart b/lib/src/server/response/see_other_response.dart deleted file mode 100644 index 95aae304..00000000 --- a/lib/src/server/response/see_other_response.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; - -class SeeOtherResponse extends ControllerResponse { - final String type; - final String id; - - SeeOtherResponse(this.type, this.id) : super(303); - - @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => null; - - @override - Map buildHeaders(UrlFactory urlFactory) => { - - 'Location': urlFactory.resource(type, id).toString() - }; -} diff --git a/lib/src/server/response/to_many_response.dart b/lib/src/server/response/to_many_response.dart deleted file mode 100644 index b58111e5..00000000 --- a/lib/src/server/response/to_many_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class ToManyResponse extends ControllerResponse { - 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); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/response/to_one_response.dart b/lib/src/server/response/to_one_response.dart deleted file mode 100644 index c3372ee1..00000000 --- a/lib/src/server/response/to_one_response.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response/json_api_response.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/url_design/url_design.dart'; - -class ToOneResponse extends ControllerResponse { - 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); - - @override - Map buildHeaders(UrlFactory urlFactory) =>{ - 'Content-Type': Document.contentType - }; -} diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index af6d4e7e..38553a19 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -36,16 +36,11 @@ abstract class RequestFactory { /// allowed by the [target]. Most likely, this should lead to either /// `405 Method Not Allowed` or `400 Bad Request`. R invalid(Target target, String method); - - /// Returns and object representing an OPTIONS request to the target - R options(Target target); } /// The target of a JSON:API request URI. The URI target and the request method /// uniquely identify the meaning of the JSON:API request. abstract class Target { - List get allowedMethods; - /// Returns the request corresponding to the request [method]. R getRequest(String method, RequestFactory factory); } @@ -53,9 +48,8 @@ abstract class Target { /// Request URI target which is not recognized by the URL Design. class UnmatchedTarget implements Target { final Uri uri; - @override - final allowedMethods = const ['OPTONS']; + @override const UnmatchedTarget(this.uri); @override @@ -71,9 +65,6 @@ class ResourceTarget implements Target { /// Resource id final String id; - @override - final allowedMethods = const ['GET', 'DELETE', 'PATCH', 'OPTONS']; - const ResourceTarget(this.type, this.id); @override @@ -85,8 +76,6 @@ class ResourceTarget implements Target { return factory.deleteResource(this); case 'PATCH': return factory.updateResource(this); - case 'OPTIONS': - return factory.options(this); default: return factory.invalid(this, method); } @@ -98,9 +87,6 @@ class CollectionTarget implements Target { /// Resource type final String type; - @override - final allowedMethods = const ['GET', 'POST', 'OPTONS']; - const CollectionTarget(this.type); @override @@ -110,8 +96,6 @@ class CollectionTarget implements Target { return factory.fetchCollection(this); case 'POST': return factory.createResource(this); - case 'OPTIONS': - return factory.options(this); default: return factory.invalid(this, method); } @@ -129,9 +113,6 @@ class RelatedTarget implements Target { /// Relationship name final String relationship; - @override - final allowedMethods = const ['GET', 'OPTONS']; - const RelatedTarget(this.type, this.id, this.relationship); @override @@ -139,8 +120,6 @@ class RelatedTarget implements Target { switch (method.toUpperCase()) { case 'GET': return factory.fetchRelated(this); - case 'OPTIONS': - return factory.options(this); default: return factory.invalid(this, method); } @@ -157,8 +136,6 @@ class RelationshipTarget implements Target { /// Relationship name final String relationship; - @override - final allowedMethods = const ['GET', 'PATCH', 'POST', 'DELETE', 'OPTONS']; const RelationshipTarget(this.type, this.id, this.relationship); @@ -173,8 +150,6 @@ class RelationshipTarget implements Target { return factory.addToRelationship(this); case 'DELETE': return factory.deleteFromRelationship(this); - case 'OPTIONS': - return factory.options(this); default: return factory.invalid(this, method); } diff --git a/test/unit/server/response_test.dart b/test/unit/server/response_test.dart deleted file mode 100644 index 3fba9fe8..00000000 --- a/test/unit/server/response_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; - -void main() { - test('Responses should have "included" set to null by default', () { - expect(CollectionResponse([]).included, null); - expect(RelatedCollectionResponse([]).included, null); - expect(RelatedResourceResponse(null).included, null); - expect(ResourceResponse(null).included, null); - }); -} From 3a6f0345f249d52f8076e4ad2b4b5b30fcc23e0a Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 18 Jan 2020 10:31:27 -0800 Subject: [PATCH 05/99] WIP --- example/fetch_collection.dart | 4 +- example/server/crud_controller.dart | 39 ++-- example/server/server.dart | 2 +- .../shelf_request_response_converter.dart | 14 +- lib/client.dart | 2 +- lib/routing.dart | 6 + lib/server.dart | 8 +- lib/src/client/json_api_client.dart | 117 +++++------ ...ory.dart => request_document_factory.dart} | 16 +- lib/src/client/response.dart | 4 +- lib/src/client/url_aware_client.dart | 82 ++++---- lib/src/routing/collection_uri.dart | 9 + lib/src/routing/recommended_routing.dart | 113 ++++++++++ lib/src/routing/related_uri.dart | 6 + lib/src/routing/relationship_uri.dart | 6 + lib/src/routing/resource_uri.dart | 5 + lib/src/routing/routing.dart | 19 ++ lib/src/server/controller/controller.dart | 37 ---- .../controller_request_factory.dart | 198 ------------------ lib/src/server/http_handler.dart | 71 +++---- lib/src/server/json_api_controller.dart | 96 +++++++++ lib/src/server/json_api_response.dart | 79 +++---- .../server/request/add_to_relationship.dart | 20 ++ .../server/request/controller_request.dart | 10 + lib/src/server/request/create_resource.dart | 18 ++ .../request/delete_from_relationship.dart | 20 ++ lib/src/server/request/delete_resource.dart | 17 ++ lib/src/server/request/fetch_collection.dart | 16 ++ lib/src/server/request/fetch_related.dart | 18 ++ .../server/request/fetch_relationship.dart | 18 ++ lib/src/server/request/fetch_resource.dart | 17 ++ lib/src/server/request/invalid_request.dart | 18 ++ .../server/request/update_relationship.dart | 28 +++ lib/src/server/request/update_resource.dart | 19 ++ ...ry.dart => response_document_factory.dart} | 24 +-- lib/src/server/target.dart | 178 ---------------- lib/src/server/target/collection_target.dart | 25 +++ lib/src/server/target/invalid_target.dart | 14 ++ lib/src/server/target/related_target.dart | 28 +++ .../server/target/relationship_target.dart | 37 ++++ lib/src/server/target/resource_target.dart | 31 +++ lib/src/server/target/target.dart | 23 ++ lib/src/url_design/path_based_url_design.dart | 69 ------ lib/src/url_design/url_design.dart | 42 ---- lib/url_design.dart | 21 -- test/functional/crud_test.dart | 9 +- .../path_based_url_design_test.dart | 95 --------- 47 files changed, 877 insertions(+), 871 deletions(-) create mode 100644 lib/routing.dart rename lib/src/client/{client_document_factory.dart => request_document_factory.dart} (75%) create mode 100644 lib/src/routing/collection_uri.dart create mode 100644 lib/src/routing/recommended_routing.dart create mode 100644 lib/src/routing/related_uri.dart create mode 100644 lib/src/routing/relationship_uri.dart create mode 100644 lib/src/routing/resource_uri.dart create mode 100644 lib/src/routing/routing.dart delete mode 100644 lib/src/server/controller/controller.dart delete mode 100644 lib/src/server/controller/controller_request_factory.dart create mode 100644 lib/src/server/json_api_controller.dart create mode 100644 lib/src/server/request/add_to_relationship.dart create mode 100644 lib/src/server/request/controller_request.dart create mode 100644 lib/src/server/request/create_resource.dart create mode 100644 lib/src/server/request/delete_from_relationship.dart create mode 100644 lib/src/server/request/delete_resource.dart create mode 100644 lib/src/server/request/fetch_collection.dart create mode 100644 lib/src/server/request/fetch_related.dart create mode 100644 lib/src/server/request/fetch_relationship.dart create mode 100644 lib/src/server/request/fetch_resource.dart create mode 100644 lib/src/server/request/invalid_request.dart create mode 100644 lib/src/server/request/update_relationship.dart create mode 100644 lib/src/server/request/update_resource.dart rename lib/src/server/{server_document_factory.dart => response_document_factory.dart} (87%) delete mode 100644 lib/src/server/target.dart create mode 100644 lib/src/server/target/collection_target.dart create mode 100644 lib/src/server/target/invalid_target.dart create mode 100644 lib/src/server/target/related_target.dart create mode 100644 lib/src/server/target/relationship_target.dart create mode 100644 lib/src/server/target/resource_target.dart create mode 100644 lib/src/server/target/target.dart delete mode 100644 lib/src/url_design/path_based_url_design.dart delete mode 100644 lib/src/url_design/url_design.dart delete mode 100644 lib/url_design.dart delete mode 100644 test/unit/url_design/path_based_url_design_test.dart diff --git a/example/fetch_collection.dart b/example/fetch_collection.dart index e8fc60bc..d264afeb 100644 --- a/example/fetch_collection.dart +++ b/example/fetch_collection.dart @@ -1,11 +1,11 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/url_design.dart'; +import 'package:json_api/routing.dart'; /// Start `dart example/server/server.dart` first void main() async { final url = Uri.parse('http://localhost:8080'); - final client = UrlAwareClient(PathBasedUrlDesign(url)); + final client = UrlAwareClient(RecommendedRouting(url)); await client.createResource( Resource('messages', '1', attributes: {'text': 'Hello World'})); client.close(); diff --git a/example/server/crud_controller.dart b/example/server/crud_controller.dart index cc7f4b07..c7354f31 100644 --- a/example/server/crud_controller.dart +++ b/example/server/crud_controller.dart @@ -2,9 +2,9 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf.dart' as shelf; -class CRUDController implements Controller { +class CRUDController implements JsonApiController { final String Function() generateId; final store = >{}; @@ -13,7 +13,7 @@ class CRUDController implements Controller { @override FutureOr createResource( - Request request, String type, Resource resource) { + shelf.Request request, String type, Resource resource) { if (resource.type != type) { return JsonApiResponse.conflict( [JsonApiError(detail: 'Incompatible type')]); @@ -34,7 +34,7 @@ class CRUDController implements Controller { @override FutureOr fetchResource( - Request request, String type, String id) { + shelf.Request request, String type, String id) { final repo = _repo(type); if (repo.containsKey(id)) { return JsonApiResponse.resource(repo[id]); @@ -44,8 +44,12 @@ class CRUDController implements Controller { } @override - FutureOr addToRelationship(Request request, String type, - String id, String relationship, Iterable identifiers) { + FutureOr addToRelationship( + shelf.Request request, + String type, + String id, + String relationship, + Iterable identifiers) { final resource = _repo(type)[id]; final ids = [...resource.toMany[relationship], ...identifiers]; _repo(type)[id] = @@ -54,8 +58,12 @@ class CRUDController implements Controller { } @override - FutureOr deleteFromRelationship(Request request, String type, - String id, String relationship, Iterable identifiers) { + FutureOr deleteFromRelationship( + shelf.Request request, + String type, + String id, + String relationship, + Iterable identifiers) { final resource = _repo(type)[id]; final rel = [...resource.toMany[relationship]]; rel.removeWhere(identifiers.contains); @@ -68,7 +76,7 @@ class CRUDController implements Controller { @override FutureOr deleteResource( - Request request, String type, String id) { + shelf.Request request, String type, String id) { final repo = _repo(type); if (!repo.containsKey(id)) { return JsonApiResponse.notFound( @@ -84,14 +92,15 @@ class CRUDController implements Controller { } @override - FutureOr fetchCollection(Request request, String type) { + FutureOr fetchCollection( + shelf.Request request, String type) { final repo = _repo(type); return JsonApiResponse.collection(repo.values); } @override FutureOr fetchRelated( - Request request, String type, String id, String relationship) { + shelf.Request request, String type, String id, String relationship) { final resource = _repo(type)[id]; if (resource == null) { return JsonApiResponse.notFound( @@ -114,7 +123,7 @@ class CRUDController implements Controller { @override FutureOr fetchRelationship( - Request request, String type, String id, String relationship) { + shelf.Request request, String type, String id, String relationship) { final r = _repo(type)[id]; if (r.toOne.containsKey(relationship)) { return JsonApiResponse.toOne( @@ -130,7 +139,7 @@ class CRUDController implements Controller { @override FutureOr updateResource( - Request request, String type, String id, Resource resource) { + shelf.Request request, String type, String id, Resource resource) { final current = _repo(type)[id]; if (resource.hasAllMembersOf(current)) { _repo(type)[id] = resource; @@ -141,7 +150,7 @@ class CRUDController implements Controller { } @override - FutureOr replaceToMany(Request request, String type, + FutureOr replaceToMany(shelf.Request request, String type, String id, String relationship, Iterable identifiers) { final resource = _repo(type)[id]; final toMany = {...resource.toMany, relationship: identifiers.toList()}; @@ -150,7 +159,7 @@ class CRUDController implements Controller { } @override - FutureOr replaceToOne(Request request, String type, + FutureOr replaceToOne(shelf.Request request, String type, String id, String relationship, Identifier identifier) { _repo(type)[id] = _repo(type)[id].replace(toOne: {relationship: identifier}); diff --git a/example/server/server.dart b/example/server/server.dart index 08385b2f..320e49be 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -14,7 +14,7 @@ void main() async { final host = 'localhost'; final port = 8080; final baseUri = Uri(scheme: 'http', host: host, port: port); - final jsonApiHandler = createHttpHandler(ShelfRequestResponseConverter(), + final jsonApiHandler = Handler(ShelfRequestResponseConverter(), CRUDController(Uuid().v4), PathBasedUrlDesign(baseUri)); await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); diff --git a/example/server/shelf_request_response_converter.dart b/example/server/shelf_request_response_converter.dart index 6faf23a9..8da05477 100644 --- a/example/server/shelf_request_response_converter.dart +++ b/example/server/shelf_request_response_converter.dart @@ -1,21 +1,21 @@ import 'dart:async'; import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf.dart' as shelf; class ShelfRequestResponseConverter - implements HttpMessageConverter { + implements HttpMessageConverter { @override - FutureOr createResponse( + FutureOr createResponse( int statusCode, String body, Map headers) => - Response(statusCode, body: body, headers: headers); + shelf.Response(statusCode, body: body, headers: headers); @override - FutureOr getBody(Request request) => request.readAsString(); + FutureOr getBody(shelf.Request request) => request.readAsString(); @override - FutureOr getMethod(Request request) => request.method; + FutureOr getMethod(shelf.Request request) => request.method; @override - FutureOr getUri(Request request) => request.requestedUri; + FutureOr getUri(shelf.Request request) => request.requestedUri; } diff --git a/lib/client.dart b/lib/client.dart index fd8f362c..48bcfca8 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,6 +1,6 @@ library client; -export 'package:json_api/src/client/client_document_factory.dart'; +export 'package:json_api/src/client/request_document_factory.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/routing.dart b/lib/routing.dart new file mode 100644 index 00000000..3d74f357 --- /dev/null +++ b/lib/routing.dart @@ -0,0 +1,6 @@ +export 'src/routing/collection_uri.dart'; +export 'src/routing/recommended_routing.dart'; +export 'src/routing/related_uri.dart'; +export 'src/routing/relationship_uri.dart'; +export 'src/routing/resource_uri.dart'; +export 'src/routing/routing.dart'; diff --git a/lib/server.dart b/lib/server.dart index 29423b9e..8d9bb552 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,10 +3,10 @@ /// The server API is not stable. Expect breaking changes. library server; -export 'package:json_api/src/server/controller/controller.dart'; export 'package:json_api/src/server/http_handler.dart'; -export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/json_api_controller.dart'; export 'package:json_api/src/server/pagination/fixed_size_page.dart'; export 'package:json_api/src/server/pagination/pagination_strategy.dart'; -export 'package:json_api/src/server/server_document_factory.dart'; -export 'package:json_api/src/server/target.dart'; +export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/response_document_factory.dart'; +export 'package:json_api/src/server/target/target.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 4fb1b5cc..3c5a8497 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -4,10 +4,15 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/client_document_factory.dart'; +import 'package:json_api/src/client/request_document_factory.dart'; import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/status_code.dart'; +/// Defines the hook which gets called when the HTTP response is received from +/// the HTTP Client. +typedef OnHttpCall = void Function( + http.Request request, http.Response response); + /// The JSON:API Client. /// /// [JsonApiClient] works on top of Dart's built-in HTTP client. @@ -17,11 +22,10 @@ import 'package:json_api/src/client/status_code.dart'; /// /// /// Start `dart example/hybrid_server.dart` first! /// void main() async { -/// final httpClient = Client(); -/// final jsonApiClient = JsonApiClient(httpClient); +/// final jsonApiClient = JsonApiClient(); /// final url = Uri.parse('http://localhost:8080/companies'); /// final response = await jsonApiClient.fetchCollection(url); -/// httpClient.close(); // Don't forget to close the http client +/// jsonApiClient.close(); // Don't forget to close the inner http client /// print('The collection page size is ${response.data.collection.length}'); /// final resource = response.data.unwrap().first; /// print('The last element is ${resource}'); @@ -29,27 +33,12 @@ import 'package:json_api/src/client/status_code.dart'; /// } /// ``` class JsonApiClient { - final http.Client httpClient; - final OnHttpCall _onHttpCall; - final ClientDocumentFactory _factory; - - /// Creates an instance of JSON:API client. - /// You have to create and pass an instance of the [httpClient] yourself. - /// Do not forget to call [httpClient.close] when you're done using - /// the JSON:API client. - /// The [onHttpCall] hook, if passed, gets called when an http response is - /// received from the HTTP Client. - JsonApiClient(this.httpClient, - {ClientDocumentFactory builder, OnHttpCall onHttpCall}) - : _factory = builder ?? ClientDocumentFactory(), - _onHttpCall = onHttpCall ?? _doNothing; - /// Fetches a resource collection by sending a GET query to the [uri]. /// Use [headers] to pass extra HTTP headers. /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollection(Uri uri, + Future> fetchCollection(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); @@ -58,7 +47,7 @@ class JsonApiClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResource(Uri uri, + Future> fetchResource(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceData.fromJson); @@ -67,7 +56,7 @@ class JsonApiClient { /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOne(Uri uri, + Future> fetchToOne(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToOne.fromJson); @@ -76,7 +65,7 @@ class JsonApiClient { /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToMany(Uri uri, + Future> fetchToMany(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToMany.fromJson); @@ -86,7 +75,7 @@ class JsonApiClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationship(Uri uri, + Future> fetchRelationship(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), Relationship.fromJson); @@ -94,47 +83,47 @@ class JsonApiClient { /// according to its type. /// /// https://jsonapi.org/format/#crud-creating - Future> createResource(Uri uri, Resource resource, - {Map headers}) => - _call(_post(uri, headers, _factory.makeResourceDocument(resource)), + Future> createResource( + Uri uri, Resource resource, {Map headers}) => + _call(_post(uri, headers, _factory.resourceDocument(resource)), ResourceData.fromJson); /// Deletes the resource. /// /// https://jsonapi.org/format/#crud-deleting - Future deleteResource(Uri uri, {Map headers}) => + Future deleteResource(Uri uri, + {Map headers}) => _call(_delete(uri, headers), null); /// Updates the resource via PATCH query. /// /// https://jsonapi.org/format/#crud-updating - Future> updateResource(Uri uri, Resource resource, - {Map headers}) => - _call(_patch(uri, headers, _factory.makeResourceDocument(resource)), + Future> updateResource( + Uri uri, Resource resource, {Map headers}) => + _call(_patch(uri, headers, _factory.resourceDocument(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, + Future> replaceToOne(Uri uri, Identifier identifier, {Map headers}) => - _call(_patch(uri, headers, _factory.makeToOneDocument(identifier)), + _call(_patch(uri, headers, _factory.toOneDocument(identifier)), ToOne.fromJson); /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] /// with id = null. - Future> deleteToOne(Uri uri, {Map headers}) => + Future> deleteToOne(Uri uri, + {Map headers}) => replaceToOne(uri, null, headers: headers); /// Removes the [identifiers] from the to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToMany( + Future> deleteFromToMany( Uri uri, Iterable identifiers, {Map headers}) => - _call( - _deleteWithBody( - uri, headers, _factory.makeToManyDocument(identifiers)), + _call(_deleteWithBody(uri, headers, _factory.toManyDocument(identifiers)), ToMany.fromJson); /// Replaces a to-many relationship with the given set of [identifiers]. @@ -144,34 +133,44 @@ class JsonApiClient { /// or return a 403 Forbidden response if complete replacement is not allowed by the server. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToMany( + Future> replaceToMany( Uri uri, Iterable identifiers, {Map headers}) => - _call(_patch(uri, headers, _factory.makeToManyDocument(identifiers)), + _call(_patch(uri, headers, _factory.toManyDocument(identifiers)), ToMany.fromJson); /// Adds the given set of [identifiers] to a to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - @Deprecated('Use addToRelationship()') - Future> addToMany(Uri uri, Iterable identifiers, - {Map headers}) => - addToRelationship(uri, identifiers, headers: headers); - - /// Adds the given set of [identifiers] to a to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationship( + Future> addToRelationship( Uri uri, Iterable identifiers, {Map headers}) => - _call(_post(uri, headers, _factory.makeToManyDocument(identifiers)), + _call(_post(uri, headers, _factory.toManyDocument(identifiers)), ToMany.fromJson); /// Closes the internal HTTP client. You have to either call this method or /// close the client yourself. /// /// See [httpClient.close] - void close() => httpClient.close(); + void close() => _http.close(); + + /// Creates an instance of JSON:API client. + /// You have to create and pass an instance of the [httpClient] yourself. + /// Do not forget to call [httpClient.close] when you're done using + /// the JSON:API client. + /// The [onHttpCall] hook, if passed, gets called when an http response is + /// received from the HTTP Client. + JsonApiClient( + {RequestDocumentFactory builder, + OnHttpCall onHttpCall, + http.Client httpClient}) + : _factory = builder ?? RequestDocumentFactory(), + _http = httpClient ?? http.Client(), + _onHttpCall = onHttpCall ?? _doNothing; + + final http.Client _http; + final OnHttpCall _onHttpCall; + final RequestDocumentFactory _factory; http.Request _get(Uri uri, Map headers, QueryParameters queryParameters) => @@ -217,30 +216,24 @@ class JsonApiClient { }) ..body = json.encode(doc); - Future> _call( + Future> _call( http.Request request, D Function(Object _) decodePrimaryData) async { - final response = - await http.Response.fromStream(await httpClient.send(request)); + final response = await http.Response.fromStream(await _http.send(request)); _onHttpCall(request, response); if (response.body.isEmpty) { - return Response(response.statusCode, response.headers); + return JsonApiResponse(response.statusCode, response.headers); } final body = json.decode(response.body); if (StatusCode(response.statusCode).isPending) { - return Response(response.statusCode, response.headers, + return JsonApiResponse(response.statusCode, response.headers, asyncDocument: body == null ? null : Document.fromJson(body, ResourceData.fromJson)); } - return Response(response.statusCode, response.headers, + return JsonApiResponse(response.statusCode, response.headers, document: body == null ? null : Document.fromJson(body, decodePrimaryData)); } } -/// Defines the hook which gets called when the HTTP response is received from -/// the HTTP Client. -typedef OnHttpCall = void Function( - http.Request request, http.Response response); - void _doNothing(http.Request request, http.Response response) {} diff --git a/lib/src/client/client_document_factory.dart b/lib/src/client/request_document_factory.dart similarity index 75% rename from lib/src/client/client_document_factory.dart rename to lib/src/client/request_document_factory.dart index a2147e13..afe65411 100644 --- a/lib/src/client/client_document_factory.dart +++ b/lib/src/client/request_document_factory.dart @@ -1,24 +1,24 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; -/// This is a document factory used by [JsonApiClient]. It is responsible +/// This is a document factory used by the client. It is responsible /// for building the JSON representation of the outgoing resources. -class ClientDocumentFactory { - /// Creates an instance of the factory. - ClientDocumentFactory({Api api}) : _api = api; - +class RequestDocumentFactory { /// Makes a document containing a single resource. - Document makeResourceDocument(Resource resource) => + Document resourceDocument(Resource resource) => Document(ResourceData(_resourceObject(resource)), api: _api); /// Makes a document containing a to-many relationship. - Document makeToManyDocument(Iterable ids) => + Document toManyDocument(Iterable ids) => Document(ToMany(ids.map(IdentifierObject.fromIdentifier)), api: _api); /// Makes a document containing a to-one relationship. - Document makeToOneDocument(Identifier id) => + Document toOneDocument(Identifier id) => Document(ToOne(nullable(IdentifierObject.fromIdentifier)(id)), api: _api); + /// Creates an instance of the factory. + RequestDocumentFactory({Api api}) : _api = api; + final Api _api; ResourceObject _resourceObject(Resource resource) => diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 999017b7..c2946967 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -4,8 +4,8 @@ import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/nullable.dart'; /// A response returned by JSON:API client -class Response { - const Response(this.status, this.headers, +class JsonApiResponse { + const JsonApiResponse(this.status, this.headers, {this.document, this.asyncDocument}); /// HTTP status code diff --git a/lib/src/client/url_aware_client.dart b/lib/src/client/url_aware_client.dart index f603f158..d6d70c03 100644 --- a/lib/src/client/url_aware_client.dart +++ b/lib/src/client/url_aware_client.dart @@ -1,30 +1,19 @@ -import 'package:http/http.dart' as http; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/url_design.dart'; +import 'package:json_api/routing.dart'; /// A wrapper over [JsonApiClient] making use of the given UrlFactory. /// This wrapper reduces the boilerplate code but is not as flexible /// as [JsonApiClient]. class UrlAwareClient { - final JsonApiClient _client; - final UrlFactory _url; - - UrlAwareClient(UrlFactory urlFactory, - {JsonApiClient jsonApiClient, http.Client httpClient}) - : this._(jsonApiClient ?? JsonApiClient(httpClient ?? http.Client()), - urlFactory); - - UrlAwareClient._(this._client, this._url); - /// Creates a new resource. The resource will be added to a collection /// according to its type. /// /// https://jsonapi.org/format/#crud-creating - Future> createResource(Resource resource, + Future> createResource(Resource resource, {Map headers}) => - _client.createResource(_url.collection(resource.type), resource, + _client.createResource(_routing.collection.uri(resource.type), resource, headers: headers); /// Fetches a single resource @@ -32,9 +21,9 @@ class UrlAwareClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResource(String type, String id, + Future> fetchResource(String type, String id, {Map headers, QueryParameters parameters}) => - _client.fetchResource(_url.resource(type, id), + _client.fetchResource(_routing.resource.uri(type, id), headers: headers, parameters: parameters); /// Fetches a resource collection . @@ -42,9 +31,9 @@ class UrlAwareClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollection(String type, + Future> fetchCollection(String type, {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_url.collection(type), + _client.fetchCollection(_routing.collection.uri(type), headers: headers, parameters: parameters); /// Fetches a related resource. @@ -52,10 +41,10 @@ class UrlAwareClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelatedResource( + Future> fetchRelatedResource( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchResource(_url.related(type, id, relationship), + _client.fetchResource(_routing.related.uri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a related resource collection. @@ -63,10 +52,10 @@ class UrlAwareClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelatedCollection( + Future> fetchRelatedCollection( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_url.related(type, id, relationship), + _client.fetchCollection(_routing.related.uri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one relationship @@ -74,10 +63,10 @@ class UrlAwareClient { /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOne( + Future> fetchToOne( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToOne(_url.relationship(type, id, relationship), + _client.fetchToOne(_routing.relationship.uri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one or to-many relationship. @@ -86,88 +75,95 @@ class UrlAwareClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationship( + Future> fetchRelationship( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchRelationship(_url.relationship(type, id, relationship), - headers: headers, parameters: parameters); + _client.fetchRelationship( + _routing.relationship.uri(type, id, relationship), + headers: headers, + parameters: parameters); /// Fetches a to-many relationship /// Use [headers] to pass extra HTTP headers. /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToMany( + Future> fetchToMany( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToMany(_url.relationship(type, id, relationship), + _client.fetchToMany(_routing.relationship.uri(type, id, relationship), headers: headers, parameters: parameters); /// Deletes the resource referenced by [type] and [id]. /// /// https://jsonapi.org/format/#crud-deleting - Future deleteResource(String type, String id, + Future deleteResource(String type, String id, {Map headers}) => - _client.deleteResource(_url.resource(type, id), headers: headers); + _client.deleteResource(_routing.resource.uri(type, id), headers: headers); /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] /// with id = null. - Future> deleteToOne( + Future> deleteToOne( String type, String id, String relationship, {Map headers}) => - _client.deleteToOne(_url.relationship(type, id, relationship), + _client.deleteToOne(_routing.relationship.uri(type, id, relationship), headers: headers); /// Removes the [identifiers] from the to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToMany(String type, String id, + Future> deleteFromToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToMany( - _url.relationship(type, id, relationship), identifiers, + _routing.relationship.uri(type, id, relationship), identifiers, headers: headers); /// Updates the [resource]. /// /// https://jsonapi.org/format/#crud-updating - Future> updateResource(Resource resource, + Future> updateResource(Resource resource, {Map headers}) => _client.updateResource( - _url.resource(resource.type, resource.id), resource, + _routing.resource.uri(resource.type, resource.id), resource, headers: headers); /// Adds the given set of [identifiers] to a to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationship(String type, String id, + Future> addToRelationship(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationship( - _url.relationship(type, id, relationship), identifiers, + _routing.relationship.uri(type, id, relationship), identifiers, headers: headers); /// Replaces a to-many relationship with the given set of [identifiers]. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToMany(String type, String id, + Future> replaceToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.replaceToMany( - _url.relationship(type, id, relationship), identifiers, + _routing.relationship.uri(type, id, relationship), identifiers, headers: headers); /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOne( + Future> replaceToOne( String type, String id, String relationship, Identifier identifier, {Map headers}) => _client.replaceToOne( - _url.relationship(type, id, relationship), identifier, + _routing.relationship.uri(type, id, relationship), identifier, headers: headers); /// Closes the internal client. You have to either call this method or /// close the client yourself. void close() => _client.close(); + + UrlAwareClient(this._routing, {JsonApiClient jsonApiClient}) + : _client = jsonApiClient ?? JsonApiClient(); + final JsonApiClient _client; + final Routing _routing; } diff --git a/lib/src/routing/collection_uri.dart b/lib/src/routing/collection_uri.dart new file mode 100644 index 00000000..9eb8f7a8 --- /dev/null +++ b/lib/src/routing/collection_uri.dart @@ -0,0 +1,9 @@ +/// Resource collection URL +abstract class CollectionUri { + /// Returns a URL for a collection of type [type] + Uri uri(String type); + + /// Returns true is the [uri] is a collection. + /// If matches, the [onMatch] will be called with the collection type. + bool match(Uri uri, void Function(String type) onMatch); +} diff --git a/lib/src/routing/recommended_routing.dart b/lib/src/routing/recommended_routing.dart new file mode 100644 index 00000000..8a1abd4c --- /dev/null +++ b/lib/src/routing/recommended_routing.dart @@ -0,0 +1,113 @@ +import 'package:json_api/src/routing/collection_uri.dart'; +import 'package:json_api/src/routing/related_uri.dart'; +import 'package:json_api/src/routing/relationship_uri.dart'; +import 'package:json_api/src/routing/resource_uri.dart'; +import 'package:json_api/src/routing/routing.dart'; + +/// The recommended route design. +/// See https://jsonapi.org/recommendations/#urls +class RecommendedRouting implements Routing { + @override + final CollectionUri collection; + + @override + final RelatedUri related; + + @override + final RelationshipUri relationship; + + @override + final ResourceUri resource; + + /// Creates an instance of + RecommendedRouting(Uri base) + : collection = _Collection(base), + resource = _Resource(base), + related = _Related(base), + relationship = _Relationship(base); +} + +class _Collection extends _Recommended implements CollectionUri { + @override + Uri uri(String type) => _append([type]); + + @override + bool match(Uri uri, void Function(String type) onMatch) { + final seg = _segments(uri); + if (seg.length == 1) { + onMatch(seg[0]); + return true; + } + return false; + } + + const _Collection(Uri base) : super(base); +} + +class _Resource extends _Recommended implements ResourceUri { + @override + Uri uri(String type, String id) => _append([type, id]); + + @override + bool match(Uri uri, void Function(String type, String id) onMatch) { + final seg = _segments(uri); + if (seg.length == 2) { + onMatch(seg[0], seg[1]); + return true; + } + return false; + } + + const _Resource(Uri base) : super(base); +} + +class _Related extends _Recommended implements RelatedUri { + @override + Uri uri(String type, String id, String relationship) => + _append([type, id, relationship]); + + @override + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch) { + final seg = _segments(uri); + if (seg.length == 3) { + onMatch(seg[0], seg[1], seg[3]); + return true; + } + return false; + } + + const _Related(Uri base) : super(base); +} + +class _Relationship extends _Recommended implements RelationshipUri { + @override + Uri uri(String type, String id, String relationship) => + _append([type, id, _relationships, relationship]); + + @override + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch) { + final seg = _segments(uri); + if (seg.length == 4 && seg[2] == _relationships) { + onMatch(seg[0], seg[1], seg[3]); + return true; + } + return false; + } + + const _Relationship(Uri base) : super(base); + static const _relationships = 'relationships'; +} + +class _Recommended { + Uri _append(Iterable segments) => + _base.replace(pathSegments: _base.pathSegments + segments); + + List _segments(Uri uri) => + uri.pathSegments.sublist(_base.pathSegments.length); + + const _Recommended(this._base); + + final Uri _base; +} diff --git a/lib/src/routing/related_uri.dart b/lib/src/routing/related_uri.dart new file mode 100644 index 00000000..0d929585 --- /dev/null +++ b/lib/src/routing/related_uri.dart @@ -0,0 +1,6 @@ +abstract class RelatedUri { + Uri uri(String type, String id, String relationship); + + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} diff --git a/lib/src/routing/relationship_uri.dart b/lib/src/routing/relationship_uri.dart new file mode 100644 index 00000000..4b761f8c --- /dev/null +++ b/lib/src/routing/relationship_uri.dart @@ -0,0 +1,6 @@ +abstract class RelationshipUri { + Uri uri(String type, String id, String relationship); + + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} diff --git a/lib/src/routing/resource_uri.dart b/lib/src/routing/resource_uri.dart new file mode 100644 index 00000000..6a5b5ca8 --- /dev/null +++ b/lib/src/routing/resource_uri.dart @@ -0,0 +1,5 @@ +abstract class ResourceUri { + Uri uri(String type, String id); + + bool match(Uri uri, void Function(String type, String id) onMatch); +} diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart new file mode 100644 index 00000000..50b6c488 --- /dev/null +++ b/lib/src/routing/routing.dart @@ -0,0 +1,19 @@ +import 'package:json_api/src/routing/collection_uri.dart'; +import 'package:json_api/src/routing/related_uri.dart'; +import 'package:json_api/src/routing/relationship_uri.dart'; +import 'package:json_api/src/routing/resource_uri.dart'; + +/// Routing represents the design of the 4 kinds of URLs: +/// - collection URL (e.g. /books) +/// - resource URL (e.g. /books/13) +/// - related resource/collection URL (e.g. /books/123/author) +/// - relationship URL (e.g. /books/123/relationship/author) +abstract class Routing { + CollectionUri get collection; + + ResourceUri get resource; + + RelatedUri get related; + + RelationshipUri get relationship; +} diff --git a/lib/src/server/controller/controller.dart b/lib/src/server/controller/controller.dart deleted file mode 100644 index 32f3b33a..00000000 --- a/lib/src/server/controller/controller.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -abstract class Controller { - FutureOr fetchCollection(R request, String type); - - FutureOr fetchResource(R request, String type, String id); - - FutureOr fetchRelated( - R request, String type, String id, String relationship); - - FutureOr fetchRelationship( - R request, String type, String id, String relationship); - - FutureOr deleteResource(R request, String type, String id); - - FutureOr createResource( - R request, String type, Resource resource); - - FutureOr updateResource( - R request, String type, String id, Resource resource); - - FutureOr replaceToOne(R request, String type, String id, - String relationship, Identifier identifier); - - FutureOr replaceToMany(R request, String type, String id, - String relationship, Iterable identifiers); - - FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers); - - FutureOr addToRelationship(R request, String type, String id, - String relationship, Iterable identifiers); -} diff --git a/lib/src/server/controller/controller_request_factory.dart b/lib/src/server/controller/controller_request_factory.dart deleted file mode 100644 index 38456ba8..00000000 --- a/lib/src/server/controller/controller_request_factory.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller/controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/target.dart'; - -abstract class ControllerRequest { - /// Calls the appropriate method of the controller - FutureOr call( - Controller controller, Object jsonPayload, R request); -} - -class ControllerRequestFactory implements RequestFactory { - const ControllerRequestFactory(); - - @override - ControllerRequest addToRelationship(RelationshipTarget target) => - _AddToRelationship(target); - - @override - ControllerRequest createResource(CollectionTarget target) => - _CreateResource(target); - - @override - ControllerRequest deleteFromRelationship(RelationshipTarget target) => - _DeleteFromRelationship(target); - - @override - ControllerRequest deleteResource(ResourceTarget target) => - _DeleteResource(target); - - @override - ControllerRequest fetchCollection(CollectionTarget target) => - _FetchCollection(target); - - @override - ControllerRequest fetchRelated(RelatedTarget target) => _FetchRelated(target); - - @override - ControllerRequest fetchRelationship(RelationshipTarget target) => - _FetchRelationship(target); - - @override - ControllerRequest fetchResource(ResourceTarget target) => - _FetchResource(target); - - @override - ControllerRequest invalid(Target target, String method) => - _InvalidRequest(target, method); - - @override - ControllerRequest updateRelationship(RelationshipTarget target) => - _UpdateRelationship(target); - - @override - ControllerRequest updateResource(ResourceTarget target) => - _UpdateResource(target); -} - -class _AddToRelationship implements ControllerRequest { - final RelationshipTarget target; - - _AddToRelationship(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.addToRelationship(request, target.type, target.id, - target.relationship, ToMany.fromJson(jsonPayload).unwrap()); -} - -class _DeleteFromRelationship implements ControllerRequest { - final RelationshipTarget target; - - _DeleteFromRelationship(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.deleteFromRelationship(request, target.type, target.id, - target.relationship, ToMany.fromJson(jsonPayload).unwrap()); -} - -class _UpdateResource implements ControllerRequest { - final ResourceTarget target; - - _UpdateResource(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.updateResource(request, target.type, target.id, - ResourceData.fromJson(jsonPayload).unwrap()); -} - -class _CreateResource implements ControllerRequest { - final CollectionTarget target; - - _CreateResource(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.createResource( - request, target.type, ResourceData.fromJson(jsonPayload).unwrap()); -} - -class _DeleteResource implements ControllerRequest { - final ResourceTarget target; - - _DeleteResource(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.deleteResource(request, target.type, target.id); -} - -class _FetchRelationship implements ControllerRequest { - final RelationshipTarget target; - - _FetchRelationship(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.fetchRelationship( - request, target.type, target.id, target.relationship); -} - -class _FetchRelated implements ControllerRequest { - final RelatedTarget target; - - _FetchRelated(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.fetchRelated( - request, target.type, target.id, target.relationship); -} - -class _FetchResource implements ControllerRequest { - final ResourceTarget target; - - _FetchResource(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.fetchResource(request, target.type, target.id); -} - -class _FetchCollection implements ControllerRequest { - final CollectionTarget target; - - _FetchCollection(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) => - controller.fetchCollection(request, target.type); -} - -class _UpdateRelationship implements ControllerRequest { - final RelationshipTarget target; - - _UpdateRelationship(this.target); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) { - final relationship = Relationship.fromJson(jsonPayload); - if (relationship is ToOne) { - return controller.replaceToOne(request, target.type, target.id, - target.relationship, relationship.unwrap()); - } - if (relationship is ToMany) { - return controller.replaceToMany(request, target.type, target.id, - target.relationship, relationship.unwrap()); - } - } -} - -class _InvalidRequest implements ControllerRequest { - final Target target; - final String method; - - _InvalidRequest(this.target, this.method); - - @override - FutureOr call( - Controller controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } -} diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart index 759768a8..72d82690 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/http_handler.dart @@ -1,43 +1,12 @@ import 'dart:async'; import 'dart:convert'; -import 'package:json_api/src/server/controller/controller.dart'; -import 'package:json_api/src/server/controller/controller_request_factory.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/pagination/no_pagination.dart'; import 'package:json_api/src/server/pagination/pagination_strategy.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/src/server/target.dart'; -import 'package:json_api/url_design.dart'; - -typedef HttpHandler = Future Function( - Request request); - -HttpHandler createHttpHandler( - HttpMessageConverter converter, - Controller controller, - UrlDesign urlDesign, - {PaginationStrategy pagination = const NoPagination()}) { - const targetFactory = TargetFactory(); - const requestFactory = ControllerRequestFactory(); - final docFactory = ServerDocumentFactory(urlDesign, pagination: pagination); - - return (Request request) async { - final uri = await converter.getUri(request); - final method = await converter.getMethod(request); - final body = await converter.getBody(request); - final target = urlDesign.match(uri, targetFactory); - final requestDocument = body.isEmpty ? null : json.decode(body); - final response = await target - .getRequest(method, requestFactory) - .call(controller, requestDocument, request); - return converter.createResponse(response.statusCode, - json.encode(response.buildDocument(docFactory, uri)), { - ...response.buildHeaders(urlDesign), - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Request-Headers': 'X-PINGOTHER, Content-Type' - }); - }; -} +import 'package:json_api/src/server/response_document_factory.dart'; +import 'package:json_api/src/server/target/target_factory.dart'; abstract class HttpMessageConverter { FutureOr getMethod(Request request); @@ -49,3 +18,35 @@ abstract class HttpMessageConverter { FutureOr createResponse( int statusCode, String body, Map headers); } + +/// HTTP handler +class Handler { + /// Processes the incoming HTTP [request] and returns a response + Future call(Request request) async { + final uri = await _converter.getUri(request); + final method = await _converter.getMethod(request); + final body = await _converter.getBody(request); + final document = body.isEmpty ? null : json.decode(body); + + final response = await _routing + .match(uri, _toTarget) + .getRequest(method) + .call(_controller, document, request); + + return _converter.createResponse( + response.statusCode, + json.encode(response.buildDocument(_docFactory, uri)), + response.buildHeaders(_routing)); + } + + /// Creates an instance of the handler. + Handler(this._converter, this._controller, this._routing, + {PaginationStrategy pagination = const NoPagination()}) + : _docFactory = + ResponseDocumentFactory(_routing, pagination: pagination); + final HttpMessageConverter _converter; + final JsonApiController _controller; + final Routing _routing; + final ResponseDocumentFactory _docFactory; + final TargetFactory _toTarget = const TargetFactory(); +} diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart new file mode 100644 index 00000000..d4fb82ea --- /dev/null +++ b/lib/src/server/json_api_controller.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +/// The Controller consolidates all possible requests a JSON:API server +/// may handle. The controller is agnostic to the request, therefore it is +/// generalized with ``. Each of the methods is expected to return a +/// [JsonApiResponse] object or a [Future] of it. +/// +/// The response may either be a successful or an error. +abstract class JsonApiController { + /// Finds an returns a primary resource collection of the given [type]. + /// Use [JsonApiResponse.collection] to return a successful response. + /// Use [JsonApiResponse.notFound] if the collection does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchCollection(R request, String type); + + /// Finds an returns a primary resource of the given [type] and [id]. + /// Use [JsonApiResponse.resource] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchResource(R request, String type, String id); + + /// Finds an returns a related resource or a collection of related resources. + /// Use [JsonApiResponse.relatedResource] or [JsonApiResponse.relatedCollection] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchRelated( + R request, String type, String id, String relationship); + + /// Finds an returns a relationship of a primary resource identified by [type] and [id]. + /// Use [JsonApiResponse.toOne] or [JsonApiResponse.toMany] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. + /// + /// See https://jsonapi.org/format/#fetching-relationships + FutureOr fetchRelationship( + R request, String type, String id, String relationship); + + /// Deletes the resource identified by [type] and [id]. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// Use [JsonApiResponse.notFound] if the resource does not exist. + /// + /// See https://jsonapi.org/format/#crud-deleting + FutureOr deleteResource(R request, String type, String id); + + /// Creates a new [resource] in the collection of the given [type]. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// Use [JsonApiResponse.notFound] if the collection does not exist. + /// Use [JsonApiResponse.forbidden] if the server does not support this operation. + /// Use [JsonApiResponse.conflict] if the resource already exists or the collection + /// does not match the [resource] type.. + /// + /// See https://jsonapi.org/format/#crud-creating + FutureOr createResource( + R request, String type, Resource resource); + + /// Updates the resource identified by [type] and [id]. The [resource] argument + /// contains the data to update/replace. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// + /// See https://jsonapi.org/format/#crud-updating + FutureOr updateResource( + R request, String type, String id, Resource resource); + + /// Replaces the to-one relationship with the given [identifier]. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toOne]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-one-relationships + FutureOr replaceToOne(R request, String type, String id, + String relationship, Identifier identifier); + + /// Replaces the to-many relationship with the given [identifiers]. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr replaceToMany(R request, String type, String id, + String relationship, Iterable identifiers); + + /// Removes the given [identifiers] from the to-many relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr deleteFromRelationship(R request, String type, + String id, String relationship, Iterable identifiers); + + /// Adds the given [identifiers] to the to-many relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr addToRelationship(R request, String type, String id, + String relationship, Iterable identifiers); +} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 098a67c9..728ee13c 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,15 +1,15 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/server_document_factory.dart'; -import 'package:json_api/url_design.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/response_document_factory.dart'; abstract class JsonApiResponse { final int statusCode; const JsonApiResponse(this.statusCode); - Document buildDocument(ServerDocumentFactory factory, Uri self); + Document buildDocument(ResponseDocumentFactory factory, Uri self); - Map buildHeaders(UrlFactory urlFactory); + Map buildHeaders(Routing routing); static JsonApiResponse noContent() => _NoContent(); @@ -54,20 +54,23 @@ abstract class JsonApiResponse { static JsonApiResponse error(int statusCode, Iterable errors) => _Error(statusCode, errors); - static JsonApiResponse notImplemented(Iterable errors) => - _Error(501, errors); + static JsonApiResponse badRequest(Iterable errors) => + _Error(400, errors); + + static JsonApiResponse forbidden(Iterable errors) => + _Error(403, errors); static JsonApiResponse notFound(Iterable errors) => _Error(404, errors); - static JsonApiResponse badRequest(Iterable errors) => - _Error(400, errors); - static JsonApiResponse methodNotAllowed(Iterable errors) => _Error(405, errors); static JsonApiResponse conflict(Iterable errors) => _Error(409, errors); + + static JsonApiResponse notImplemented(Iterable errors) => + _Error(501, errors); } class _NoContent extends JsonApiResponse { @@ -75,11 +78,11 @@ class _NoContent extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory factory, Uri self) => + ResponseDocumentFactory factory, Uri self) => null; @override - Map buildHeaders(UrlFactory urlFactory) => {}; + Map buildHeaders(Routing routing) => {}; } class _Collection extends JsonApiResponse { @@ -91,12 +94,12 @@ class _Collection extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeCollectionDocument(self, collection, included: included, total: total); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -107,14 +110,14 @@ class _Accepted extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory factory, Uri self) => + ResponseDocumentFactory factory, Uri self) => factory.makeResourceDocument(self, resource); @override - Map buildHeaders(UrlFactory urlFactory) => { + Map buildHeaders(Routing routing) => { 'Content-Type': Document.contentType, 'Content-Location': - urlFactory.resource(resource.type, resource.id).toString(), + routing.resource.uri(resource.type, resource.id).toString(), }; } @@ -124,11 +127,11 @@ class _Error extends JsonApiResponse { const _Error(int status, this.errors) : super(status); @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => + Document buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeErrorDocument(errors); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -138,11 +141,11 @@ class _Meta extends JsonApiResponse { _Meta(this.meta) : super(200); @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => + Document buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeMetaDocument(meta); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -156,11 +159,11 @@ class _RelatedCollection extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeRelatedCollectionDocument(self, collection, total: total); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -172,11 +175,11 @@ class _RelatedResource extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeRelatedResourceDocument(self, resource); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -189,13 +192,13 @@ class _ResourceCreated extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeCreatedResourceDocument(resource); @override - Map buildHeaders(UrlFactory urlFactory) => { + Map buildHeaders(Routing routing) => { 'Content-Type': Document.contentType, - 'Location': urlFactory.resource(resource.type, resource.id).toString() + 'Location': routing.resource.uri(resource.type, resource.id).toString() }; } @@ -207,11 +210,11 @@ class _Resource extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeResourceDocument(self, resource, included: included); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -222,11 +225,11 @@ class _ResourceUpdated extends JsonApiResponse { @override Document buildDocument( - ServerDocumentFactory builder, Uri self) => + ResponseDocumentFactory builder, Uri self) => builder.makeResourceDocument(self, resource); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -237,11 +240,11 @@ class _SeeOther extends JsonApiResponse { _SeeOther(this.type, this.id) : super(303); @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => null; + Document buildDocument(ResponseDocumentFactory builder, Uri self) => null; @override - Map buildHeaders(UrlFactory urlFactory) => - {'Location': urlFactory.resource(type, id).toString()}; + Map buildHeaders(Routing routing) => + {'Location': routing.resource.uri(type, id).toString()}; } class _ToMany extends JsonApiResponse { @@ -254,11 +257,11 @@ class _ToMany extends JsonApiResponse { : super(200); @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => + Document buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeToManyDocument(self, collection, type, id, relationship); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -272,10 +275,10 @@ class _ToOne extends JsonApiResponse { : super(200); @override - Document buildDocument(ServerDocumentFactory builder, Uri self) => + Document buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeToOneDocument(self, identifier, type, id, relationship); @override - Map buildHeaders(UrlFactory urlFactory) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } diff --git a/lib/src/server/request/add_to_relationship.dart b/lib/src/server/request/add_to_relationship.dart new file mode 100644 index 00000000..867987eb --- /dev/null +++ b/lib/src/server/request/add_to_relationship.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class AddToRelationship implements ControllerRequest { + final String type; + final String id; + final String relationship; + + AddToRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.addToRelationship(request, type, id, relationship, + ToMany.fromJson(jsonPayload).unwrap()); +} diff --git a/lib/src/server/request/controller_request.dart b/lib/src/server/request/controller_request.dart new file mode 100644 index 00000000..b1c502e6 --- /dev/null +++ b/lib/src/server/request/controller_request.dart @@ -0,0 +1,10 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +abstract class ControllerRequest { + /// Calls the appropriate method of [controller] + FutureOr call( + JsonApiController controller, Object jsonPayload, R request); +} diff --git a/lib/src/server/request/create_resource.dart b/lib/src/server/request/create_resource.dart new file mode 100644 index 00000000..9ee2b5d4 --- /dev/null +++ b/lib/src/server/request/create_resource.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class CreateResource implements ControllerRequest { + final String type; + + CreateResource(this.type); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.createResource( + request, type, ResourceData.fromJson(jsonPayload).unwrap()); +} diff --git a/lib/src/server/request/delete_from_relationship.dart b/lib/src/server/request/delete_from_relationship.dart new file mode 100644 index 00000000..47b9d916 --- /dev/null +++ b/lib/src/server/request/delete_from_relationship.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class DeleteFromRelationship implements ControllerRequest { + final String type; + final String id; + final String relationship; + + DeleteFromRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteFromRelationship(request, type, id, relationship, + ToMany.fromJson(jsonPayload).unwrap()); +} diff --git a/lib/src/server/request/delete_resource.dart b/lib/src/server/request/delete_resource.dart new file mode 100644 index 00000000..a46db6ed --- /dev/null +++ b/lib/src/server/request/delete_resource.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class DeleteResource implements ControllerRequest { + final String type; + final String id; + + DeleteResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteResource(request, type, id); +} diff --git a/lib/src/server/request/fetch_collection.dart b/lib/src/server/request/fetch_collection.dart new file mode 100644 index 00000000..1558ee92 --- /dev/null +++ b/lib/src/server/request/fetch_collection.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class FetchCollection implements ControllerRequest { + final String type; + + FetchCollection(this.type); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchCollection(request, type); +} diff --git a/lib/src/server/request/fetch_related.dart b/lib/src/server/request/fetch_related.dart new file mode 100644 index 00000000..cb6cc841 --- /dev/null +++ b/lib/src/server/request/fetch_related.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class FetchRelated implements ControllerRequest { + final String type; + final String id; + final String relationship; + + FetchRelated(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelated(request, type, id, relationship); +} diff --git a/lib/src/server/request/fetch_relationship.dart b/lib/src/server/request/fetch_relationship.dart new file mode 100644 index 00000000..367d08bc --- /dev/null +++ b/lib/src/server/request/fetch_relationship.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class FetchRelationship implements ControllerRequest { + final String type; + final String id; + final String relationship; + + FetchRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelationship(request, type, id, relationship); +} diff --git a/lib/src/server/request/fetch_resource.dart b/lib/src/server/request/fetch_resource.dart new file mode 100644 index 00000000..b5f8fbf9 --- /dev/null +++ b/lib/src/server/request/fetch_resource.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class FetchResource implements ControllerRequest { + final String type; + final String id; + + FetchResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchResource(request, type, id); +} diff --git a/lib/src/server/request/invalid_request.dart b/lib/src/server/request/invalid_request.dart new file mode 100644 index 00000000..231dd840 --- /dev/null +++ b/lib/src/server/request/invalid_request.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class InvalidRequest implements ControllerRequest { + final String method; + + InvalidRequest(this.method); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} diff --git a/lib/src/server/request/update_relationship.dart b/lib/src/server/request/update_relationship.dart new file mode 100644 index 00000000..2372da3d --- /dev/null +++ b/lib/src/server/request/update_relationship.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class UpdateRelationship implements ControllerRequest { + final String type; + final String id; + final String relationship; + + UpdateRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + final r = Relationship.fromJson(jsonPayload); + if (r is ToOne) { + return controller.replaceToOne( + request, type, id, relationship, r.unwrap()); + } + if (r is ToMany) { + return controller.replaceToMany( + request, type, id, relationship, r.unwrap()); + } + } +} diff --git a/lib/src/server/request/update_resource.dart b/lib/src/server/request/update_resource.dart new file mode 100644 index 00000000..232dbc18 --- /dev/null +++ b/lib/src/server/request/update_resource.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class UpdateResource implements ControllerRequest { + final String type; + final String id; + + UpdateResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.updateResource( + request, type, id, ResourceData.fromJson(jsonPayload).unwrap()); +} diff --git a/lib/src/server/server_document_factory.dart b/lib/src/server/response_document_factory.dart similarity index 87% rename from lib/src/server/server_document_factory.dart rename to lib/src/server/response_document_factory.dart index d9a41c16..4eec028c 100644 --- a/lib/src/server/server_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -1,16 +1,16 @@ import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/query/page.dart'; import 'package:json_api/src/server/pagination/no_pagination.dart'; import 'package:json_api/src/server/pagination/pagination_strategy.dart'; -import 'package:json_api/url_design.dart'; -class ServerDocumentFactory { - final UrlFactory _urlFactory; +class ResponseDocumentFactory { + final Routing _routing; final PaginationStrategy _pagination; final Api _api; - ServerDocumentFactory(this._urlFactory, + ResponseDocumentFactory(this._routing, {Api api, PaginationStrategy pagination = const NoPagination()}) : _api = api, _pagination = pagination; @@ -59,7 +59,7 @@ class ServerDocumentFactory { /// See https://jsonapi.org/format/#crud-creating-responses-201 Document makeCreatedResourceDocument(Resource resource) => makeResourceDocument( - _urlFactory.resource(resource.type, resource.id), resource); + _routing.resource.uri(resource.type, resource.id), resource); /// A document containing a single related resource Document makeRelatedResourceDocument( @@ -82,7 +82,7 @@ class ServerDocumentFactory { identifiers.map(IdentifierObject.fromIdentifier), links: { 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) + 'related': Link(_routing.related.uri(type, id, relationship)) }, ), api: _api); @@ -95,7 +95,7 @@ class ServerDocumentFactory { nullable(IdentifierObject.fromIdentifier)(identifier), links: { 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) + 'related': Link(_routing.related.uri(type, id, relationship)) }, ), api: _api); @@ -111,8 +111,8 @@ class ServerDocumentFactory { ToOne( nullable(IdentifierObject.fromIdentifier)(v), links: { - 'self': Link(_urlFactory.relationship(r.type, r.id, k)), - 'related': Link(_urlFactory.related(r.type, r.id, k)) + 'self': Link(_routing.relationship.uri(r.type, r.id, k)), + 'related': Link(_routing.related.uri(r.type, r.id, k)) }, ))), ...r.toMany.map((k, v) => MapEntry( @@ -120,12 +120,12 @@ class ServerDocumentFactory { ToMany( v.map(IdentifierObject.fromIdentifier), links: { - 'self': Link(_urlFactory.relationship(r.type, r.id, k)), - 'related': Link(_urlFactory.related(r.type, r.id, k)) + 'self': Link(_routing.relationship.uri(r.type, r.id, k)), + 'related': Link(_routing.related.uri(r.type, r.id, k)) }, ))) }, links: { - 'self': Link(_urlFactory.resource(r.type, r.id)) + 'self': Link(_routing.resource.uri(r.type, r.id)) }); Map _navigation(Uri uri, int total) { diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart deleted file mode 100644 index 38553a19..00000000 --- a/lib/src/server/target.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:json_api/url_design.dart'; - -/// Creates JSON:API requests. -abstract class RequestFactory { - /// Returns an object representing a GET request to a resource URI - R fetchResource(ResourceTarget target); - - /// Returns an object representing a DELETE request to a resource URI - R deleteResource(ResourceTarget target); - - /// Returns an object representing a PATCH request to a resource URI - R updateResource(ResourceTarget target); - - /// Returns an object representing a GET request to a resource collection URI - R fetchCollection(CollectionTarget target); - - /// Returns an object representing a POST request to a resource collection URI - R createResource(CollectionTarget target); - - /// Returns an object representing a GET request to a related resource URI - R fetchRelated(RelatedTarget target); - - /// Returns an object representing a GET request to a relationship URI - R fetchRelationship(RelationshipTarget target); - - /// Returns an object representing a PATCH request to a relationship URI - R updateRelationship(RelationshipTarget target); - - /// Returns an object representing a POST request to a relationship URI - R addToRelationship(RelationshipTarget target); - - /// Returns an object representing a DELETE request to a relationship URI - R deleteFromRelationship(RelationshipTarget target); - - /// Returns an object representing a request with a [method] which is not - /// allowed by the [target]. Most likely, this should lead to either - /// `405 Method Not Allowed` or `400 Bad Request`. - R invalid(Target target, String method); -} - -/// The target of a JSON:API request URI. The URI target and the request method -/// uniquely identify the meaning of the JSON:API request. -abstract class Target { - /// Returns the request corresponding to the request [method]. - R getRequest(String method, RequestFactory factory); -} - -/// Request URI target which is not recognized by the URL Design. -class UnmatchedTarget implements Target { - final Uri uri; - - @override - const UnmatchedTarget(this.uri); - - @override - R getRequest(String method, RequestFactory factory) => - factory.invalid(this, method); -} - -/// The target of a URI referring to a single resource -class ResourceTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - const ResourceTarget(this.type, this.id); - - @override - R getRequest(String method, RequestFactory factory) { - switch (method.toUpperCase()) { - case 'GET': - return factory.fetchResource(this); - case 'DELETE': - return factory.deleteResource(this); - case 'PATCH': - return factory.updateResource(this); - default: - return factory.invalid(this, method); - } - } -} - -/// The target of a URI referring a resource collection -class CollectionTarget implements Target { - /// Resource type - final String type; - - const CollectionTarget(this.type); - - @override - R getRequest(String method, RequestFactory factory) { - switch (method.toUpperCase()) { - case 'GET': - return factory.fetchCollection(this); - case 'POST': - return factory.createResource(this); - default: - return factory.invalid(this, method); - } - } -} - -/// The target of a URI referring a related resource or collection -class RelatedTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelatedTarget(this.type, this.id, this.relationship); - - @override - R getRequest(String method, RequestFactory factory) { - switch (method.toUpperCase()) { - case 'GET': - return factory.fetchRelated(this); - default: - return factory.invalid(this, method); - } - } -} - -/// The target of a URI referring a relationship -class RelationshipTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelationshipTarget(this.type, this.id, this.relationship); - - @override - R getRequest(String method, RequestFactory factory) { - switch (method.toUpperCase()) { - case 'GET': - return factory.fetchRelationship(this); - case 'PATCH': - return factory.updateRelationship(this); - case 'POST': - return factory.addToRelationship(this); - case 'DELETE': - return factory.deleteFromRelationship(this); - default: - return factory.invalid(this, method); - } - } -} - -class TargetFactory implements MatchCase { - const TargetFactory(); - - @override - Target unmatched(Uri uri) => UnmatchedTarget(uri); - - @override - Target collection(String type) => CollectionTarget(type); - - @override - Target related(String type, String id, String relationship) => - RelatedTarget(type, id, relationship); - - @override - Target relationship(String type, String id, String relationship) => - RelationshipTarget(type, id, relationship); - - @override - Target resource(String type, String id) => ResourceTarget(type, id); -} diff --git a/lib/src/server/target/collection_target.dart b/lib/src/server/target/collection_target.dart new file mode 100644 index 00000000..e59d8e77 --- /dev/null +++ b/lib/src/server/target/collection_target.dart @@ -0,0 +1,25 @@ +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/request/create_resource.dart'; +import 'package:json_api/src/server/request/fetch_collection.dart'; +import 'package:json_api/src/server/request/invalid_request.dart'; +import 'package:json_api/src/server/target/target.dart'; + +/// The target of a URI referring a resource collection +class CollectionTarget implements Target { + /// Resource type + final String type; + + const CollectionTarget(this.type); + + @override + ControllerRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return FetchCollection(type); + case 'POST': + return CreateResource(type); + default: + return InvalidRequest(method); + } + } +} diff --git a/lib/src/server/target/invalid_target.dart b/lib/src/server/target/invalid_target.dart new file mode 100644 index 00000000..0368c4c5 --- /dev/null +++ b/lib/src/server/target/invalid_target.dart @@ -0,0 +1,14 @@ +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/request/invalid_request.dart'; +import 'package:json_api/src/server/target/target.dart'; + +/// Request URI target which is not recognized by the URL Design. +class InvalidTarget implements Target { + final Uri uri; + + @override + const InvalidTarget(this.uri); + + @override + ControllerRequest getRequest(String method) => InvalidRequest(method); +} diff --git a/lib/src/server/target/related_target.dart b/lib/src/server/target/related_target.dart new file mode 100644 index 00000000..eb57d0f5 --- /dev/null +++ b/lib/src/server/target/related_target.dart @@ -0,0 +1,28 @@ +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/request/fetch_related.dart'; +import 'package:json_api/src/server/request/invalid_request.dart'; +import 'package:json_api/src/server/target/target.dart'; + +/// The target of a URI referring a related resource or collection +class RelatedTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelatedTarget(this.type, this.id, this.relationship); + + @override + ControllerRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return FetchRelated(type, id, relationship); + default: + return InvalidRequest(method); + } + } +} diff --git a/lib/src/server/target/relationship_target.dart b/lib/src/server/target/relationship_target.dart new file mode 100644 index 00000000..84b15cfb --- /dev/null +++ b/lib/src/server/target/relationship_target.dart @@ -0,0 +1,37 @@ +import 'package:json_api/src/server/request/add_to_relationship.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/request/delete_from_relationship.dart'; +import 'package:json_api/src/server/request/fetch_relationship.dart'; +import 'package:json_api/src/server/request/invalid_request.dart'; +import 'package:json_api/src/server/request/update_relationship.dart'; +import 'package:json_api/src/server/target/target.dart'; + +/// The target of a URI referring a relationship +class RelationshipTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelationshipTarget(this.type, this.id, this.relationship); + + @override + ControllerRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return FetchRelationship(type, id, relationship); + case 'PATCH': + return UpdateRelationship(type, id, relationship); + case 'POST': + return AddToRelationship(type, id, relationship); + case 'DELETE': + return DeleteFromRelationship(type, id, relationship); + default: + return InvalidRequest(method); + } + } +} diff --git a/lib/src/server/target/resource_target.dart b/lib/src/server/target/resource_target.dart new file mode 100644 index 00000000..47f0c15f --- /dev/null +++ b/lib/src/server/target/resource_target.dart @@ -0,0 +1,31 @@ +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/request/delete_resource.dart'; +import 'package:json_api/src/server/request/fetch_resource.dart'; +import 'package:json_api/src/server/request/invalid_request.dart'; +import 'package:json_api/src/server/request/update_resource.dart'; +import 'package:json_api/src/server/target/target.dart'; + +/// The target of a URI referring to a single resource +class ResourceTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + const ResourceTarget(this.type, this.id); + + @override + ControllerRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return FetchResource(type, id); + case 'DELETE': + return DeleteResource(type, id); + case 'PATCH': + return UpdateResource(type, id); + default: + return InvalidRequest(method); + } + } +} diff --git a/lib/src/server/target/target.dart b/lib/src/server/target/target.dart new file mode 100644 index 00000000..984a9970 --- /dev/null +++ b/lib/src/server/target/target.dart @@ -0,0 +1,23 @@ +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/request/controller_request.dart'; +import 'package:json_api/src/server/target/collection_target.dart'; +import 'package:json_api/src/server/target/invalid_target.dart'; + +/// The target of a JSON:API request URI. The URI target and the request method +/// uniquely identify the meaning of the JSON:API request. +abstract class Target { + /// Returns the request corresponding to the request [method]. + ControllerRequest getRequest(String method); + + static Target match(Uri uri, Routing routing) { + Target target = InvalidTarget(uri); + final collection = routing.collection.match(uri); + if( collection != null) { + return CollectionTarget(collection.type); + } + if (routing.collection + .match(uri, (type) => target = CollectionTarget(type))) { + return target; + } + } +} diff --git a/lib/src/url_design/path_based_url_design.dart b/lib/src/url_design/path_based_url_design.dart deleted file mode 100644 index e1b2924c..00000000 --- a/lib/src/url_design/path_based_url_design.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:json_api/src/url_design/url_design.dart'; - -/// URL Design where the target is determined by the URL path. -/// This is the recommended design according to the JSON:API standard. -/// @see https://jsonapi.org/recommendations/#urls -class PathBasedUrlDesign implements UrlDesign { - static const _relationships = 'relationships'; - - /// The base to be added the the generated URIs - final Uri base; - - /// Check incoming URIs match the [base] - final bool matchBase; - - PathBasedUrlDesign(this.base, {this.matchBase = false}); - - /// Creates an instance with "/" as the base URI. - static UrlDesign relative() => PathBasedUrlDesign(Uri()); - - /// Returns a URL for the primary resource collection of type [type] - @override - Uri collection(String type) => _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. - @override - Uri related(String type, String id, String relationship) => - _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. - @override - Uri relationship(String type, String id, String relationship) => - _appendToBase([type, id, _relationships, relationship]); - - /// Returns a URL for the primary resource of type [type] with id [id] - @override - Uri resource(String type, String id) => _appendToBase([type, id]); - - @override - T match(final Uri uri, final MatchCase matchCase) { - if (!matchBase || _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); - } - - Uri _appendToBase(List segments) => - base.replace(pathSegments: base.pathSegments + segments); - - bool _matchesBase(Uri uri) => - base.host == uri.host && - base.port == uri.port && - uri.path.startsWith(base.path); -} diff --git a/lib/src/url_design/url_design.dart b/lib/src/url_design/url_design.dart deleted file mode 100644 index 5220bfa4..00000000 --- a/lib/src/url_design/url_design.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// URL Design describes how the endpoints are organized. -abstract class UrlDesign implements TargetMatcher, UrlFactory {} - -/// 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(Uri uri); -} diff --git a/lib/url_design.dart b/lib/url_design.dart deleted file mode 100644 index 03111744..00000000 --- a/lib/url_design.dart +++ /dev/null @@ -1,21 +0,0 @@ -/// The URL Design specifies the structure of the URLs used for specific targets. -/// The JSON:API standard describes 4 possible request targets: -/// - Collections (parameterized by the resource type) -/// - Individual resources (parameterized by the resource type and id) -/// - 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 [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] which implements the -/// [Recommended URL Design] allowing you to specify the a common prefix -/// for all your JSON:API endpoints. -/// -/// [Recommended URL Design]: https://jsonapi.org/recommendations/#urls -library url_design; - -export 'package:json_api/src/url_design/path_based_url_design.dart'; -export 'package:json_api/src/url_design/url_design.dart'; diff --git a/test/functional/crud_test.dart b/test/functional/crud_test.dart index 861d24fe..45ac7887 100644 --- a/test/functional/crud_test.dart +++ b/test/functional/crud_test.dart @@ -40,7 +40,7 @@ void main() async { setUp(() async { client = UrlAwareClient(design); - final handler = createHttpHandler( + final handler = Handler( ShelfRequestResponseConverter(), CRUDController(Uuid().v4), design); server = await serve(handler, host, port); @@ -69,6 +69,13 @@ void main() async { expect(r.data.unwrap().toMany['authors'].last.type, 'people'); }); + test('a non-existing primary resource', () async { + final r = await client.fetchResource('unicorns', '1'); + expect(r.status, 404); + expect(r.isSuccessful, isFalse); + expect(r.document.errors.first.detail, 'Resource not found'); + }); + test('a primary collection', () async { final r = await client.fetchCollection('people'); expect(r.status, 200); diff --git a/test/unit/url_design/path_based_url_design_test.dart b/test/unit/url_design/path_based_url_design_test.dart deleted file mode 100644 index 53741a6e..00000000 --- a/test/unit/url_design/path_based_url_design_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:json_api/url_design.dart'; -import 'package:test/test.dart'; - -void main() { - final routing = - PathBasedUrlDesign(Uri.parse('http://example.com/api'), matchBase: true); - final mapper = _Mapper(); - - group('URL construction', () { - test('Collection URL adds type', () { - expect(routing.collection('books').toString(), - 'http://example.com/api/books'); - }); - - test('Resource URL adds type, ai', () { - expect(routing.resource('books', '42').toString(), - 'http://example.com/api/books/42'); - }); - - test('Related URL adds type, id, relationship', () { - expect(routing.related('books', '42', 'sellers').toString(), - 'http://example.com/api/books/42/sellers'); - }); - - test('Reltionship URL adds type, id, relationship', () { - expect(routing.relationship('books', '42', 'sellers').toString(), - 'http://example.com/api/books/42/relationships/sellers'); - }); - }); - - group('URL matching', () { - test('Matches collection URL', () { - expect(routing.match(Uri.parse('http://example.com/api/books'), mapper), - 'collection:books'); - }); - - test('Matches resource URL', () { - expect( - routing.match(Uri.parse('http://example.com/api/books/42'), mapper), - 'resource:books:42'); - }); - - test('Matches related URL', () { - expect( - routing.match( - Uri.parse('http://example.com/api/books/42/authors'), mapper), - 'related:books:42:authors'); - }); - - test('Matches relationship URL', () { - 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', () { - expect(routing.match(Uri.parse('http://example.com/foo/apples'), mapper), - 'unmatched:http://example.com/foo/apples'); - }); - - test('Does not match collection URL with incorrect host', () { - expect(routing.match(Uri.parse('http://example.org/api/apples'), mapper), - 'unmatched:http://example.org/api/apples'); - }); - - test('Does not match collection URL with incorrect port', () { - expect( - routing.match( - Uri.parse('http://example.com:8080/api/apples'), mapper), - 'unmatched:http://example.com:8080/api/apples'); - }); - }); -} - -class _Mapper implements MatchCase { - @override - String unmatched(Uri uri) => 'unmatched:$uri'; - - @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'; -} From 6c17e09883379609e1d3b229ad6caf8aa088bfb8 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 19 Jan 2020 22:35:59 -0800 Subject: [PATCH 06/99] WIP --- example/fetch_collection.dart | 6 +- example/server/crud_controller.dart | 14 +- example/server/server.dart | 4 +- .../shelf_request_response_converter.dart | 2 +- lib/client.dart | 2 +- lib/routing.dart | 6 - lib/server.dart | 4 +- lib/src/client/json_api_client.dart | 12 +- lib/src/client/request_document_factory.dart | 2 +- ...ware_client.dart => uri_aware_client.dart} | 39 ++-- lib/src/routing/collection_uri.dart | 9 - lib/src/routing/recommended_routing.dart | 113 ---------- lib/src/routing/related_uri.dart | 6 - lib/src/routing/relationship_uri.dart | 6 - lib/src/routing/resource_uri.dart | 5 - lib/src/routing/routing.dart | 19 -- lib/src/server/http_handler.dart | 69 +++--- lib/src/server/json_api_request.dart | 196 ++++++++++++++++++ lib/src/server/json_api_response.dart | 36 ++-- lib/src/server/pagination.dart | 91 ++++++++ .../server/pagination/fixed_size_page.dart | 42 ---- lib/src/server/pagination/no_pagination.dart | 24 --- .../pagination/pagination_strategy.dart | 23 -- .../server/request/add_to_relationship.dart | 20 -- .../server/request/controller_request.dart | 10 - lib/src/server/request/create_resource.dart | 18 -- .../request/delete_from_relationship.dart | 20 -- lib/src/server/request/delete_resource.dart | 17 -- lib/src/server/request/fetch_collection.dart | 16 -- lib/src/server/request/fetch_related.dart | 18 -- .../server/request/fetch_relationship.dart | 18 -- lib/src/server/request/fetch_resource.dart | 17 -- lib/src/server/request/invalid_request.dart | 18 -- .../server/request/update_relationship.dart | 28 --- lib/src/server/request/update_resource.dart | 19 -- lib/src/server/response_document_factory.dart | 38 ++-- lib/src/server/target.dart | 150 ++++++++++++++ lib/src/server/target/collection_target.dart | 25 --- lib/src/server/target/invalid_target.dart | 14 -- lib/src/server/target/related_target.dart | 28 --- .../server/target/relationship_target.dart | 37 ---- lib/src/server/target/resource_target.dart | 31 --- lib/src/server/target/target.dart | 23 -- lib/uri_design.dart | 95 +++++++++ test/functional/crud_test.dart | 13 +- test/unit/server/numbered_page_test.dart | 10 +- 46 files changed, 659 insertions(+), 754 deletions(-) delete mode 100644 lib/routing.dart rename lib/src/client/{url_aware_client.dart => uri_aware_client.dart} (85%) delete mode 100644 lib/src/routing/collection_uri.dart delete mode 100644 lib/src/routing/recommended_routing.dart delete mode 100644 lib/src/routing/related_uri.dart delete mode 100644 lib/src/routing/relationship_uri.dart delete mode 100644 lib/src/routing/resource_uri.dart delete mode 100644 lib/src/routing/routing.dart create mode 100644 lib/src/server/json_api_request.dart create mode 100644 lib/src/server/pagination.dart delete mode 100644 lib/src/server/pagination/fixed_size_page.dart delete mode 100644 lib/src/server/pagination/no_pagination.dart delete mode 100644 lib/src/server/pagination/pagination_strategy.dart delete mode 100644 lib/src/server/request/add_to_relationship.dart delete mode 100644 lib/src/server/request/controller_request.dart delete mode 100644 lib/src/server/request/create_resource.dart delete mode 100644 lib/src/server/request/delete_from_relationship.dart delete mode 100644 lib/src/server/request/delete_resource.dart delete mode 100644 lib/src/server/request/fetch_collection.dart delete mode 100644 lib/src/server/request/fetch_related.dart delete mode 100644 lib/src/server/request/fetch_relationship.dart delete mode 100644 lib/src/server/request/fetch_resource.dart delete mode 100644 lib/src/server/request/invalid_request.dart delete mode 100644 lib/src/server/request/update_relationship.dart delete mode 100644 lib/src/server/request/update_resource.dart create mode 100644 lib/src/server/target.dart delete mode 100644 lib/src/server/target/collection_target.dart delete mode 100644 lib/src/server/target/invalid_target.dart delete mode 100644 lib/src/server/target/related_target.dart delete mode 100644 lib/src/server/target/relationship_target.dart delete mode 100644 lib/src/server/target/resource_target.dart delete mode 100644 lib/src/server/target/target.dart create mode 100644 lib/uri_design.dart diff --git a/example/fetch_collection.dart b/example/fetch_collection.dart index d264afeb..64490941 100644 --- a/example/fetch_collection.dart +++ b/example/fetch_collection.dart @@ -1,11 +1,11 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; +import 'package:json_api/uri_design.dart'; /// Start `dart example/server/server.dart` first void main() async { - final url = Uri.parse('http://localhost:8080'); - final client = UrlAwareClient(RecommendedRouting(url)); + final base = Uri.parse('http://localhost:8080'); + final client = UriAwareClient(UriDesign.standard(base)); await client.createResource( Resource('messages', '1', attributes: {'text': 'Hello World'})); client.close(); diff --git a/example/server/crud_controller.dart b/example/server/crud_controller.dart index c7354f31..865855e1 100644 --- a/example/server/crud_controller.dart +++ b/example/server/crud_controller.dart @@ -4,12 +4,18 @@ import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; import 'package:shelf/shelf.dart' as shelf; +typedef Criteria = bool Function(String type); + +/// This is an example controller allowing simple CRUD operations on resources. class CRUDController implements JsonApiController { + /// Generates a new GUID final String Function() generateId; - final store = >{}; + /// Returns true is the [type] is supported by the server + final bool Function(String type) isTypeSupported; + final _store = >{}; - CRUDController(this.generateId); + CRUDController(this.generateId, this.isTypeSupported); @override FutureOr createResource( @@ -167,7 +173,7 @@ class CRUDController implements JsonApiController { } Map _repo(String type) { - store.putIfAbsent(type, () => {}); - return store[type]; + _store.putIfAbsent(type, () => {}); + return _store[type]; } } diff --git a/example/server/server.dart b/example/server/server.dart index 320e49be..f6cbda90 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/http_handler.dart'; -import 'package:json_api/url_design.dart'; +import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:uuid/uuid.dart'; @@ -15,7 +15,7 @@ void main() async { final port = 8080; final baseUri = Uri(scheme: 'http', host: host, port: port); final jsonApiHandler = Handler(ShelfRequestResponseConverter(), - CRUDController(Uuid().v4), PathBasedUrlDesign(baseUri)); + CRUDController(Uuid().v4, (_) => true), UriDesign.standard(baseUri)); await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); print('Serving at $baseUri'); diff --git a/example/server/shelf_request_response_converter.dart b/example/server/shelf_request_response_converter.dart index 8da05477..4a314305 100644 --- a/example/server/shelf_request_response_converter.dart +++ b/example/server/shelf_request_response_converter.dart @@ -4,7 +4,7 @@ import 'package:json_api/server.dart'; import 'package:shelf/shelf.dart' as shelf; class ShelfRequestResponseConverter - implements HttpMessageConverter { + implements HttpAdapter { @override FutureOr createResponse( int statusCode, String body, Map headers) => diff --git a/lib/client.dart b/lib/client.dart index 48bcfca8..aae7c575 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -4,4 +4,4 @@ export 'package:json_api/src/client/request_document_factory.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; -export 'package:json_api/src/client/url_aware_client.dart'; +export 'package:json_api/src/client/uri_aware_client.dart'; diff --git a/lib/routing.dart b/lib/routing.dart deleted file mode 100644 index 3d74f357..00000000 --- a/lib/routing.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'src/routing/collection_uri.dart'; -export 'src/routing/recommended_routing.dart'; -export 'src/routing/related_uri.dart'; -export 'src/routing/relationship_uri.dart'; -export 'src/routing/resource_uri.dart'; -export 'src/routing/routing.dart'; diff --git a/lib/server.dart b/lib/server.dart index 8d9bb552..2472a20e 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,8 +5,6 @@ library server; export 'package:json_api/src/server/http_handler.dart'; export 'package:json_api/src/server/json_api_controller.dart'; -export 'package:json_api/src/server/pagination/fixed_size_page.dart'; -export 'package:json_api/src/server/pagination/pagination_strategy.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/response_document_factory.dart'; -export 'package:json_api/src/server/target/target.dart'; +export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 3c5a8497..5d89400b 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -8,11 +8,6 @@ import 'package:json_api/src/client/request_document_factory.dart'; import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/status_code.dart'; -/// Defines the hook which gets called when the HTTP response is received from -/// the HTTP Client. -typedef OnHttpCall = void Function( - http.Request request, http.Response response); - /// The JSON:API Client. /// /// [JsonApiClient] works on top of Dart's built-in HTTP client. @@ -164,7 +159,7 @@ class JsonApiClient { {RequestDocumentFactory builder, OnHttpCall onHttpCall, http.Client httpClient}) - : _factory = builder ?? RequestDocumentFactory(), + : _factory = builder ?? RequestDocumentFactory(api: Api(version: '1.0')), _http = httpClient ?? http.Client(), _onHttpCall = onHttpCall ?? _doNothing; @@ -236,4 +231,9 @@ class JsonApiClient { } } +/// Defines the hook which gets called when the HTTP response is received from +/// the HTTP Client. +typedef OnHttpCall = void Function( + http.Request request, http.Response response); + void _doNothing(http.Request request, http.Response response) {} diff --git a/lib/src/client/request_document_factory.dart b/lib/src/client/request_document_factory.dart index afe65411..b3992b50 100644 --- a/lib/src/client/request_document_factory.dart +++ b/lib/src/client/request_document_factory.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; -/// This is a document factory used by the client. It is responsible +/// The document factory used by the client. It is responsible /// for building the JSON representation of the outgoing resources. class RequestDocumentFactory { /// Makes a document containing a single resource. diff --git a/lib/src/client/url_aware_client.dart b/lib/src/client/uri_aware_client.dart similarity index 85% rename from lib/src/client/url_aware_client.dart rename to lib/src/client/uri_aware_client.dart index d6d70c03..dcb60c82 100644 --- a/lib/src/client/url_aware_client.dart +++ b/lib/src/client/uri_aware_client.dart @@ -1,19 +1,19 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; +import 'package:json_api/uri_design.dart'; /// A wrapper over [JsonApiClient] making use of the given UrlFactory. /// This wrapper reduces the boilerplate code but is not as flexible /// as [JsonApiClient]. -class UrlAwareClient { +class UriAwareClient { /// Creates a new resource. The resource will be added to a collection /// according to its type. /// /// https://jsonapi.org/format/#crud-creating Future> createResource(Resource resource, {Map headers}) => - _client.createResource(_routing.collection.uri(resource.type), resource, + _client.createResource(_uriFactory.collectionUri(resource.type), resource, headers: headers); /// Fetches a single resource @@ -23,7 +23,7 @@ class UrlAwareClient { /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) Future> fetchResource(String type, String id, {Map headers, QueryParameters parameters}) => - _client.fetchResource(_routing.resource.uri(type, id), + _client.fetchResource(_uriFactory.resourceUri(type, id), headers: headers, parameters: parameters); /// Fetches a resource collection . @@ -33,7 +33,7 @@ class UrlAwareClient { /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) Future> fetchCollection(String type, {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_routing.collection.uri(type), + _client.fetchCollection(_uriFactory.collectionUri(type), headers: headers, parameters: parameters); /// Fetches a related resource. @@ -44,7 +44,7 @@ class UrlAwareClient { Future> fetchRelatedResource( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchResource(_routing.related.uri(type, id, relationship), + _client.fetchResource(_uriFactory.relatedUri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a related resource collection. @@ -55,7 +55,7 @@ class UrlAwareClient { Future> fetchRelatedCollection( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_routing.related.uri(type, id, relationship), + _client.fetchCollection(_uriFactory.relatedUri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one relationship @@ -66,7 +66,7 @@ class UrlAwareClient { Future> fetchToOne( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToOne(_routing.relationship.uri(type, id, relationship), + _client.fetchToOne(_uriFactory.relationshipUri(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one or to-many relationship. @@ -79,7 +79,7 @@ class UrlAwareClient { String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchRelationship( - _routing.relationship.uri(type, id, relationship), + _uriFactory.relationshipUri(type, id, relationship), headers: headers, parameters: parameters); @@ -91,7 +91,7 @@ class UrlAwareClient { Future> fetchToMany( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToMany(_routing.relationship.uri(type, id, relationship), + _client.fetchToMany(_uriFactory.relationshipUri(type, id, relationship), headers: headers, parameters: parameters); /// Deletes the resource referenced by [type] and [id]. @@ -99,14 +99,15 @@ class UrlAwareClient { /// https://jsonapi.org/format/#crud-deleting Future deleteResource(String type, String id, {Map headers}) => - _client.deleteResource(_routing.resource.uri(type, id), headers: headers); + _client.deleteResource(_uriFactory.resourceUri(type, id), + headers: headers); /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] /// with id = null. Future> deleteToOne( String type, String id, String relationship, {Map headers}) => - _client.deleteToOne(_routing.relationship.uri(type, id, relationship), + _client.deleteToOne(_uriFactory.relationshipUri(type, id, relationship), headers: headers); /// Removes the [identifiers] from the to-many relationship. @@ -116,7 +117,7 @@ class UrlAwareClient { String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToMany( - _routing.relationship.uri(type, id, relationship), identifiers, + _uriFactory.relationshipUri(type, id, relationship), identifiers, headers: headers); /// Updates the [resource]. @@ -125,7 +126,7 @@ class UrlAwareClient { Future> updateResource(Resource resource, {Map headers}) => _client.updateResource( - _routing.resource.uri(resource.type, resource.id), resource, + _uriFactory.resourceUri(resource.type, resource.id), resource, headers: headers); /// Adds the given set of [identifiers] to a to-many relationship. @@ -135,7 +136,7 @@ class UrlAwareClient { String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationship( - _routing.relationship.uri(type, id, relationship), identifiers, + _uriFactory.relationshipUri(type, id, relationship), identifiers, headers: headers); /// Replaces a to-many relationship with the given set of [identifiers]. @@ -145,7 +146,7 @@ class UrlAwareClient { String relationship, Iterable identifiers, {Map headers}) => _client.replaceToMany( - _routing.relationship.uri(type, id, relationship), identifiers, + _uriFactory.relationshipUri(type, id, relationship), identifiers, headers: headers); /// Updates a to-one relationship via PATCH query @@ -155,15 +156,15 @@ class UrlAwareClient { String type, String id, String relationship, Identifier identifier, {Map headers}) => _client.replaceToOne( - _routing.relationship.uri(type, id, relationship), identifier, + _uriFactory.relationshipUri(type, id, relationship), identifier, headers: headers); /// Closes the internal client. You have to either call this method or /// close the client yourself. void close() => _client.close(); - UrlAwareClient(this._routing, {JsonApiClient jsonApiClient}) + UriAwareClient(this._uriFactory, {JsonApiClient jsonApiClient}) : _client = jsonApiClient ?? JsonApiClient(); final JsonApiClient _client; - final Routing _routing; + final UriFactory _uriFactory; } diff --git a/lib/src/routing/collection_uri.dart b/lib/src/routing/collection_uri.dart deleted file mode 100644 index 9eb8f7a8..00000000 --- a/lib/src/routing/collection_uri.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Resource collection URL -abstract class CollectionUri { - /// Returns a URL for a collection of type [type] - Uri uri(String type); - - /// Returns true is the [uri] is a collection. - /// If matches, the [onMatch] will be called with the collection type. - bool match(Uri uri, void Function(String type) onMatch); -} diff --git a/lib/src/routing/recommended_routing.dart b/lib/src/routing/recommended_routing.dart deleted file mode 100644 index 8a1abd4c..00000000 --- a/lib/src/routing/recommended_routing.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:json_api/src/routing/collection_uri.dart'; -import 'package:json_api/src/routing/related_uri.dart'; -import 'package:json_api/src/routing/relationship_uri.dart'; -import 'package:json_api/src/routing/resource_uri.dart'; -import 'package:json_api/src/routing/routing.dart'; - -/// The recommended route design. -/// See https://jsonapi.org/recommendations/#urls -class RecommendedRouting implements Routing { - @override - final CollectionUri collection; - - @override - final RelatedUri related; - - @override - final RelationshipUri relationship; - - @override - final ResourceUri resource; - - /// Creates an instance of - RecommendedRouting(Uri base) - : collection = _Collection(base), - resource = _Resource(base), - related = _Related(base), - relationship = _Relationship(base); -} - -class _Collection extends _Recommended implements CollectionUri { - @override - Uri uri(String type) => _append([type]); - - @override - bool match(Uri uri, void Function(String type) onMatch) { - final seg = _segments(uri); - if (seg.length == 1) { - onMatch(seg[0]); - return true; - } - return false; - } - - const _Collection(Uri base) : super(base); -} - -class _Resource extends _Recommended implements ResourceUri { - @override - Uri uri(String type, String id) => _append([type, id]); - - @override - bool match(Uri uri, void Function(String type, String id) onMatch) { - final seg = _segments(uri); - if (seg.length == 2) { - onMatch(seg[0], seg[1]); - return true; - } - return false; - } - - const _Resource(Uri base) : super(base); -} - -class _Related extends _Recommended implements RelatedUri { - @override - Uri uri(String type, String id, String relationship) => - _append([type, id, relationship]); - - @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { - final seg = _segments(uri); - if (seg.length == 3) { - onMatch(seg[0], seg[1], seg[3]); - return true; - } - return false; - } - - const _Related(Uri base) : super(base); -} - -class _Relationship extends _Recommended implements RelationshipUri { - @override - Uri uri(String type, String id, String relationship) => - _append([type, id, _relationships, relationship]); - - @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { - final seg = _segments(uri); - if (seg.length == 4 && seg[2] == _relationships) { - onMatch(seg[0], seg[1], seg[3]); - return true; - } - return false; - } - - const _Relationship(Uri base) : super(base); - static const _relationships = 'relationships'; -} - -class _Recommended { - Uri _append(Iterable segments) => - _base.replace(pathSegments: _base.pathSegments + segments); - - List _segments(Uri uri) => - uri.pathSegments.sublist(_base.pathSegments.length); - - const _Recommended(this._base); - - final Uri _base; -} diff --git a/lib/src/routing/related_uri.dart b/lib/src/routing/related_uri.dart deleted file mode 100644 index 0d929585..00000000 --- a/lib/src/routing/related_uri.dart +++ /dev/null @@ -1,6 +0,0 @@ -abstract class RelatedUri { - Uri uri(String type, String id, String relationship); - - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} diff --git a/lib/src/routing/relationship_uri.dart b/lib/src/routing/relationship_uri.dart deleted file mode 100644 index 4b761f8c..00000000 --- a/lib/src/routing/relationship_uri.dart +++ /dev/null @@ -1,6 +0,0 @@ -abstract class RelationshipUri { - Uri uri(String type, String id, String relationship); - - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} diff --git a/lib/src/routing/resource_uri.dart b/lib/src/routing/resource_uri.dart deleted file mode 100644 index 6a5b5ca8..00000000 --- a/lib/src/routing/resource_uri.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class ResourceUri { - Uri uri(String type, String id); - - bool match(Uri uri, void Function(String type, String id) onMatch); -} diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart deleted file mode 100644 index 50b6c488..00000000 --- a/lib/src/routing/routing.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_api/src/routing/collection_uri.dart'; -import 'package:json_api/src/routing/related_uri.dart'; -import 'package:json_api/src/routing/relationship_uri.dart'; -import 'package:json_api/src/routing/resource_uri.dart'; - -/// Routing represents the design of the 4 kinds of URLs: -/// - collection URL (e.g. /books) -/// - resource URL (e.g. /books/13) -/// - related resource/collection URL (e.g. /books/123/author) -/// - relationship URL (e.g. /books/123/relationship/author) -abstract class Routing { - CollectionUri get collection; - - ResourceUri get resource; - - RelatedUri get related; - - RelationshipUri get relationship; -} diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart index 72d82690..ff9e934e 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/http_handler.dart @@ -1,52 +1,49 @@ import 'dart:async'; import 'dart:convert'; -import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/pagination/no_pagination.dart'; -import 'package:json_api/src/server/pagination/pagination_strategy.dart'; +import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/response_document_factory.dart'; -import 'package:json_api/src/server/target/target_factory.dart'; - -abstract class HttpMessageConverter { - FutureOr getMethod(Request request); - - FutureOr getUri(Request request); - - FutureOr getBody(Request request); - - FutureOr createResponse( - int statusCode, String body, Map headers); -} +import 'package:json_api/uri_design.dart'; /// HTTP handler class Handler { /// Processes the incoming HTTP [request] and returns a response Future call(Request request) async { - final uri = await _converter.getUri(request); - final method = await _converter.getMethod(request); - final body = await _converter.getBody(request); - final document = body.isEmpty ? null : json.decode(body); - - final response = await _routing - .match(uri, _toTarget) - .getRequest(method) - .call(_controller, document, request); - - return _converter.createResponse( - response.statusCode, - json.encode(response.buildDocument(_docFactory, uri)), - response.buildHeaders(_routing)); + final uri = await _http.getUri(request); + final method = await _http.getMethod(request); + final requestBody = await _http.getBody(request); + final requestDoc = requestBody.isEmpty ? null : json.decode(requestBody); + final requestTarget = Target.of(uri, _design); + final jsonApiRequest = requestTarget.getRequest(method); + final jsonApiResponse = + await jsonApiRequest.call(_controller, requestDoc, request); + final statusCode = jsonApiResponse.statusCode; + final headers = jsonApiResponse.buildHeaders(_design); + final responseDocument = jsonApiResponse.buildDocument(_docFactory, uri); + return _http.createResponse( + statusCode, json.encode(responseDocument), headers); } /// Creates an instance of the handler. - Handler(this._converter, this._controller, this._routing, - {PaginationStrategy pagination = const NoPagination()}) - : _docFactory = - ResponseDocumentFactory(_routing, pagination: pagination); - final HttpMessageConverter _converter; + Handler(this._http, this._controller, this._design, {Pagination pagination}) + : _docFactory = ResponseDocumentFactory(_design, + pagination: pagination ?? Pagination.none()); + final HttpAdapter _http; final JsonApiController _controller; - final Routing _routing; + final UriDesign _design; final ResponseDocumentFactory _docFactory; - final TargetFactory _toTarget = const TargetFactory(); +} + +/// The adapter is responsible +abstract class HttpAdapter { + FutureOr getMethod(Request request); + + FutureOr getUri(Request request); + + FutureOr getBody(Request request); + + FutureOr createResponse( + int statusCode, String body, Map headers); } diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart new file mode 100644 index 00000000..ad616262 --- /dev/null +++ b/lib/src/server/json_api_request.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +abstract class JsonApiRequest { + /// Calls the appropriate method of [controller] + FutureOr call( + JsonApiController controller, Object jsonPayload, R request); + + static JsonApiRequest fetchCollection(String type) => _FetchCollection(type); + + static JsonApiRequest createResource(String type) => _CreateResource(type); + + static JsonApiRequest invalidRequest(String method) => + _InvalidRequest(method); + + static JsonApiRequest fetchResource(String type, String id) => + _FetchResource(type, id); + + static JsonApiRequest deleteResource(String type, String id) => + _DeleteResource(type, id); + + static JsonApiRequest updateResource(String type, String id) => + _UpdateResource(type, id); + + static JsonApiRequest fetchRelated( + String type, String id, String relationship) => + _FetchRelated(type, id, relationship); + + static JsonApiRequest fetchRelationship( + String type, String id, String relationship) => + _FetchRelationship(type, id, relationship); + + static JsonApiRequest updateRelationship( + String type, String id, String relationship) => + _UpdateRelationship(type, id, relationship); + + static JsonApiRequest addToRelationship( + String type, String id, String relationship) => + _AddToRelationship(type, id, relationship); + + static JsonApiRequest deleteFromRelationship( + String type, String id, String relationship) => + _DeleteFromRelationship(type, id, relationship); +} + +class _AddToRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + _AddToRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.addToRelationship(request, type, id, relationship, + ToMany.fromJson(jsonPayload).unwrap()); +} + +class _CreateResource implements JsonApiRequest { + final String type; + + _CreateResource(this.type); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.createResource( + request, type, ResourceData.fromJson(jsonPayload).unwrap()); +} + +class _DeleteFromRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + _DeleteFromRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteFromRelationship(request, type, id, relationship, + ToMany.fromJson(jsonPayload).unwrap()); +} + +class _DeleteResource implements JsonApiRequest { + final String type; + final String id; + + _DeleteResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.deleteResource(request, type, id); +} + +class _FetchCollection implements JsonApiRequest { + final String type; + + _FetchCollection(this.type); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchCollection(request, type); +} + +class _FetchRelated implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + _FetchRelated(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelated(request, type, id, relationship); +} + +class _FetchRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + _FetchRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchRelationship(request, type, id, relationship); +} + +class _FetchResource implements JsonApiRequest { + final String type; + final String id; + + _FetchResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.fetchResource(request, type, id); +} + +class _InvalidRequest implements JsonApiRequest { + final String method; + + _InvalidRequest(this.method); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + // TODO: implement call + return null; + } +} + +class _UpdateRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + _UpdateRelationship(this.type, this.id, this.relationship); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) { + final r = Relationship.fromJson(jsonPayload); + if (r is ToOne) { + return controller.replaceToOne( + request, type, id, relationship, r.unwrap()); + } + if (r is ToMany) { + return controller.replaceToMany( + request, type, id, relationship, r.unwrap()); + } + } +} + +class _UpdateResource implements JsonApiRequest { + final String type; + final String id; + + _UpdateResource(this.type, this.id); + + @override + FutureOr call( + JsonApiController controller, Object jsonPayload, R request) => + controller.updateResource( + request, type, id, ResourceData.fromJson(jsonPayload).unwrap()); +} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 728ee13c..4a2bbc30 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,6 +1,6 @@ import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/src/server/response_document_factory.dart'; +import 'package:json_api/uri_design.dart'; abstract class JsonApiResponse { final int statusCode; @@ -9,7 +9,7 @@ abstract class JsonApiResponse { Document buildDocument(ResponseDocumentFactory factory, Uri self); - Map buildHeaders(Routing routing); + Map buildHeaders(UriDesign design); static JsonApiResponse noContent() => _NoContent(); @@ -82,7 +82,7 @@ class _NoContent extends JsonApiResponse { null; @override - Map buildHeaders(Routing routing) => {}; + Map buildHeaders(UriDesign design) => {}; } class _Collection extends JsonApiResponse { @@ -99,7 +99,7 @@ class _Collection extends JsonApiResponse { included: included, total: total); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -114,10 +114,10 @@ class _Accepted extends JsonApiResponse { factory.makeResourceDocument(self, resource); @override - Map buildHeaders(Routing routing) => { + Map buildHeaders(UriDesign design) => { 'Content-Type': Document.contentType, 'Content-Location': - routing.resource.uri(resource.type, resource.id).toString(), + design.resourceUri(resource.type, resource.id).toString(), }; } @@ -131,7 +131,7 @@ class _Error extends JsonApiResponse { builder.makeErrorDocument(errors); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -145,7 +145,7 @@ class _Meta extends JsonApiResponse { builder.makeMetaDocument(meta); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -163,7 +163,7 @@ class _RelatedCollection extends JsonApiResponse { builder.makeRelatedCollectionDocument(self, collection, total: total); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -179,7 +179,7 @@ class _RelatedResource extends JsonApiResponse { builder.makeRelatedResourceDocument(self, resource); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -196,9 +196,9 @@ class _ResourceCreated extends JsonApiResponse { builder.makeCreatedResourceDocument(resource); @override - Map buildHeaders(Routing routing) => { + Map buildHeaders(UriDesign design) => { 'Content-Type': Document.contentType, - 'Location': routing.resource.uri(resource.type, resource.id).toString() + 'Location': design.resourceUri(resource.type, resource.id).toString() }; } @@ -214,7 +214,7 @@ class _Resource extends JsonApiResponse { builder.makeResourceDocument(self, resource, included: included); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -229,7 +229,7 @@ class _ResourceUpdated extends JsonApiResponse { builder.makeResourceDocument(self, resource); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -243,8 +243,8 @@ class _SeeOther extends JsonApiResponse { Document buildDocument(ResponseDocumentFactory builder, Uri self) => null; @override - Map buildHeaders(Routing routing) => - {'Location': routing.resource.uri(type, id).toString()}; + Map buildHeaders(UriDesign design) => + {'Location': design.resourceUri(type, id).toString()}; } class _ToMany extends JsonApiResponse { @@ -261,7 +261,7 @@ class _ToMany extends JsonApiResponse { builder.makeToManyDocument(self, collection, type, id, relationship); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } @@ -279,6 +279,6 @@ class _ToOne extends JsonApiResponse { builder.makeToOneDocument(self, identifier, type, id, relationship); @override - Map buildHeaders(Routing routing) => + Map buildHeaders(UriDesign design) => {'Content-Type': Document.contentType}; } diff --git a/lib/src/server/pagination.dart b/lib/src/server/pagination.dart new file mode 100644 index 00000000..667f9c3b --- /dev/null +++ b/lib/src/server/pagination.dart @@ -0,0 +1,91 @@ +import 'package:json_api/src/query/page.dart'; + +/// Pagination strategy determines how pagination information is encoded in the +/// URL query parameters +abstract class Pagination { + /// Number of elements per page. Null for unlimited. + int limit(Page page); + + /// The page offset. + int offset(Page page); + + /// Link to the first page. Null if not supported. + Page first(); + + /// Reference to the last page. Null if not supported. + Page last(int total); + + /// Reference to the next page. Null if not supported or if current page is the last. + Page next(Page page, [int total]); + + /// Reference to the first page. Null if not supported or if current page is the first. + Page prev(Page page); + + /// No pagination. The server will not be able to produce pagination links. + static Pagination none() => _None(); + + /// Pages of fixed [size]. + static Pagination fixedSize(int size) => _FixedSize(size); +} + +class _None implements Pagination { + const _None(); + + @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; +} + +class _FixedSize implements Pagination { + final int size; + + _FixedSize(this.size) { + if (size < 1) throw ArgumentError(); + } + + @override + Page first() => _page(1); + + @override + Page last(int total) => _page((total - 1) ~/ size + 1); + + @override + Page next(Page page, [int total]) { + final number = _number(page); + if (total == null || number * size < total) { + return _page(number + 1); + } + return null; + } + + @override + Page prev(Page page) { + final number = _number(page); + if (number > 1) return _page(number - 1); + return null; + } + + @override + int limit(Page page) => size; + + @override + int offset(Page page) => size * (_number(page) - 1); + + int _number(Page page) => int.parse(page['number'] ?? '1'); + + Page _page(int number) => Page({'number': number.toString()}); +} diff --git a/lib/src/server/pagination/fixed_size_page.dart b/lib/src/server/pagination/fixed_size_page.dart deleted file mode 100644 index 6ea5717c..00000000 --- a/lib/src/server/pagination/fixed_size_page.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination/pagination_strategy.dart'; - -class FixedSizePage implements PaginationStrategy { - final int size; - - FixedSizePage(this.size) { - if (size < 1) throw ArgumentError(); - } - - @override - Page first() => _page(1); - - @override - Page last(int total) => _page((total - 1) ~/ size + 1); - - @override - Page next(Page page, [int total]) { - final number = _number(page); - if (total == null || number * size < total) { - return _page(number + 1); - } - return null; - } - - @override - Page prev(Page page) { - final number = _number(page); - if (number > 1) return _page(number - 1); - return null; - } - - @override - int limit(Page page) => size; - - @override - int offset(Page page) => size * (_number(page) - 1); - - int _number(Page page) => int.parse(page['number'] ?? '1'); - - Page _page(int number) => Page({'number': number.toString()}); -} diff --git a/lib/src/server/pagination/no_pagination.dart b/lib/src/server/pagination/no_pagination.dart deleted file mode 100644 index 28b8276e..00000000 --- a/lib/src/server/pagination/no_pagination.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination/pagination_strategy.dart'; - -class NoPagination implements PaginationStrategy { - 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/server/pagination/pagination_strategy.dart b/lib/src/server/pagination/pagination_strategy.dart deleted file mode 100644 index 1eaaf9ab..00000000 --- a/lib/src/server/pagination/pagination_strategy.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/query/page.dart'; - -/// Pagination strategy determines how pagination information is encoded in the -/// URL query parameters -abstract class PaginationStrategy { - /// Number of elements per page. Null for unlimited. - int limit(Page page); - - /// The page offset. - int offset(Page page); - - /// Link to the first page. Null if not supported. - Page first(); - - /// Reference to the last page. Null if not supported. - Page last(int total); - - /// Reference to the next page. Null if not supported or if current page is the last. - Page next(Page page, [int total]); - - /// Reference to the first page. Null if not supported or if current page is the first. - Page prev(Page page); -} diff --git a/lib/src/server/request/add_to_relationship.dart b/lib/src/server/request/add_to_relationship.dart deleted file mode 100644 index 867987eb..00000000 --- a/lib/src/server/request/add_to_relationship.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class AddToRelationship implements ControllerRequest { - final String type; - final String id; - final String relationship; - - AddToRelationship(this.type, this.id, this.relationship); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.addToRelationship(request, type, id, relationship, - ToMany.fromJson(jsonPayload).unwrap()); -} diff --git a/lib/src/server/request/controller_request.dart b/lib/src/server/request/controller_request.dart deleted file mode 100644 index b1c502e6..00000000 --- a/lib/src/server/request/controller_request.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -abstract class ControllerRequest { - /// Calls the appropriate method of [controller] - FutureOr call( - JsonApiController controller, Object jsonPayload, R request); -} diff --git a/lib/src/server/request/create_resource.dart b/lib/src/server/request/create_resource.dart deleted file mode 100644 index 9ee2b5d4..00000000 --- a/lib/src/server/request/create_resource.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class CreateResource implements ControllerRequest { - final String type; - - CreateResource(this.type); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.createResource( - request, type, ResourceData.fromJson(jsonPayload).unwrap()); -} diff --git a/lib/src/server/request/delete_from_relationship.dart b/lib/src/server/request/delete_from_relationship.dart deleted file mode 100644 index 47b9d916..00000000 --- a/lib/src/server/request/delete_from_relationship.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class DeleteFromRelationship implements ControllerRequest { - final String type; - final String id; - final String relationship; - - DeleteFromRelationship(this.type, this.id, this.relationship); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.deleteFromRelationship(request, type, id, relationship, - ToMany.fromJson(jsonPayload).unwrap()); -} diff --git a/lib/src/server/request/delete_resource.dart b/lib/src/server/request/delete_resource.dart deleted file mode 100644 index a46db6ed..00000000 --- a/lib/src/server/request/delete_resource.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class DeleteResource implements ControllerRequest { - final String type; - final String id; - - DeleteResource(this.type, this.id); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.deleteResource(request, type, id); -} diff --git a/lib/src/server/request/fetch_collection.dart b/lib/src/server/request/fetch_collection.dart deleted file mode 100644 index 1558ee92..00000000 --- a/lib/src/server/request/fetch_collection.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class FetchCollection implements ControllerRequest { - final String type; - - FetchCollection(this.type); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchCollection(request, type); -} diff --git a/lib/src/server/request/fetch_related.dart b/lib/src/server/request/fetch_related.dart deleted file mode 100644 index cb6cc841..00000000 --- a/lib/src/server/request/fetch_related.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class FetchRelated implements ControllerRequest { - final String type; - final String id; - final String relationship; - - FetchRelated(this.type, this.id, this.relationship); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchRelated(request, type, id, relationship); -} diff --git a/lib/src/server/request/fetch_relationship.dart b/lib/src/server/request/fetch_relationship.dart deleted file mode 100644 index 367d08bc..00000000 --- a/lib/src/server/request/fetch_relationship.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class FetchRelationship implements ControllerRequest { - final String type; - final String id; - final String relationship; - - FetchRelationship(this.type, this.id, this.relationship); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchRelationship(request, type, id, relationship); -} diff --git a/lib/src/server/request/fetch_resource.dart b/lib/src/server/request/fetch_resource.dart deleted file mode 100644 index b5f8fbf9..00000000 --- a/lib/src/server/request/fetch_resource.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class FetchResource implements ControllerRequest { - final String type; - final String id; - - FetchResource(this.type, this.id); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchResource(request, type, id); -} diff --git a/lib/src/server/request/invalid_request.dart b/lib/src/server/request/invalid_request.dart deleted file mode 100644 index 231dd840..00000000 --- a/lib/src/server/request/invalid_request.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class InvalidRequest implements ControllerRequest { - final String method; - - InvalidRequest(this.method); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } -} diff --git a/lib/src/server/request/update_relationship.dart b/lib/src/server/request/update_relationship.dart deleted file mode 100644 index 2372da3d..00000000 --- a/lib/src/server/request/update_relationship.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class UpdateRelationship implements ControllerRequest { - final String type; - final String id; - final String relationship; - - UpdateRelationship(this.type, this.id, this.relationship); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - final r = Relationship.fromJson(jsonPayload); - if (r is ToOne) { - return controller.replaceToOne( - request, type, id, relationship, r.unwrap()); - } - if (r is ToMany) { - return controller.replaceToMany( - request, type, id, relationship, r.unwrap()); - } - } -} diff --git a/lib/src/server/request/update_resource.dart b/lib/src/server/request/update_resource.dart deleted file mode 100644 index 232dbc18..00000000 --- a/lib/src/server/request/update_resource.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class UpdateResource implements ControllerRequest { - final String type; - final String id; - - UpdateResource(this.type, this.id); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.updateResource( - request, type, id, ResourceData.fromJson(jsonPayload).unwrap()); -} diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index 4eec028c..99765e92 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -1,20 +1,10 @@ import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination/no_pagination.dart'; -import 'package:json_api/src/server/pagination/pagination_strategy.dart'; +import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/uri_design.dart'; class ResponseDocumentFactory { - final Routing _routing; - final PaginationStrategy _pagination; - final Api _api; - - ResponseDocumentFactory(this._routing, - {Api api, PaginationStrategy pagination = const NoPagination()}) - : _api = api, - _pagination = pagination; - /// A document containing a list of errors Document makeErrorDocument(Iterable errors) => Document.error(errors, api: _api); @@ -59,7 +49,7 @@ class ResponseDocumentFactory { /// See https://jsonapi.org/format/#crud-creating-responses-201 Document makeCreatedResourceDocument(Resource resource) => makeResourceDocument( - _routing.resource.uri(resource.type, resource.id), resource); + _urlFactory.resourceUri(resource.type, resource.id), resource); /// A document containing a single related resource Document makeRelatedResourceDocument( @@ -82,7 +72,7 @@ class ResponseDocumentFactory { identifiers.map(IdentifierObject.fromIdentifier), links: { 'self': Link(self), - 'related': Link(_routing.related.uri(type, id, relationship)) + 'related': Link(_urlFactory.relatedUri(type, id, relationship)) }, ), api: _api); @@ -95,7 +85,7 @@ class ResponseDocumentFactory { nullable(IdentifierObject.fromIdentifier)(identifier), links: { 'self': Link(self), - 'related': Link(_routing.related.uri(type, id, relationship)) + 'related': Link(_urlFactory.relatedUri(type, id, relationship)) }, ), api: _api); @@ -104,6 +94,14 @@ class ResponseDocumentFactory { Document makeMetaDocument(Map meta) => Document.empty(meta, api: _api); + ResponseDocumentFactory(this._urlFactory, {Api api, Pagination pagination}) + : _api = api, + _pagination = pagination ?? Pagination.none(); + + final UriFactory _urlFactory; + final Pagination _pagination; + final Api _api; + ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { ...r.toOne.map((k, v) => MapEntry( @@ -111,8 +109,8 @@ class ResponseDocumentFactory { ToOne( nullable(IdentifierObject.fromIdentifier)(v), links: { - 'self': Link(_routing.relationship.uri(r.type, r.id, k)), - 'related': Link(_routing.related.uri(r.type, r.id, k)) + 'self': Link(_urlFactory.relationshipUri(r.type, r.id, k)), + 'related': Link(_urlFactory.relatedUri(r.type, r.id, k)) }, ))), ...r.toMany.map((k, v) => MapEntry( @@ -120,12 +118,12 @@ class ResponseDocumentFactory { ToMany( v.map(IdentifierObject.fromIdentifier), links: { - 'self': Link(_routing.relationship.uri(r.type, r.id, k)), - 'related': Link(_routing.related.uri(r.type, r.id, k)) + 'self': Link(_urlFactory.relationshipUri(r.type, r.id, k)), + 'related': Link(_urlFactory.relatedUri(r.type, r.id, k)) }, ))) }, links: { - 'self': Link(_routing.resource.uri(r.type, r.id)) + 'self': Link(_urlFactory.resourceUri(r.type, r.id)) }); Map _navigation(Uri uri, int total) { diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart new file mode 100644 index 00000000..0e231ff6 --- /dev/null +++ b/lib/src/server/target.dart @@ -0,0 +1,150 @@ +import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/uri_design.dart'; + +/// The target of a JSON:API request URI. The URI target and the request method +/// uniquely identify the meaning of the JSON:API request. +abstract class Target { + /// Returns the request corresponding to the request [method]. + JsonApiRequest getRequest(String method); + + /// Returns the target of the [url] according to the [design] + static Target of(Uri uri, UriDesign design) { + final builder = _Builder(); + design.matchTarget(uri, builder); + return builder.target ?? _Invalid(uri); + } +} + +class _Builder implements OnTargetMatch { + Target target; + + @override + void collection(String type) { + target = _Collection(type); + } + + @override + void resource(String type, String id) { + target = _Resource(type, id); + } + + @override + void related(String type, String id, String rel) { + target = _Related(type, id, rel); + } + + @override + void relationship(String type, String id, String rel) { + target = _Relationship(type, id, rel); + } +} + +/// The target of a URI referring a resource collection +class _Collection implements Target { + /// Resource type + final String type; + + const _Collection(this.type); + + @override + JsonApiRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return JsonApiRequest.fetchCollection(type); + case 'POST': + return JsonApiRequest.createResource(type); + default: + return JsonApiRequest.invalidRequest(method); + } + } +} + +/// The target of a URI referring to a single resource +class _Resource implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + const _Resource(this.type, this.id); + + @override + JsonApiRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return JsonApiRequest.fetchResource(type, id); + case 'DELETE': + return JsonApiRequest.deleteResource(type, id); + case 'PATCH': + return JsonApiRequest.updateResource(type, id); + default: + return JsonApiRequest.invalidRequest(method); + } + } +} + +/// The target of a URI referring a related resource or collection +class _Related implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const _Related(this.type, this.id, this.relationship); + + @override + JsonApiRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return JsonApiRequest.fetchRelated(type, id, relationship); + default: + return JsonApiRequest.invalidRequest(method); + } + } +} + +/// The target of a URI referring a relationship +class _Relationship implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const _Relationship(this.type, this.id, this.relationship); + + @override + JsonApiRequest getRequest(String method) { + switch (method.toUpperCase()) { + case 'GET': + return JsonApiRequest.fetchRelationship(type, id, relationship); + case 'PATCH': + return JsonApiRequest.updateRelationship(type, id, relationship); + case 'POST': + return JsonApiRequest.addToRelationship(type, id, relationship); + case 'DELETE': + return JsonApiRequest.deleteFromRelationship(type, id, relationship); + default: + return JsonApiRequest.invalidRequest(method); + } + } +} + +/// Request URI target which is not recognized by the URL Design. +class _Invalid implements Target { + final Uri uri; + + const _Invalid(this.uri); + + @override + JsonApiRequest getRequest(String method) => + JsonApiRequest.invalidRequest(method); +} diff --git a/lib/src/server/target/collection_target.dart b/lib/src/server/target/collection_target.dart deleted file mode 100644 index e59d8e77..00000000 --- a/lib/src/server/target/collection_target.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/request/create_resource.dart'; -import 'package:json_api/src/server/request/fetch_collection.dart'; -import 'package:json_api/src/server/request/invalid_request.dart'; -import 'package:json_api/src/server/target/target.dart'; - -/// The target of a URI referring a resource collection -class CollectionTarget implements Target { - /// Resource type - final String type; - - const CollectionTarget(this.type); - - @override - ControllerRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return FetchCollection(type); - case 'POST': - return CreateResource(type); - default: - return InvalidRequest(method); - } - } -} diff --git a/lib/src/server/target/invalid_target.dart b/lib/src/server/target/invalid_target.dart deleted file mode 100644 index 0368c4c5..00000000 --- a/lib/src/server/target/invalid_target.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/request/invalid_request.dart'; -import 'package:json_api/src/server/target/target.dart'; - -/// Request URI target which is not recognized by the URL Design. -class InvalidTarget implements Target { - final Uri uri; - - @override - const InvalidTarget(this.uri); - - @override - ControllerRequest getRequest(String method) => InvalidRequest(method); -} diff --git a/lib/src/server/target/related_target.dart b/lib/src/server/target/related_target.dart deleted file mode 100644 index eb57d0f5..00000000 --- a/lib/src/server/target/related_target.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/request/fetch_related.dart'; -import 'package:json_api/src/server/request/invalid_request.dart'; -import 'package:json_api/src/server/target/target.dart'; - -/// The target of a URI referring a related resource or collection -class RelatedTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelatedTarget(this.type, this.id, this.relationship); - - @override - ControllerRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return FetchRelated(type, id, relationship); - default: - return InvalidRequest(method); - } - } -} diff --git a/lib/src/server/target/relationship_target.dart b/lib/src/server/target/relationship_target.dart deleted file mode 100644 index 84b15cfb..00000000 --- a/lib/src/server/target/relationship_target.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_api/src/server/request/add_to_relationship.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/request/delete_from_relationship.dart'; -import 'package:json_api/src/server/request/fetch_relationship.dart'; -import 'package:json_api/src/server/request/invalid_request.dart'; -import 'package:json_api/src/server/request/update_relationship.dart'; -import 'package:json_api/src/server/target/target.dart'; - -/// The target of a URI referring a relationship -class RelationshipTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelationshipTarget(this.type, this.id, this.relationship); - - @override - ControllerRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return FetchRelationship(type, id, relationship); - case 'PATCH': - return UpdateRelationship(type, id, relationship); - case 'POST': - return AddToRelationship(type, id, relationship); - case 'DELETE': - return DeleteFromRelationship(type, id, relationship); - default: - return InvalidRequest(method); - } - } -} diff --git a/lib/src/server/target/resource_target.dart b/lib/src/server/target/resource_target.dart deleted file mode 100644 index 47f0c15f..00000000 --- a/lib/src/server/target/resource_target.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/request/delete_resource.dart'; -import 'package:json_api/src/server/request/fetch_resource.dart'; -import 'package:json_api/src/server/request/invalid_request.dart'; -import 'package:json_api/src/server/request/update_resource.dart'; -import 'package:json_api/src/server/target/target.dart'; - -/// The target of a URI referring to a single resource -class ResourceTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - const ResourceTarget(this.type, this.id); - - @override - ControllerRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return FetchResource(type, id); - case 'DELETE': - return DeleteResource(type, id); - case 'PATCH': - return UpdateResource(type, id); - default: - return InvalidRequest(method); - } - } -} diff --git a/lib/src/server/target/target.dart b/lib/src/server/target/target.dart deleted file mode 100644 index 984a9970..00000000 --- a/lib/src/server/target/target.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/request/controller_request.dart'; -import 'package:json_api/src/server/target/collection_target.dart'; -import 'package:json_api/src/server/target/invalid_target.dart'; - -/// The target of a JSON:API request URI. The URI target and the request method -/// uniquely identify the meaning of the JSON:API request. -abstract class Target { - /// Returns the request corresponding to the request [method]. - ControllerRequest getRequest(String method); - - static Target match(Uri uri, Routing routing) { - Target target = InvalidTarget(uri); - final collection = routing.collection.match(uri); - if( collection != null) { - return CollectionTarget(collection.type); - } - if (routing.collection - .match(uri, (type) => target = CollectionTarget(type))) { - return target; - } - } -} diff --git a/lib/uri_design.dart b/lib/uri_design.dart new file mode 100644 index 00000000..6bb153b2 --- /dev/null +++ b/lib/uri_design.dart @@ -0,0 +1,95 @@ +/// URI Design describes how the endpoints are organized. +abstract class UriDesign implements TargetMatcher, UriFactory { + /// Returns the URI design recommended by the JSON:API standard. + /// @see https://jsonapi.org/recommendations/#urls + static UriDesign standard(Uri base) => _Standard(base); +} + +/// Makes URIs for specific targets +abstract class UriFactory { + /// Returns a URL for the primary resource collection of type [type] + Uri collectionUri(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 relatedUri(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 relationshipUri(String type, String id, String relationship); + + /// Returns a URL for the primary resource of type [type] with id [id] + Uri resourceUri(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 [onTargetMatch] will be called with the target parameters + void matchTarget(Uri uri, OnTargetMatch onTargetMatch); +} + +abstract class OnTargetMatch { + /// Called when a URI targets a collection. + void collection(String type); + + /// Called when a URI targets a resource. + void resource(String type, String id); + + /// Called when a URI targets a related resource or collection. + void related(String type, String id, String relationship); + + /// Called when a URI targets a relationship. + void relationship(String type, String id, String relationship); +} + +class _Standard implements UriDesign { + /// Returns a URL for the primary resource collection of type [type] + @override + Uri collectionUri(String type) => _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. + @override + Uri relatedUri(String type, String id, String relationship) => + _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. + @override + Uri relationshipUri(String type, String id, String relationship) => + _appendToBase([type, id, _relationships, relationship]); + + /// Returns a URL for the primary resource of type [type] with id [id] + @override + Uri resourceUri(String type, String id) => _appendToBase([type, id]); + + @override + void matchTarget(Uri uri, OnTargetMatch match) { + if (!uri.toString().startsWith(_base.toString())) return; + final s = uri.pathSegments.sublist(_base.pathSegments.length); + if (s.length == 1) { + match.collection(s[0]); + } else if (s.length == 2) { + match.resource(s[0], s[1]); + } else if (s.length == 3) { + match.related(s[0], s[1], s[2]); + } else if (s.length == 4 && s[2] == _relationships) { + match.relationship(s[0], s[1], s[3]); + } + } + + _Standard(this._base); + + static const _relationships = 'relationships'; + + /// The base to be added the the generated URIs + final Uri _base; + + Uri _appendToBase(List segments) => + _base.replace(pathSegments: _base.pathSegments + segments); +} diff --git a/test/functional/crud_test.dart b/test/functional/crud_test.dart index 45ac7887..d16bacfe 100644 --- a/test/functional/crud_test.dart +++ b/test/functional/crud_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/url_design.dart'; +import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; @@ -14,11 +14,11 @@ import '../../example/server/shelf_request_response_converter.dart'; /// Basic CRUD operations void main() async { HttpServer server; - UrlAwareClient client; + UriAwareClient client; final host = 'localhost'; final port = 8081; final design = - PathBasedUrlDesign(Uri(scheme: 'http', host: host, port: port)); + UriDesign.standard(Uri(scheme: 'http', host: host, port: port)); final people = [ 'Erich Gamma', 'Richard Helm', @@ -39,9 +39,12 @@ void main() async { toMany: {'authors': people.map(Identifier.of).toList()}); setUp(() async { - client = UrlAwareClient(design); + client = UriAwareClient(design); final handler = Handler( - ShelfRequestResponseConverter(), CRUDController(Uuid().v4), design); + ShelfRequestResponseConverter(), + CRUDController( + Uuid().v4, const ['people', 'books', 'companies'].contains), + design); server = await serve(handler, host, port); diff --git a/test/unit/server/numbered_page_test.dart b/test/unit/server/numbered_page_test.dart index 85e06974..5a6a3eaf 100644 --- a/test/unit/server/numbered_page_test.dart +++ b/test/unit/server/numbered_page_test.dart @@ -1,27 +1,27 @@ import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination/fixed_size_page.dart'; +import 'package:json_api/src/server/pagination.dart'; import 'package:test/test.dart'; void main() { test('page size must be posititve', () { - expect(() => FixedSizePage(0), throwsArgumentError); + expect(() => Pagination.fixedSize(0), throwsArgumentError); }); test('no pages after last', () { final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); + final pagination = Pagination.fixedSize(3); expect(pagination.next(page, 10), isNull); }); test('no pages before first', () { final page = Page({'number': '1'}); - final pagination = FixedSizePage(3); + final pagination = Pagination.fixedSize(3); expect(pagination.prev(page), isNull); }); test('pagination', () { final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); + final pagination = Pagination.fixedSize(3); expect(pagination.prev(page)['number'], '3'); expect(pagination.next(page, 100)['number'], '5'); expect(pagination.first()['number'], '1'); From 085697928cf594f5ca05c17bd4a5469a334d0447 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 20 Jan 2020 01:39:25 -0800 Subject: [PATCH 07/99] Pagination --- .gitignore | 3 +- example/server/colors.dart | 24 ++++++ example/server/crud_controller.dart | 7 +- example/server/paginating_controller.dart | 24 ++++++ lib/server.dart | 1 + lib/src/document/api.dart | 2 +- lib/src/document/decoding_exception.dart | 9 ++- lib/src/document/document.dart | 2 +- lib/src/document/identifier_object.dart | 2 +- lib/src/document/json_api_error.dart | 2 +- lib/src/document/link.dart | 6 +- lib/src/document/relationship.dart | 8 +- lib/src/document/resource_data.dart | 2 +- lib/src/document/resource_object.dart | 2 +- lib/src/server/http_handler.dart | 13 +++- lib/src/server/json_api_controller_base.dart | 76 +++++++++++++++++++ pubspec.yaml | 1 + test/functional/crud_test.dart | 9 ++- test/functional/pagination_test.dart | 60 +++++++++++++++ test/unit/document/api_test.dart | 6 ++ .../document/decoding_exception_test.dart | 10 +++ test/unit/document/resource_test.dart | 5 ++ 22 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 example/server/colors.dart create mode 100644 example/server/paginating_controller.dart create mode 100644 lib/src/server/json_api_controller_base.dart create mode 100644 test/functional/pagination_test.dart create mode 100644 test/unit/document/decoding_exception_test.dart diff --git a/.gitignore b/.gitignore index 363fa3b8..9b5d7b92 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ doc/api/ # Generated by test_coverage test/.test_coverage.dart -coverage/ \ No newline at end of file +coverage/ +/coverage_badge.svg diff --git a/example/server/colors.dart b/example/server/colors.dart new file mode 100644 index 00000000..2acad4d2 --- /dev/null +++ b/example/server/colors.dart @@ -0,0 +1,24 @@ +import 'package:json_api/document.dart'; +import 'package:uuid/uuid.dart'; + +final Map colors = Map.fromIterable( + const [ + ['black', '000000'], + ['silver', 'c0c0c0'], + ['gray', '808080'], + ['white', 'ffffff'], + ['maroon', '800000'], + ['red', 'ff0000'], + ['purple', '800080'], + ['fuchsia', 'ff00ff'], + ['green', '008000'], + ['lime', '00ff00'], + ['olive', '808000'], + ['yellow', 'ffff00'], + ['navy', '000080'], + ['blue', '0000ff'], + ['teal', '008080'], + ['aqua', '00ffff'], + ].map((c) => Resource('colors', Uuid().v4(), + attributes: {'name': c[0], 'rgb': c[1]})), + key: (r) => r.id); diff --git a/example/server/crud_controller.dart b/example/server/crud_controller.dart index 865855e1..c3fa775d 100644 --- a/example/server/crud_controller.dart +++ b/example/server/crud_controller.dart @@ -4,8 +4,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; import 'package:shelf/shelf.dart' as shelf; -typedef Criteria = bool Function(String type); - /// This is an example controller allowing simple CRUD operations on resources. class CRUDController implements JsonApiController { /// Generates a new GUID @@ -173,6 +171,11 @@ class CRUDController implements JsonApiController { } Map _repo(String type) { + if (!isTypeSupported(type)) { + throw JsonApiResponse.notFound( + [JsonApiError(detail: 'Collection not found')]); + } + _store.putIfAbsent(type, () => {}); return _store[type]; } diff --git a/example/server/paginating_controller.dart b/example/server/paginating_controller.dart new file mode 100644 index 00000000..51c7af6e --- /dev/null +++ b/example/server/paginating_controller.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:json_api/query.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/pagination.dart'; +import 'package:shelf/shelf.dart' as shelf; + +import 'colors.dart'; + +class PaginatingController extends JsonApiControllerBase { + final Pagination _pagination; + + PaginatingController(this._pagination); + + @override + FutureOr fetchCollection( + shelf.Request request, String type) { + final page = Page.fromUri(request.requestedUri); + final offset = _pagination.offset(page); + final limit = _pagination.limit(page); + return JsonApiResponse.collection(colors.values.skip(offset).take(limit), + total: colors.length); + } +} diff --git a/lib/server.dart b/lib/server.dart index 2472a20e..85c7bd0c 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,6 +5,7 @@ library server; export 'package:json_api/src/server/http_handler.dart'; export 'package:json_api/src/server/json_api_controller.dart'; +export 'package:json_api/src/server/json_api_controller_base.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/response_document_factory.dart'; export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index 3a4fcf50..9d3cfb23 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -15,7 +15,7 @@ class Api { if (json is Map) { return Api(version: json['version'], meta: json['meta']); } - throw DecodingException('Can not decode JsonApi from $json'); + throw DecodingException(json); } Map toJson() => { diff --git a/lib/src/document/decoding_exception.dart b/lib/src/document/decoding_exception.dart index 1d562fc2..6d3c8955 100644 --- a/lib/src/document/decoding_exception.dart +++ b/lib/src/document/decoding_exception.dart @@ -1,6 +1,9 @@ /// Indicates an error happened while converting JSON data into a JSON:API object. -class DecodingException implements Exception { - final String message; +class DecodingException implements Exception { + final Object json; - DecodingException(this.message); + DecodingException(this.json); + + @override + String toString() => 'Can not decode $T from JSON: $json'; } diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 05bbfc41..5aa03ccb 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -58,7 +58,7 @@ class Document { return Document.empty(json['meta'], api: api); } } - throw DecodingException('Can not decode Document from $json'); + throw DecodingException(json); } Map toJson() => { diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index 24ce55fd..129dd180 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -29,7 +29,7 @@ class IdentifierObject { if (json is Map) { return IdentifierObject(json['type'], json['id'], meta: json['meta']); } - throw DecodingException('Can not decode IdentifierObject from $json'); + throw DecodingException(json); } Identifier unwrap() => Identifier(type, id); diff --git a/lib/src/document/json_api_error.dart b/lib/src/document/json_api_error.dart index 6fa89fe5..b9408c75 100644 --- a/lib/src/document/json_api_error.dart +++ b/lib/src/document/json_api_error.dart @@ -88,7 +88,7 @@ class JsonApiError { meta: json['meta'], links: (links == null) ? null : Link.mapFromJson(links)); } - throw DecodingException('Can not decode ErrorObject from $json'); + throw DecodingException(json); } Map toJson() { diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 4fd20e5c..4c41b094 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -13,7 +13,7 @@ class Link { static Link fromJson(Object json) { if (json is String) return Link(Uri.parse(json)); if (json is Map) return LinkObject.fromJson(json); - throw DecodingException('Can not decode Link from $json'); + throw DecodingException(json); } /// Reconstructs the document's `links` member into a map. @@ -23,7 +23,7 @@ class Link { return ({...json}..removeWhere((_, v) => v == null)) .map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); } - throw DecodingException('Can not decode links map from $json'); + throw DecodingException>(json); } Object toJson() => uri.toString(); @@ -46,7 +46,7 @@ class LinkObject extends Link { return LinkObject(Uri.parse(href), meta: json['meta']); } } - throw DecodingException('Can not decode LinkObject from $json'); + throw DecodingException(json); } @override diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index c0a0d4ef..c67435c6 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -37,7 +37,7 @@ class Relationship extends PrimaryData { return Relationship( links: (links == null) ? null : Link.mapFromJson(links)); } - throw DecodingException('Can not decode Relationship from $json'); + throw DecodingException(json); } /// Parses the `relationships` member of a Resource Object @@ -46,7 +46,7 @@ class Relationship extends PrimaryData { return json .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); } - throw DecodingException('Can not decode Relationship map from $json'); + throw DecodingException>(json); } /// Top-level JSON object @@ -83,7 +83,7 @@ class ToOne extends Relationship { included: included is List ? ResourceObject.fromJsonList(included) : null); } - throw DecodingException('Can not decode ToOne from $json'); + throw DecodingException(json); } @override @@ -126,7 +126,7 @@ class ToMany extends Relationship { ); } } - throw DecodingException('Can not decode ToMany from $json'); + throw DecodingException(json); } @override diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 2fc58170..ca80b04f 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -26,7 +26,7 @@ class ResourceData extends PrimaryData { links: Link.mapFromJson(json['links'] ?? {}), included: resources.isNotEmpty ? resources : null); } - throw DecodingException('Can not decode SingleResourceObject from $json'); + throw DecodingException(json); } @override diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 90e4b498..931f1d15 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -52,7 +52,7 @@ class ResourceObject { meta: json['meta']); } } - throw DecodingException('Can not decode ResourceObject from $json'); + throw DecodingException(json); } static List fromJsonList(Iterable json) => diff --git a/lib/src/server/http_handler.dart b/lib/src/server/http_handler.dart index ff9e934e..a687aebc 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/http_handler.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/pagination.dart'; @@ -17,8 +18,13 @@ class Handler { final requestDoc = requestBody.isEmpty ? null : json.decode(requestBody); final requestTarget = Target.of(uri, _design); final jsonApiRequest = requestTarget.getRequest(method); - final jsonApiResponse = - await jsonApiRequest.call(_controller, requestDoc, request); + JsonApiResponse jsonApiResponse; + try { + jsonApiResponse = + await jsonApiRequest.call(_controller, requestDoc, request); + } on JsonApiResponse catch (error) { + jsonApiResponse = error; + } final statusCode = jsonApiResponse.statusCode; final headers = jsonApiResponse.buildHeaders(_design); final responseDocument = jsonApiResponse.buildDocument(_docFactory, uri); @@ -29,7 +35,8 @@ class Handler { /// Creates an instance of the handler. Handler(this._http, this._controller, this._design, {Pagination pagination}) : _docFactory = ResponseDocumentFactory(_design, - pagination: pagination ?? Pagination.none()); + pagination: pagination ?? Pagination.none(), + api: Api(version: '1.0')); final HttpAdapter _http; final JsonApiController _controller; final UriDesign _design; diff --git a/lib/src/server/json_api_controller_base.dart b/lib/src/server/json_api_controller_base.dart new file mode 100644 index 00000000..a54e5e90 --- /dev/null +++ b/lib/src/server/json_api_controller_base.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; + +class JsonApiControllerBase implements JsonApiController { + @override + FutureOr addToRelationship(R request, String type, String id, + String relationship, Iterable identifiers) { + throw _forbidden; + } + + @override + FutureOr createResource( + request, String type, Resource resource) { + throw _forbidden; + } + + @override + FutureOr deleteFromRelationship(R request, String type, + String id, String relationship, Iterable identifiers) { + throw _forbidden; + } + + @override + FutureOr deleteResource(R request, String type, String id) { + throw _forbidden; + } + + @override + FutureOr fetchCollection(R request, String type) { + throw _forbidden; + } + + @override + FutureOr fetchRelated( + request, String type, String id, String relationship) { + throw _forbidden; + } + + @override + FutureOr fetchRelationship( + request, String type, String id, String relationship) { + throw _forbidden; + } + + @override + FutureOr fetchResource(R request, String type, String id) { + throw _forbidden; + } + + @override + FutureOr replaceToMany(R request, String type, String id, + String relationship, Iterable identifiers) { + throw _forbidden; + } + + @override + FutureOr replaceToOne(R request, String type, String id, + String relationship, Identifier identifier) { + throw _forbidden; + } + + @override + FutureOr updateResource( + request, String type, String id, Resource resource) { + throw _forbidden; + } + + final _forbidden = JsonApiResponse.forbidden([ + JsonApiError( + status: '403', + detail: 'This request is not supported by the server', + title: '403 Forbidden') + ]); +} diff --git a/pubspec.yaml b/pubspec.yaml index aa2fc5bb..9b84cd27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: dependencies: http: ^0.12.0 dev_dependencies: + args: ^1.5.2 pedantic: ^1.9.0 test: ^1.9.2 json_matcher: ^0.2.3 diff --git a/test/functional/crud_test.dart b/test/functional/crud_test.dart index d16bacfe..7e488abb 100644 --- a/test/functional/crud_test.dart +++ b/test/functional/crud_test.dart @@ -73,7 +73,7 @@ void main() async { }); test('a non-existing primary resource', () async { - final r = await client.fetchResource('unicorns', '1'); + final r = await client.fetchResource('books', '1'); expect(r.status, 404); expect(r.isSuccessful, isFalse); expect(r.document.errors.first.detail, 'Resource not found'); @@ -88,6 +88,13 @@ void main() async { expect(r.data.unwrap().first.attributes['lastName'], 'Gamma'); }); + test('a non-existing primary collection', () async { + final r = await client.fetchCollection('unicorns'); + expect(r.status, 404); + expect(r.isSuccessful, isFalse); + expect(r.document.errors.first.detail, 'Collection not found'); + }); + test('a related resource', () async { final r = await client.fetchRelatedResource(book.type, book.id, 'publisher'); diff --git a/test/functional/pagination_test.dart b/test/functional/pagination_test.dart new file mode 100644 index 00000000..93a11f61 --- /dev/null +++ b/test/functional/pagination_test.dart @@ -0,0 +1,60 @@ +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:test/test.dart'; + +import '../../example/server/paginating_controller.dart'; +import '../../example/server/shelf_request_response_converter.dart'; + +/// Pagination +void main() async { + HttpServer server; + JsonApiClient client; + final host = 'localhost'; + final port = 8082; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + client = JsonApiClient(); + final pagination = Pagination.fixedSize(5); + final handler = Handler(ShelfRequestResponseConverter(), + PaginatingController(pagination), design, + pagination: pagination); + + server = await serve(handler, host, port); + }); + + tearDown(() async { + client.close(); + await server.close(); + }); + + group('Paginating', () { + test('a primary collection', () async { + final r0 = + await client.fetchCollection(base.replace(pathSegments: ['colors'])); + expect(r0.data.unwrap().length, 5); + expect(r0.data.unwrap().first.attributes['name'], 'black'); + expect(r0.data.unwrap().last.attributes['name'], 'maroon'); + + final r1 = await client.fetchCollection(r0.data.next.uri); + expect(r1.data.unwrap().length, 5); + expect(r1.data.unwrap().first.attributes['name'], 'red'); + expect(r1.data.unwrap().last.attributes['name'], 'lime'); + + final r2 = await client.fetchCollection(r0.data.last.uri); + expect(r2.data.unwrap().length, 1); + expect(r2.data.unwrap().first.attributes['name'], 'aqua'); + + final r3 = await client.fetchCollection(r2.data.prev.uri); + expect(r3.data.unwrap().length, 5); + expect(r3.data.unwrap().first.attributes['name'], 'olive'); + expect(r3.data.unwrap().last.attributes['name'], 'teal'); + }); + }, testOn: 'vm'); +} diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart index 25330ed1..368e1d40 100644 --- a/test/unit/document/api_test.dart +++ b/test/unit/document/api_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; +import 'package:json_api/src/document/decoding_exception.dart'; import 'package:json_matcher/json_matcher.dart'; import 'package:test/test.dart'; @@ -12,6 +13,11 @@ void main() { expect('bar', api.meta['foo']); }); + test('Throws exception when can not be decoded', () { + expect( + () => Api.fromJson([]), throwsA(TypeMatcher>())); + }); + test('Empty/null properties are not encoded', () { expect(Api(), encodesToJson({})); }); diff --git a/test/unit/document/decoding_exception_test.dart b/test/unit/document/decoding_exception_test.dart new file mode 100644 index 00000000..fe394be9 --- /dev/null +++ b/test/unit/document/decoding_exception_test.dart @@ -0,0 +1,10 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:test/test.dart'; + +void main() { + test('DecpdingException.toString()', () { + expect(DecodingException([]).toString(), + 'Can not decode Api from JSON: []'); + }); +} diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index d0bb1d5c..f8648f03 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -16,4 +16,9 @@ void main() { expect(Resource('apples', '123').key, 'apples:123'); expect(Resource('apples', null).key, 'apples:null'); }); + + test('toString', () { + expect(Resource('appless', '42', attributes: {'color': 'red'}).toString(), + 'Resource(appless:42 {color: red})'); + }); } From 9848b6b667f2bcf172cd2da5bb0cf301ba76a6fe Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 21 Jan 2020 21:38:28 -0800 Subject: [PATCH 08/99] wip --- example/server/{ => controller}/colors.dart | 0 .../{ => controller}/crud_controller.dart | 0 .../paginating_controller.dart | 0 .../server/controller/sorting_controller.dart | 32 ++++++ example/server/server.dart | 10 +- lib/server.dart | 2 +- lib/src/document/api.dart | 6 +- lib/src/document/decoding_exception.dart | 9 -- lib/src/document/document.dart | 10 +- lib/src/document/document_exception.dart | 15 +++ lib/src/document/identifier.dart | 5 +- lib/src/document/identifier_object.dart | 4 +- lib/src/document/json_api_error.dart | 4 +- lib/src/document/link.dart | 9 +- lib/src/document/relationship.dart | 13 ++- lib/src/document/resource.dart | 3 +- .../document/resource_collection_data.dart | 6 +- lib/src/document/resource_data.dart | 5 +- lib/src/document/resource_object.dart | 4 +- lib/src/query/query_parameters.dart | 6 +- lib/src/query/sort.dart | 52 ++++------ lib/src/server/json_api_controller_base.dart | 2 +- lib/src/server/json_api_request.dart | 45 +++++---- ...http_handler.dart => request_handler.dart} | 41 ++++++-- test/functional/crud_test.dart | 20 +++- test/functional/pagination_test.dart | 4 +- test/functional/sorting_test.dart | 82 ++++++++++++++++ test/unit/document/api_test.dart | 5 +- .../document/decoding_exception_test.dart | 10 -- test/unit/server/request_handler_test.dart | 97 +++++++++++++++++++ 30 files changed, 373 insertions(+), 128 deletions(-) rename example/server/{ => controller}/colors.dart (100%) rename example/server/{ => controller}/crud_controller.dart (100%) rename example/server/{ => controller}/paginating_controller.dart (100%) create mode 100644 example/server/controller/sorting_controller.dart delete mode 100644 lib/src/document/decoding_exception.dart create mode 100644 lib/src/document/document_exception.dart rename lib/src/server/{http_handler.dart => request_handler.dart} (54%) create mode 100644 test/functional/sorting_test.dart delete mode 100644 test/unit/document/decoding_exception_test.dart create mode 100644 test/unit/server/request_handler_test.dart diff --git a/example/server/colors.dart b/example/server/controller/colors.dart similarity index 100% rename from example/server/colors.dart rename to example/server/controller/colors.dart diff --git a/example/server/crud_controller.dart b/example/server/controller/crud_controller.dart similarity index 100% rename from example/server/crud_controller.dart rename to example/server/controller/crud_controller.dart diff --git a/example/server/paginating_controller.dart b/example/server/controller/paginating_controller.dart similarity index 100% rename from example/server/paginating_controller.dart rename to example/server/controller/paginating_controller.dart diff --git a/example/server/controller/sorting_controller.dart b/example/server/controller/sorting_controller.dart new file mode 100644 index 00000000..424d1b8b --- /dev/null +++ b/example/server/controller/sorting_controller.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/server.dart'; +import 'package:shelf/shelf.dart' as shelf; +import 'package:uuid/uuid.dart'; + +class SortingController extends JsonApiControllerBase { + @override + FutureOr fetchCollection( + shelf.Request request, String type) { + final sort = Sort.fromUri(request.requestedUri); + final namesSorted = [...names]; + sort.toList().reversed.forEach((field) { + namesSorted.sort((a, b) { + final attrA = a.attributes[field.name].toString(); + final attrB = b.attributes[field.name].toString(); + if (attrA == attrB) return 0; + return attrA.compareTo(attrB) * field.comparisonFactor; + }); + }); + return JsonApiResponse.collection(namesSorted); + } +} + +final firstNames = const ['Emma', 'Liam', 'Olivia', 'Noah']; +final lastNames = const ['Smith', 'Johnson', 'Williams', 'Brown']; +final names = firstNames + .map((first) => lastNames.map((last) => Resource('names', Uuid().v4(), + attributes: {'firstName': first, 'lastName': last}))) + .expand((_) => _); diff --git a/example/server/server.dart b/example/server/server.dart index f6cbda90..b984e3da 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -1,12 +1,12 @@ import 'dart:io'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/http_handler.dart'; +import 'package:json_api/src/server/request_handler.dart'; import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:uuid/uuid.dart'; -import 'crud_controller.dart'; +import 'controller/crud_controller.dart'; import 'shelf_request_response_converter.dart'; /// This example shows how to build a simple CRUD server on top of Dart Shelf @@ -14,8 +14,10 @@ void main() async { final host = 'localhost'; final port = 8080; final baseUri = Uri(scheme: 'http', host: host, port: port); - final jsonApiHandler = Handler(ShelfRequestResponseConverter(), - CRUDController(Uuid().v4, (_) => true), UriDesign.standard(baseUri)); + /// You may also try PaginatingController + final controller = CRUDController(Uuid().v4, (_) => true); + final jsonApiHandler = RequestHandler( + ShelfRequestResponseConverter(), controller, UriDesign.standard(baseUri)); await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); print('Serving at $baseUri'); diff --git a/lib/server.dart b/lib/server.dart index 85c7bd0c..88892a75 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,7 +3,7 @@ /// The server API is not stable. Expect breaking changes. library server; -export 'package:json_api/src/server/http_handler.dart'; +export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/json_api_controller.dart'; export 'package:json_api/src/server/json_api_controller_base.dart'; export 'package:json_api/src/server/json_api_response.dart'; diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index 9d3cfb23..979d992b 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -1,7 +1,9 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object class Api { + static const memberName = 'jsonapi'; + /// The JSON:API version. May be null. final String version; @@ -15,7 +17,7 @@ class Api { if (json is Map) { return Api(version: json['version'], meta: json['meta']); } - throw DecodingException(json); + throw DocumentException('The `$memberName` member must be a JSON object'); } Map toJson() => { diff --git a/lib/src/document/decoding_exception.dart b/lib/src/document/decoding_exception.dart deleted file mode 100644 index 6d3c8955..00000000 --- a/lib/src/document/decoding_exception.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// Indicates an error happened while converting JSON data into a JSON:API object. -class DecodingException implements Exception { - final Object json; - - DecodingException(this.json); - - @override - String toString() => 'Can not decode $T from JSON: $json'; -} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 5aa03ccb..8781d969 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -1,5 +1,5 @@ import 'package:json_api/src/document/api.dart'; -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/json_api_error.dart'; import 'package:json_api/src/document/primary_data.dart'; @@ -35,7 +35,7 @@ class Document { : data = null, meta = (meta == null) ? null : Map.unmodifiable(meta), errors = null { - ArgumentError.checkNotNull(meta, 'meta'); + DocumentException.throwIfNull(meta, 'The `meta` member must not be null'); } /// Reconstructs a document with the specified primary data @@ -43,8 +43,8 @@ class Document { Object json, Data Function(Object json) primaryData) { if (json is Map) { Api api; - if (json.containsKey('jsonapi')) { - api = Api.fromJson(json['jsonapi']); + if (json.containsKey(Api.memberName)) { + api = Api.fromJson(json[Api.memberName]); } if (json.containsKey('errors')) { final errors = json['errors']; @@ -58,7 +58,7 @@ class Document { return Document.empty(json['meta'], api: api); } } - throw DecodingException(json); + throw DocumentException('A JSON:API document must be a JSON object'); } Map toJson() => { diff --git a/lib/src/document/document_exception.dart b/lib/src/document/document_exception.dart new file mode 100644 index 00000000..917e2f2d --- /dev/null +++ b/lib/src/document/document_exception.dart @@ -0,0 +1,15 @@ +/// Indicates a violation of JSON:API Document structure or data. +class DocumentException implements Exception { + /// Human-readable text explaining the issue.. + final String message; + + @override + String toString() => message; + + DocumentException(this.message); + + /// Throws a [DocumentException] with the [message] if [value] is null. + static void throwIfNull(Object value, String message) { + if (value == null) throw DocumentException(message); + } +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 001f0116..5fa0b426 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,4 +1,5 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/document/document_exception.dart'; /// Resource identifier /// @@ -14,8 +15,8 @@ class Identifier { /// Neither [type] nor [id] can be null or empty. Identifier(this.type, this.id) { - ArgumentError.checkNotNull(id, 'id'); - ArgumentError.checkNotNull(type, 'type'); + DocumentException.throwIfNull(id, 'Identifier `id` must not be null'); + DocumentException.throwIfNull(type, 'Identifier `type` must not be null'); } static Identifier of(Resource resource) => diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index 129dd180..a48f1195 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; /// [IdentifierObject] is a JSON representation of the [Identifier]. @@ -29,7 +29,7 @@ class IdentifierObject { if (json is Map) { return IdentifierObject(json['type'], json['id'], meta: json['meta']); } - throw DecodingException(json); + throw DocumentException('A JSON:API identifier must be a JSON object'); } Identifier unwrap() => Identifier(type, id); diff --git a/lib/src/document/json_api_error.dart b/lib/src/document/json_api_error.dart index b9408c75..24b39d10 100644 --- a/lib/src/document/json_api_error.dart +++ b/lib/src/document/json_api_error.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; /// [JsonApiError] represents an error occurred on the server. @@ -88,7 +88,7 @@ class JsonApiError { meta: json['meta'], links: (links == null) ? null : Link.mapFromJson(links)); } - throw DecodingException(json); + throw DocumentException('A JSON:API error must be a JSON object'); } Map toJson() { diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 4c41b094..6926aa2f 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; /// A JSON:API link /// https://jsonapi.org/format/#document-links @@ -13,7 +13,8 @@ class Link { static Link fromJson(Object json) { if (json is String) return Link(Uri.parse(json)); if (json is Map) return LinkObject.fromJson(json); - throw DecodingException(json); + throw DocumentException( + 'A JSON:API link must be a JSON string or a JSON object'); } /// Reconstructs the document's `links` member into a map. @@ -23,7 +24,7 @@ class Link { return ({...json}..removeWhere((_, v) => v == null)) .map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); } - throw DecodingException>(json); + throw DocumentException('A JSON:API links object must be a JSON object'); } Object toJson() => uri.toString(); @@ -46,7 +47,7 @@ class LinkObject extends Link { return LinkObject(Uri.parse(href), meta: json['meta']); } } - throw DecodingException(json); + throw DocumentException('A JSON:API link object must be a JSON object'); } @override diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index c67435c6..d5ed55d4 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/identifier_object.dart'; import 'package:json_api/src/document/link.dart'; @@ -37,7 +37,8 @@ class Relationship extends PrimaryData { return Relationship( links: (links == null) ? null : Link.mapFromJson(links)); } - throw DecodingException(json); + throw DocumentException( + 'A JSON:API relationship object must be a JSON object'); } /// Parses the `relationships` member of a Resource Object @@ -46,7 +47,7 @@ class Relationship extends PrimaryData { return json .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); } - throw DecodingException>(json); + throw DocumentException('The `relationships` member must be a JSON object'); } /// Top-level JSON object @@ -83,7 +84,8 @@ class ToOne extends Relationship { included: included is List ? ResourceObject.fromJsonList(included) : null); } - throw DecodingException(json); + throw DocumentException( + 'A to-one relationship must be a JSON object and contain the `data` member'); } @override @@ -126,7 +128,8 @@ class ToMany extends Relationship { ); } } - throw DecodingException(json); + throw DocumentException( + 'A to-many relationship must be a JSON object and contain the `data` member'); } @override diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 29af67a4..0d33c55a 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,3 +1,4 @@ +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; /// Resource @@ -33,7 +34,7 @@ class Resource { : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable(toMany ?? {}) { - ArgumentError.checkNotNull(type, 'type'); + DocumentException.throwIfNull(type, 'Resource `type` must not be null'); } /// Resource type and id combined diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index 4a455f06..5449ddda 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/document/resource.dart'; @@ -25,8 +25,8 @@ class ResourceCollectionData extends PrimaryData { : null); } } - throw DecodingException( - 'Can not decode ResourceObjectCollection from $json'); + throw DocumentException( + 'A JSON:API resource collection document must be a JSON object with a JSON array in the `data` member'); } /// The link to the last page. May be null. diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index ca80b04f..e8c247eb 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/document/resource.dart'; @@ -26,7 +26,8 @@ class ResourceData extends PrimaryData { links: Link.mapFromJson(json['links'] ?? {}), included: resources.isNotEmpty ? resources : null); } - throw DecodingException(json); + throw DocumentException( + 'A JSON:API resource document must be a JSON object and contain the `data` member'); } @override diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 931f1d15..5009f928 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/relationship.dart'; @@ -52,7 +52,7 @@ class ResourceObject { meta: json['meta']); } } - throw DecodingException(json); + throw DocumentException('A JSON:API resource must be a JSON object'); } static List fromJsonList(Iterable json) => diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart index 42385739..3a6e4e67 100644 --- a/lib/src/query/query_parameters.dart +++ b/lib/src/query/query_parameters.dart @@ -1,9 +1,6 @@ /// This class and its descendants describe the query parameters recognized /// by JSON:API. class QueryParameters { - QueryParameters(Map parameters) - : _parameters = {...parameters}; - bool get isEmpty => _parameters.isEmpty; bool get isNotEmpty => _parameters.isNotEmpty; @@ -21,5 +18,8 @@ class QueryParameters { QueryParameters operator &(QueryParameters moreParameters) => merge(moreParameters); + QueryParameters(Map parameters) + : _parameters = {...parameters}; + final Map _parameters; } diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 75894d2a..533e3181 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -29,46 +29,36 @@ class Sort extends QueryParameters with IterableMixin { final List _fields; } -abstract class SortField { - bool get isAsc; +class SortField { + final bool isAsc; - bool get isDesc; + final bool isDesc; - String get name; - - static SortField parse(String queryParam) => queryParam.startsWith('-') - ? Desc(queryParam.substring(1)) - : Asc(queryParam); -} + final String name; -class Asc implements SortField { - Asc(this.name); + /// Returns 1 for Ascending fields, -1 for Descending + int get comparisonFactor => isAsc ? 1 : -1; @override - bool get isAsc => true; + String toString() => isAsc ? name : '-$name'; - @override - bool get isDesc => false; + SortField.Asc(this.name) + : isAsc = true, + isDesc = false; - @override - final String name; + SortField.Desc(this.name) + : isAsc = false, + isDesc = true; - @override - String toString() => name; + static SortField parse(String queryParam) => queryParam.startsWith('-') + ? Desc(queryParam.substring(1)) + : Asc(queryParam); } -class Desc implements SortField { - Desc(this.name); - - @override - bool get isAsc => false; - - @override - bool get isDesc => true; - - @override - final String name; +class Asc extends SortField { + Asc(String name) : super.Asc(name); +} - @override - String toString() => '-${name}'; +class Desc extends SortField { + Desc(String name) : super.Desc(name); } diff --git a/lib/src/server/json_api_controller_base.dart b/lib/src/server/json_api_controller_base.dart index a54e5e90..30b1931c 100644 --- a/lib/src/server/json_api_controller_base.dart +++ b/lib/src/server/json_api_controller_base.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; -class JsonApiControllerBase implements JsonApiController { +abstract class JsonApiControllerBase implements JsonApiController { @override FutureOr addToRelationship(R request, String type, String id, String relationship, Iterable identifiers) { diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index ad616262..6a6e1598 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -46,30 +46,34 @@ abstract class JsonApiRequest { _DeleteFromRelationship(type, id, relationship); } +/// Exception thrown by [JsonApiRequest] when an `updateRelationship` request +/// receives an incomplete relationship object. +class IncompleteRelationshipException implements Exception {} + class _AddToRelationship implements JsonApiRequest { final String type; final String id; final String relationship; - _AddToRelationship(this.type, this.id, this.relationship); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.addToRelationship(request, type, id, relationship, ToMany.fromJson(jsonPayload).unwrap()); + + _AddToRelationship(this.type, this.id, this.relationship); } class _CreateResource implements JsonApiRequest { final String type; - _CreateResource(this.type); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.createResource( request, type, ResourceData.fromJson(jsonPayload).unwrap()); + + _CreateResource(this.type); } class _DeleteFromRelationship implements JsonApiRequest { @@ -77,36 +81,36 @@ class _DeleteFromRelationship implements JsonApiRequest { final String id; final String relationship; - _DeleteFromRelationship(this.type, this.id, this.relationship); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.deleteFromRelationship(request, type, id, relationship, ToMany.fromJson(jsonPayload).unwrap()); + + _DeleteFromRelationship(this.type, this.id, this.relationship); } class _DeleteResource implements JsonApiRequest { final String type; final String id; - _DeleteResource(this.type, this.id); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.deleteResource(request, type, id); + + _DeleteResource(this.type, this.id); } class _FetchCollection implements JsonApiRequest { final String type; - _FetchCollection(this.type); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.fetchCollection(request, type); + + _FetchCollection(this.type); } class _FetchRelated implements JsonApiRequest { @@ -114,12 +118,12 @@ class _FetchRelated implements JsonApiRequest { final String id; final String relationship; - _FetchRelated(this.type, this.id, this.relationship); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.fetchRelated(request, type, id, relationship); + + _FetchRelated(this.type, this.id, this.relationship); } class _FetchRelationship implements JsonApiRequest { @@ -127,37 +131,37 @@ class _FetchRelationship implements JsonApiRequest { final String id; final String relationship; - _FetchRelationship(this.type, this.id, this.relationship); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.fetchRelationship(request, type, id, relationship); + + _FetchRelationship(this.type, this.id, this.relationship); } class _FetchResource implements JsonApiRequest { final String type; final String id; - _FetchResource(this.type, this.id); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) => controller.fetchResource(request, type, id); + + _FetchResource(this.type, this.id); } class _InvalidRequest implements JsonApiRequest { final String method; - _InvalidRequest(this.method); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) { // TODO: implement call return null; } + + _InvalidRequest(this.method); } class _UpdateRelationship implements JsonApiRequest { @@ -165,8 +169,6 @@ class _UpdateRelationship implements JsonApiRequest { final String id; final String relationship; - _UpdateRelationship(this.type, this.id, this.relationship); - @override FutureOr call( JsonApiController controller, Object jsonPayload, R request) { @@ -179,7 +181,10 @@ class _UpdateRelationship implements JsonApiRequest { return controller.replaceToMany( request, type, id, relationship, r.unwrap()); } + throw IncompleteRelationshipException(); } + + _UpdateRelationship(this.type, this.id, this.relationship); } class _UpdateResource implements JsonApiRequest { diff --git a/lib/src/server/http_handler.dart b/lib/src/server/request_handler.dart similarity index 54% rename from lib/src/server/http_handler.dart rename to lib/src/server/request_handler.dart index a687aebc..3028d8b1 100644 --- a/lib/src/server/http_handler.dart +++ b/lib/src/server/request_handler.dart @@ -3,41 +3,62 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/server.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/json_api_request.dart'; import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/response_document_factory.dart'; import 'package:json_api/uri_design.dart'; /// HTTP handler -class Handler { +class RequestHandler { /// Processes the incoming HTTP [request] and returns a response Future call(Request request) async { - final uri = await _http.getUri(request); - final method = await _http.getMethod(request); - final requestBody = await _http.getBody(request); - final requestDoc = requestBody.isEmpty ? null : json.decode(requestBody); + final uri = await _httpAdapter.getUri(request); + final method = await _httpAdapter.getMethod(request); + final requestBody = await _httpAdapter.getBody(request); final requestTarget = Target.of(uri, _design); final jsonApiRequest = requestTarget.getRequest(method); JsonApiResponse jsonApiResponse; try { + final requestDoc = requestBody.isEmpty ? null : json.decode(requestBody); jsonApiResponse = await jsonApiRequest.call(_controller, requestDoc, request); - } on JsonApiResponse catch (error) { - jsonApiResponse = error; + } on JsonApiResponse catch (e) { + jsonApiResponse = e; + } on IncompleteRelationshipException { + jsonApiResponse = JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Incomplete relationship object') + ]); + } on FormatException catch (e) { + jsonApiResponse = JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Invalid JSON. ${e.message} at offset ${e.offset}') + ]); + } on DocumentException catch (e) { + jsonApiResponse = JsonApiResponse.badRequest([ + JsonApiError(status: '400', title: 'Bad request', detail: e.message) + ]); } final statusCode = jsonApiResponse.statusCode; final headers = jsonApiResponse.buildHeaders(_design); final responseDocument = jsonApiResponse.buildDocument(_docFactory, uri); - return _http.createResponse( + return _httpAdapter.createResponse( statusCode, json.encode(responseDocument), headers); } /// Creates an instance of the handler. - Handler(this._http, this._controller, this._design, {Pagination pagination}) + RequestHandler(this._httpAdapter, this._controller, this._design, + {Pagination pagination}) : _docFactory = ResponseDocumentFactory(_design, pagination: pagination ?? Pagination.none(), api: Api(version: '1.0')); - final HttpAdapter _http; + final HttpAdapter _httpAdapter; final JsonApiController _controller; final UriDesign _design; final ResponseDocumentFactory _docFactory; diff --git a/test/functional/crud_test.dart b/test/functional/crud_test.dart index 7e488abb..c19ae802 100644 --- a/test/functional/crud_test.dart +++ b/test/functional/crud_test.dart @@ -8,7 +8,7 @@ import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; -import '../../example/server/crud_controller.dart'; +import '../../example/server/controller/crud_controller.dart'; import '../../example/server/shelf_request_response_converter.dart'; /// Basic CRUD operations @@ -17,8 +17,8 @@ void main() async { UriAwareClient client; final host = 'localhost'; final port = 8081; - final design = - UriDesign.standard(Uri(scheme: 'http', host: host, port: port)); + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); final people = [ 'Erich Gamma', 'Richard Helm', @@ -40,7 +40,7 @@ void main() async { setUp(() async { client = UriAwareClient(design); - final handler = Handler( + final handler = RequestHandler( ShelfRequestResponseConverter(), CRUDController( Uuid().v4, const ['people', 'books', 'companies'].contains), @@ -188,6 +188,18 @@ void main() async { }); }, testOn: 'vm'); + group('Create', () { + test('a primary resource, id assigned on the server', () async { + final book = Resource('books', null, + attributes: {'title': 'The Lord of the Rings'}); + final r0 = await client.createResource(book); + expect(r0.status, 201); + final r1 = await JsonApiClient().fetchResource(r0.location); + expect(r1.data.unwrap().attributes, equals(book.attributes)); + expect(r1.data.unwrap().type, equals(book.type)); + }); + }, testOn: 'vm'); + group('Update', () { test('a primary resource', () async { await client.updateResource(book.replace(attributes: {'pageCount': 416})); diff --git a/test/functional/pagination_test.dart b/test/functional/pagination_test.dart index 93a11f61..33717a37 100644 --- a/test/functional/pagination_test.dart +++ b/test/functional/pagination_test.dart @@ -7,7 +7,7 @@ import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/server/paginating_controller.dart'; +import '../../example/server/controller/paginating_controller.dart'; import '../../example/server/shelf_request_response_converter.dart'; /// Pagination @@ -22,7 +22,7 @@ void main() async { setUp(() async { client = JsonApiClient(); final pagination = Pagination.fixedSize(5); - final handler = Handler(ShelfRequestResponseConverter(), + final handler = RequestHandler(ShelfRequestResponseConverter(), PaginatingController(pagination), design, pagination: pagination); diff --git a/test/functional/sorting_test.dart b/test/functional/sorting_test.dart new file mode 100644 index 00000000..c470b24f --- /dev/null +++ b/test/functional/sorting_test.dart @@ -0,0 +1,82 @@ +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:test/test.dart'; + +import '../../example/server/controller/sorting_controller.dart'; +import '../../example/server/shelf_request_response_converter.dart'; + +/// Sorting +void main() async { + HttpServer server; + UriAwareClient client; + final host = 'localhost'; + final port = 8083; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + client = UriAwareClient(design); + final handler = + RequestHandler(ShelfRequestResponseConverter(), SortingController(), design); + + server = await serve(handler, host, port); + }); + + tearDown(() async { + client.close(); + await server.close(); + }); + + group('Sorting a collection', () { + test('unsorted', () async { + final r = await client.fetchCollection('names'); + expect(r.data.unwrap().length, 16); + expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); + expect(r.data.unwrap().first.attributes['lastName'], 'Smith'); + expect(r.data.unwrap().last.attributes['firstName'], 'Noah'); + expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); + }); + + test('sort by firstName ASC', () async { + final r = await client.fetchCollection('names', + parameters: Sort([Asc('firstName')])); + expect(r.data.unwrap().length, 16); + expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); + expect(r.data.unwrap().first.attributes['lastName'], 'Smith'); + expect(r.data.unwrap().last.attributes['firstName'], 'Olivia'); + expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); + }); + + test('sort by lastName DESC', () async { + final r = await client.fetchCollection('names', + parameters: Sort([Desc('lastName')])); + expect(r.data.unwrap().length, 16); + expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); + expect(r.data.unwrap().first.attributes['lastName'], 'Williams'); + expect(r.data.unwrap().last.attributes['firstName'], 'Noah'); + expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); + }); + + test('sort by fistName DESC, lastName ASC', () async { + final r = await client.fetchCollection('names', + parameters: Sort([Desc('firstName'), Asc('lastName')])); + expect(r.data.unwrap().length, 16); + expect(r.data.unwrap()[0].attributes['firstName'], 'Olivia'); + expect(r.data.unwrap()[0].attributes['lastName'], 'Brown'); + expect(r.data.unwrap()[1].attributes['firstName'], 'Olivia'); + expect(r.data.unwrap()[1].attributes['lastName'], 'Johnson'); + expect(r.data.unwrap()[2].attributes['firstName'], 'Olivia'); + expect(r.data.unwrap()[2].attributes['lastName'], 'Smith'); + expect(r.data.unwrap()[3].attributes['firstName'], 'Olivia'); + expect(r.data.unwrap()[3].attributes['lastName'], 'Williams'); + + expect(r.data.unwrap().last.attributes['firstName'], 'Emma'); + expect(r.data.unwrap().last.attributes['lastName'], 'Williams'); + }); + }, testOn: 'vm'); +} diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart index 368e1d40..317cbf35 100644 --- a/test/unit/document/api_test.dart +++ b/test/unit/document/api_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; -import 'package:json_api/src/document/decoding_exception.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_matcher/json_matcher.dart'; import 'package:test/test.dart'; @@ -14,8 +14,7 @@ void main() { }); test('Throws exception when can not be decoded', () { - expect( - () => Api.fromJson([]), throwsA(TypeMatcher>())); + expect(() => Api.fromJson([]), throwsA(TypeMatcher())); }); test('Empty/null properties are not encoded', () { diff --git a/test/unit/document/decoding_exception_test.dart b/test/unit/document/decoding_exception_test.dart deleted file mode 100644 index fe394be9..00000000 --- a/test/unit/document/decoding_exception_test.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/decoding_exception.dart'; -import 'package:test/test.dart'; - -void main() { - test('DecpdingException.toString()', () { - expect(DecodingException([]).toString(), - 'Can not decode Api from JSON: []'); - }); -} diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/request_handler_test.dart new file mode 100644 index 00000000..e2e8f67d --- /dev/null +++ b/test/unit/server/request_handler_test.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +void main() { + final base = Uri.parse('http://localhost'); + var uriDesign = UriDesign.standard(base); + final handler = RequestHandler(TestAdapter(), DummyController(), uriDesign); + + group('HTTP Handler', () { + test('returns `bad request` on incomplete relationship', () async { + final rq = TestRequest( + uriDesign.relationshipUri('books', '1', 'author'), 'patch', '{}'); + final rs = await handler.call(rq); + expect(rs.statusCode, 400); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '400'); + expect(error.title, 'Bad request'); + expect(error.detail, 'Incomplete relationship object'); + }); + + test('returns `bad request` when payload is not a valid JSON', () async { + final rq = + TestRequest(uriDesign.collectionUri('books'), 'post', '"ololo"abc'); + final rs = await handler.call(rq); + expect(rs.statusCode, 400); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '400'); + expect(error.title, 'Bad request'); + expect(error.detail, 'Invalid JSON. Unexpected character at offset 7'); + }); + + test('returns `bad request` when payload is not a valid JSON:API object', + () async { + final rq = + TestRequest(uriDesign.collectionUri('books'), 'post', '"oops"'); + final rs = await handler.call(rq); + expect(rs.statusCode, 400); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '400'); + expect(error.title, 'Bad request'); + expect(error.detail, + 'A JSON:API resource document must be a JSON object and contain the `data` member'); + }); + + test('returns `bad request` when payload violates JSON:API', () async { + final rq = + TestRequest(uriDesign.collectionUri('books'), 'post', '{"data": {}}'); + final rs = await handler.call(rq); + expect(rs.statusCode, 400); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '400'); + expect(error.title, 'Bad request'); + expect(error.detail, 'Resource `type` must not be null'); + }); + }); +} + +class TestAdapter implements HttpAdapter { + @override + FutureOr createResponse( + int statusCode, String body, Map headers) => + TestResponse(statusCode, body, headers); + + @override + FutureOr getBody(TestRequest request) => request.body; + + @override + FutureOr getMethod(TestRequest request) => request.method; + + @override + FutureOr getUri(TestRequest request) => request.uri; +} + +class TestRequest { + final Uri uri; + final String method; + final String body; + + TestRequest(this.uri, this.method, this.body); +} + +class TestResponse { + final int statusCode; + final String body; + final Map headers; + + Document get document => Document.fromJson(json.decode(body), null); + + TestResponse(this.statusCode, this.body, this.headers); +} + +class DummyController extends JsonApiControllerBase {} From 3a55125e459248220c6287a99a851523128c07b2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 22 Jan 2020 21:15:23 -0800 Subject: [PATCH 09/99] quites --- lib/src/document/api.dart | 2 +- lib/src/document/document.dart | 2 +- lib/src/document/identifier.dart | 4 ++-- lib/src/document/relationship.dart | 6 +++--- lib/src/document/resource.dart | 2 +- lib/src/document/resource_collection_data.dart | 2 +- lib/src/document/resource_data.dart | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index 979d992b..cd93d6a0 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -17,7 +17,7 @@ class Api { if (json is Map) { return Api(version: json['version'], meta: json['meta']); } - throw DocumentException('The `$memberName` member must be a JSON object'); + throw DocumentException("The '$memberName' member must be a JSON object"); } Map toJson() => { diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 8781d969..33489247 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -35,7 +35,7 @@ class Document { : data = null, meta = (meta == null) ? null : Map.unmodifiable(meta), errors = null { - DocumentException.throwIfNull(meta, 'The `meta` member must not be null'); + DocumentException.throwIfNull(meta, "The 'meta' member must not be null"); } /// Reconstructs a document with the specified primary data diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 5fa0b426..5b033127 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -15,8 +15,8 @@ class Identifier { /// Neither [type] nor [id] can be null or empty. Identifier(this.type, this.id) { - DocumentException.throwIfNull(id, 'Identifier `id` must not be null'); - DocumentException.throwIfNull(type, 'Identifier `type` must not be null'); + DocumentException.throwIfNull(id, "Identifier 'id' must not be null"); + DocumentException.throwIfNull(type, "Identifier 'type' must not be null"); } static Identifier of(Resource resource) => diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index d5ed55d4..f66a8c0d 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -47,7 +47,7 @@ class Relationship extends PrimaryData { return json .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); } - throw DocumentException('The `relationships` member must be a JSON object'); + throw DocumentException("The 'relationships' member must be a JSON object"); } /// Top-level JSON object @@ -85,7 +85,7 @@ class ToOne extends Relationship { included is List ? ResourceObject.fromJsonList(included) : null); } throw DocumentException( - 'A to-one relationship must be a JSON object and contain the `data` member'); + "A to-one relationship must be a JSON object and contain the 'data' member"); } @override @@ -129,7 +129,7 @@ class ToMany extends Relationship { } } throw DocumentException( - 'A to-many relationship must be a JSON object and contain the `data` member'); + "A to-many relationship must be a JSON object and contain the 'data' member"); } @override diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 0d33c55a..f20b9b5f 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -34,7 +34,7 @@ class Resource { : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable(toMany ?? {}) { - DocumentException.throwIfNull(type, 'Resource `type` must not be null'); + DocumentException.throwIfNull(type, "Resource 'type' must not be null"); } /// Resource type and id combined diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index 5449ddda..e19ce966 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -26,7 +26,7 @@ class ResourceCollectionData extends PrimaryData { } } throw DocumentException( - 'A JSON:API resource collection document must be a JSON object with a JSON array in the `data` member'); + "A JSON:API resource collection document must be a JSON object with a JSON array in the 'data' member"); } /// The link to the last page. May be null. diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index e8c247eb..77e6b958 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -27,7 +27,7 @@ class ResourceData extends PrimaryData { included: resources.isNotEmpty ? resources : null); } throw DocumentException( - 'A JSON:API resource document must be a JSON object and contain the `data` member'); + "A JSON:API resource document must be a JSON object and contain the 'data' member"); } @override From 8bee3d78f2d5a402231333b963937fcc2e7e42db Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 22 Jan 2020 21:21:48 -0800 Subject: [PATCH 10/99] quites --- test/unit/server/request_handler_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/request_handler_test.dart index e2e8f67d..4f62dd66 100644 --- a/test/unit/server/request_handler_test.dart +++ b/test/unit/server/request_handler_test.dart @@ -44,7 +44,7 @@ void main() { expect(error.status, '400'); expect(error.title, 'Bad request'); expect(error.detail, - 'A JSON:API resource document must be a JSON object and contain the `data` member'); + "A JSON:API resource document must be a JSON object and contain the 'data' member"); }); test('returns `bad request` when payload violates JSON:API', () async { @@ -55,7 +55,7 @@ void main() { final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); expect(error.title, 'Bad request'); - expect(error.detail, 'Resource `type` must not be null'); + expect(error.detail, "Resource 'type' must not be null"); }); }); } From 2262c1b1c10af161991b9efc743bebea9c8945f9 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 22 Jan 2020 21:38:58 -0800 Subject: [PATCH 11/99] quites --- lib/src/server/request_handler.dart | 2 +- test/unit/server/request_handler_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/server/request_handler.dart b/lib/src/server/request_handler.dart index 3028d8b1..2d01fda3 100644 --- a/lib/src/server/request_handler.dart +++ b/lib/src/server/request_handler.dart @@ -38,7 +38,7 @@ class RequestHandler { JsonApiError( status: '400', title: 'Bad request', - detail: 'Invalid JSON. ${e.message} at offset ${e.offset}') + detail: 'Invalid JSON. ${e.message}') ]); } on DocumentException catch (e) { jsonApiResponse = JsonApiResponse.badRequest([ diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/request_handler_test.dart index 4f62dd66..58659188 100644 --- a/test/unit/server/request_handler_test.dart +++ b/test/unit/server/request_handler_test.dart @@ -31,7 +31,7 @@ void main() { final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); expect(error.title, 'Bad request'); - expect(error.detail, 'Invalid JSON. Unexpected character at offset 7'); + expect(error.detail, startsWith('Invalid JSON. ')); }); test('returns `bad request` when payload is not a valid JSON:API object', From 2177a3b265a87dd4dd68ed316ed6dbb1484ad297 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 22 Jan 2020 21:53:14 -0800 Subject: [PATCH 12/99] format --- example/server/server.dart | 1 + lib/client.dart | 2 +- lib/server.dart | 2 +- test/functional/sorting_test.dart | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/example/server/server.dart b/example/server/server.dart index b984e3da..49db6081 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -14,6 +14,7 @@ void main() async { final host = 'localhost'; final port = 8080; final baseUri = Uri(scheme: 'http', host: host, port: port); + /// You may also try PaginatingController final controller = CRUDController(Uuid().v4, (_) => true); final jsonApiHandler = RequestHandler( diff --git a/lib/client.dart b/lib/client.dart index aae7c575..89767e22 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,7 +1,7 @@ library client; -export 'package:json_api/src/client/request_document_factory.dart'; export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/request_document_factory.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; export 'package:json_api/src/client/uri_aware_client.dart'; diff --git a/lib/server.dart b/lib/server.dart index 88892a75..71eb9267 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,9 +3,9 @@ /// The server API is not stable. Expect breaking changes. library server; -export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/json_api_controller.dart'; export 'package:json_api/src/server/json_api_controller_base.dart'; export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/response_document_factory.dart'; export 'package:json_api/src/server/target.dart'; diff --git a/test/functional/sorting_test.dart b/test/functional/sorting_test.dart index c470b24f..c71d3ac2 100644 --- a/test/functional/sorting_test.dart +++ b/test/functional/sorting_test.dart @@ -21,8 +21,8 @@ void main() async { setUp(() async { client = UriAwareClient(design); - final handler = - RequestHandler(ShelfRequestResponseConverter(), SortingController(), design); + final handler = RequestHandler( + ShelfRequestResponseConverter(), SortingController(), design); server = await serve(handler, host, port); }); From 08ad31e396e67674f2dbeb7d05cd7eb17f51b337 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 25 Jan 2020 20:36:07 -0800 Subject: [PATCH 13/99] wip --- .gitignore | 4 +- example/server/controller/colors.dart | 2 +- .../server/controller/crud_controller.dart | 33 +- lib/src/client/response.dart | 18 +- lib/src/client/uri_aware_client.dart | 20 +- lib/src/document/document.dart | 2 +- lib/src/document/primary_data.dart | 2 +- lib/src/document/relationship.dart | 13 +- lib/src/document/resource.dart | 29 +- .../document/resource_collection_data.dart | 6 +- lib/src/document/resource_object.dart | 6 +- lib/src/server/repository/in_memory.dart | 84 ++++ lib/src/server/repository/repository.dart | 62 +++ lib/src/server/repository_controller.dart | 134 ++++++ pubspec.yaml | 3 +- test/functional/async_processing_test.dart | 1 + test/functional/basic_crud_test.dart | 392 ++++++++++++++++++ .../functional/forbidden_operations_test.dart | 1 + test/functional/no_content_test.dart | 1 + .../shelf_request_response_converter.dart | 21 + test/unit/document/resource_test.dart | 5 - {test/functional => tmp}/crud_test.dart | 30 +- {test/functional => tmp}/pagination_test.dart | 4 +- {test/functional => tmp}/sorting_test.dart | 4 +- 24 files changed, 789 insertions(+), 88 deletions(-) create mode 100644 lib/src/server/repository/in_memory.dart create mode 100644 lib/src/server/repository/repository.dart create mode 100644 lib/src/server/repository_controller.dart create mode 100644 test/functional/async_processing_test.dart create mode 100644 test/functional/basic_crud_test.dart create mode 100644 test/functional/forbidden_operations_test.dart create mode 100644 test/functional/no_content_test.dart create mode 100644 test/helper/shelf_request_response_converter.dart rename {test/functional => tmp}/crud_test.dart (93%) rename {test/functional => tmp}/pagination_test.dart (92%) rename {test/functional => tmp}/sorting_test.dart (95%) diff --git a/.gitignore b/.gitignore index 9b5d7b92..adf2127e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,6 @@ pubspec.lock doc/api/ # Generated by test_coverage -test/.test_coverage.dart -coverage/ +/test/.test_coverage.dart +/coverage/ /coverage_badge.svg diff --git a/example/server/controller/colors.dart b/example/server/controller/colors.dart index 2acad4d2..7630dc20 100644 --- a/example/server/controller/colors.dart +++ b/example/server/controller/colors.dart @@ -21,4 +21,4 @@ final Map colors = Map.fromIterable( ['aqua', '00ffff'], ].map((c) => Resource('colors', Uuid().v4(), attributes: {'name': c[0], 'rgb': c[1]})), - key: (r) => r.id); + key: (r) => r.generateId); diff --git a/example/server/controller/crud_controller.dart b/example/server/controller/crud_controller.dart index c3fa775d..04a898db 100644 --- a/example/server/controller/crud_controller.dart +++ b/example/server/controller/crud_controller.dart @@ -99,9 +99,32 @@ class CRUDController implements JsonApiController { FutureOr fetchCollection( shelf.Request request, String type) { final repo = _repo(type); +// final include = Include.fromUri(request.requestedUri); +// final includedResources = []; + return JsonApiResponse.collection(repo.values); } + Iterable _getRelated(Resource resource, String relationship) { + if (resource.toOne.containsKey(relationship)) { + final related = _getResource(resource.toOne[relationship]); + if (related != null) { + return [related]; + } + } + if (resource.toMany.containsKey(relationship)) { + return resource.toMany[relationship] + .map(_getResource) + .skipWhile((_) => _ == null); + } + return []; + } + + Resource _getResource(Identifier id) { + if (id == null) return null; + return _repo(id.type)[id]; + } + @override FutureOr fetchRelated( shelf.Request request, String type, String id, String relationship) { @@ -145,11 +168,11 @@ class CRUDController implements JsonApiController { FutureOr updateResource( shelf.Request request, String type, String id, Resource resource) { final current = _repo(type)[id]; - if (resource.hasAllMembersOf(current)) { - _repo(type)[id] = resource; - return JsonApiResponse.noContent(); - } - _repo(type)[id] = resource.withExtraMembersFrom(current); +// if (resource.hasAllMembersOf(current)) { +// _repo(type)[id] = resource; +// return JsonApiResponse.noContent(); +// } +// _repo(type)[id] = resource.withExtraMembersFrom(current); return JsonApiResponse.resourceUpdated(_repo(type)[id]); } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index c2946967..e6a8f06d 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -5,11 +5,11 @@ import 'package:json_api/src/nullable.dart'; /// A response returned by JSON:API client class JsonApiResponse { - const JsonApiResponse(this.status, this.headers, + const JsonApiResponse(this.statusCode, this.headers, {this.document, this.asyncDocument}); /// HTTP status code - final int status; + final int statusCode; /// Document parsed from the response body. /// May be null. @@ -24,16 +24,20 @@ class JsonApiResponse { /// Primary Data from the document (if any). For unsuccessful operations /// this property will be null, the error details may be found in [Document.errors]. - Data get data => document.data; + Data get data => document?.data; + + /// List of errors (if any) returned by the server in case of an unsuccessful + /// operation. May be empty. Will be null if the operation was successful. + List get errors => document?.errors; /// Primary Data from the async document (if any) - ResourceData get asyncData => asyncDocument.data; + ResourceData get asyncData => asyncDocument?.data; /// Was the query successful? /// /// For pending (202 Accepted) requests both [isSuccessful] and [isFailed] /// are always false. - bool get isSuccessful => StatusCode(status).isSuccessful; + bool get isSuccessful => StatusCode(statusCode).isSuccessful; /// This property is an equivalent of `202 Accepted` HTTP status. /// It indicates that the query is accepted but not finished yet (e.g. queued). @@ -46,11 +50,11 @@ class JsonApiResponse { /// return the created resource. /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing - bool get isAsync => StatusCode(status).isPending; + bool get isAsync => StatusCode(statusCode).isPending; /// Any non 2** status code is considered a failed operation. /// For failed requests, [document] is expected to contain [ErrorDocument] - bool get isFailed => StatusCode(status).isFailed; + bool get isFailed => StatusCode(statusCode).isFailed; /// The `Location` HTTP header value. For `201 Created` responses this property /// contains the location of a newly created resource. diff --git a/lib/src/client/uri_aware_client.dart b/lib/src/client/uri_aware_client.dart index dcb60c82..fb34253a 100644 --- a/lib/src/client/uri_aware_client.dart +++ b/lib/src/client/uri_aware_client.dart @@ -7,13 +7,16 @@ import 'package:json_api/uri_design.dart'; /// This wrapper reduces the boilerplate code but is not as flexible /// as [JsonApiClient]. class UriAwareClient { - /// Creates a new resource. The resource will be added to a collection - /// according to its type. + /// Creates a new resource. + /// + /// If [collection] is specified, the resource will be added to that collection, + /// otherwise its type will be used to reference the target collection. /// /// https://jsonapi.org/format/#crud-creating Future> createResource(Resource resource, - {Map headers}) => - _client.createResource(_uriFactory.collectionUri(resource.type), resource, + {String collection, Map headers}) => + _client.createResource( + _uriFactory.collectionUri(collection ?? resource.type), resource, headers: headers); /// Fetches a single resource @@ -120,13 +123,16 @@ class UriAwareClient { _uriFactory.relationshipUri(type, id, relationship), identifiers, headers: headers); - /// Updates the [resource]. + /// Updates the [resource]. If [collection] and/or [id] is specified, they + /// will be used to refer the existing resource to be replaced. /// /// https://jsonapi.org/format/#crud-updating Future> updateResource(Resource resource, - {Map headers}) => + {Map headers, String collection, String id}) => _client.updateResource( - _uriFactory.resourceUri(resource.type, resource.id), resource, + _uriFactory.resourceUri( + collection ?? resource.type, id ?? resource.id), + resource, headers: headers); /// Adds the given set of [identifiers] to a to-many relationship. diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 33489247..31164df4 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -13,7 +13,7 @@ class Document { final Api api; /// List of errors. May be null. - final List errors; + final Iterable errors; /// Meta data. May be empty or null. final Map meta; diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index cf138d97..dfcacdfe 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -9,7 +9,7 @@ import 'package:json_api/src/document/resource_object.dart'; abstract class PrimaryData { /// In a Compound document this member contains the included resources. /// May be empty or null. - final List included; + final Iterable included; /// The top-level `links` object. May be empty or null. final Map links; diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index f66a8c0d..5dc48caa 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -109,13 +109,12 @@ class ToMany extends Relationship { /// Can be empty for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final linkage = []; + final Iterable linkage; ToMany(Iterable linkage, {Iterable included, Map links}) - : super(included: included, links: links) { - this.linkage.addAll(linkage); - } + : linkage = List.unmodifiable(linkage), + super(included: included, links: links); static ToMany fromJson(Object json) { if (json is Map && json.containsKey('data')) { @@ -138,10 +137,10 @@ class ToMany extends Relationship { 'data': linkage, }; - /// Converts to List. + /// Converts to Iterable. /// For empty relationships returns an empty List. - List unwrap() => linkage.map((_) => _.unwrap()).toList(); + Iterable unwrap() => linkage.map((_) => _.unwrap()).toList(); /// Same as [unwrap()] - List get identifiers => unwrap(); + Iterable get identifiers => unwrap(); } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index f20b9b5f..3cfb6767 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -22,7 +22,7 @@ class Resource { final Map toOne; /// Unmodifiable map of to-many relationships - final Map> toMany; + final Map> toMany; /// Creates an instance of [Resource]. /// The [type] can not be null. @@ -30,7 +30,7 @@ class Resource { Resource(this.type, this.id, {Map attributes, Map toOne, - Map> toMany}) + Map> toMany}) : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable(toMany ?? {}) { @@ -50,38 +50,15 @@ class Resource { @override String toString() => 'Resource($key $attributes)'; - /// Returns true if this resource has the same [key] and all [attributes] - /// and relationships as the [other] (not necessarily with the same values). - /// This method can be used to chose between 200 and 204 in PATCH requests. - /// See https://jsonapi.org/format/#crud-updating-responses - bool hasAllMembersOf(Resource other) => - other.key == key && - other.attributes.keys.every(attributes.containsKey) && - other.toOne.keys.every(toOne.containsKey) && - other.toMany.keys.every(toMany.containsKey); - - /// Adds all attributes and relationships from the [other] resource which - /// are not present in this resource. Returns a new instance. - Resource withExtraMembersFrom(Resource other) => Resource(type, id, - attributes: _merge(other.attributes, attributes), - toOne: _merge(other.toOne, toOne), - toMany: _merge(other.toMany, toMany)); - /// Creates a new instance of the resource with replaced properties Resource replace( {String type, String id, Map attributes, Map toOne, - Map> toMany}) => + Map> toMany}) => Resource(type ?? this.type, id ?? this.id, attributes: attributes ?? this.attributes, toOne: toOne ?? this.toOne, toMany: toMany ?? this.toMany); - - Map _merge(Map source, Map dest) { - final copy = {...dest}; - source.forEach((k, v) => copy.putIfAbsent(k, () => v)); - return copy; - } } diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index e19ce966..f2f100c6 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -6,7 +6,7 @@ import 'package:json_api/src/document/resource_object.dart'; /// Represents a resource collection or a collection of related resources of a to-many relationship class ResourceCollectionData extends PrimaryData { - final List collection; + final Iterable collection; ResourceCollectionData(Iterable collection, {Iterable included, Map links = const {}}) @@ -42,11 +42,11 @@ class ResourceCollectionData extends PrimaryData { Link get prev => (links ?? {})['prev']; /// Returns a list of resources contained in the collection - List unwrap() => collection.map((_) => _.unwrap()).toList(); + Iterable unwrap() => collection.map((_) => _.unwrap()); /// Returns a map of resources indexed by ids Map unwrapToMap() => - Map.fromIterable(unwrap(), key: (r) => r.id); + Map.fromIterable(unwrap(), key: (r) => r.id); @override Map toJson() => { diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 5009f928..8f0afc09 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -55,8 +55,8 @@ class ResourceObject { throw DocumentException('A JSON:API resource must be a JSON object'); } - static List fromJsonList(Iterable json) => - json.map(fromJson).toList(); + static Iterable fromJsonList(Iterable json) => + json.map(fromJson); /// Returns the JSON object to be used in the `data` or `included` members /// of a JSON:API Document @@ -74,7 +74,7 @@ class ResourceObject { /// recovered and this method will throw a [StateError]. Resource unwrap() { final toOne = {}; - final toMany = >{}; + final toMany = >{}; final incomplete = {}; (relationships ?? {}).forEach((name, rel) { if (rel is ToOne) { diff --git a/lib/src/server/repository/in_memory.dart b/lib/src/server/repository/in_memory.dart new file mode 100644 index 00000000..f3d3c452 --- /dev/null +++ b/lib/src/server/repository/in_memory.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/repository/repository.dart'; + +typedef IdGenerator = String Function(String collection); +typedef TypeAttributionCriteria = bool Function(String collection, String type); + +final _typeEqualsCollection = ((t, s) => t == s); + +class InMemoryRepository implements Repository { + final Map> _collections; + final IdGenerator _generateId; + final TypeAttributionCriteria _typeBelongs; + + @override + FutureOr create(String collection, Resource resource) async { + if (!_collections.containsKey(collection)) { + throw CollectionNotFound("Collection '$collection' does not exist"); + } + if (!_typeBelongs(collection, resource.type)) { + throw _invalidType(resource, collection); + } + for (final relationship in resource.toOne.values + .followedBy(resource.toMany.values.expand((_) => _))) { + await get(relationship.type, relationship.id); + } + if (resource.id == null) { + final id = _generateId?.call(collection); + if (id == null) { + throw UnsupportedOperation('Id generation is not supported'); + } + final created = resource.replace(id: id); + _collections[collection][created.id] = created; + return created; + } + if (_collections[collection].containsKey(resource.id)) { + throw ResourceExists('Resource with this type and id already exists'); + } + _collections[collection][resource.id] = resource; + return null; + } + + @override + FutureOr get(String collection, String id) { + if (_collections.containsKey(collection)) { + final resource = _collections[collection][id]; + if (resource == null) { + throw ResourceNotFound( + "Resource '$id' does not exist in '$collection'"); + } + return resource; + } + throw CollectionNotFound("Collection '$collection' does not exist"); + } + + @override + FutureOr update( + String collection, String id, Resource resource) async { + if (collection != resource.type) { + throw _invalidType(resource, collection); + } + final original = await get(collection, id); + final updated = Resource( + original.type, + original.id, + attributes: {...original.attributes}..addAll(resource.attributes), + toOne: {...original.toOne}..addAll(resource.toOne), + toMany: {...original.toMany}..addAll(resource.toMany), + ); + _collections[collection][id] = updated; + return updated; + } + + InvalidType _invalidType(Resource resource, String collection) { + return InvalidType( + "Type '${resource.type}' does not belong in '$collection'"); + } + + InMemoryRepository(this._collections, + {TypeAttributionCriteria typeBelongs, IdGenerator generateId}) + : _typeBelongs = typeBelongs ?? _typeEqualsCollection, + _generateId = generateId; +} diff --git a/lib/src/server/repository/repository.dart b/lib/src/server/repository/repository.dart new file mode 100644 index 00000000..72a23c2c --- /dev/null +++ b/lib/src/server/repository/repository.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:json_api/src/document/resource.dart'; + +abstract class Repository { + /// Creates the [resource] in the [collection]. + /// If the resource was modified during creation, + /// this method must return the modified resource (e.g. with the generated id). + /// Otherwise must return null. + /// + /// Throws [CollectionNotFound] if there is no such [collection]. + + /// Throws [ResourceNotFound] if one or more related resources are not found. + /// + /// Throws [UnsupportedOperation] if the operation + /// is not supported (e.g. the client sent a resource without the id, but + /// the id generation is not supported by this repository). This exception + /// will be converted to HTTP 403 error. + /// + /// Throws [InvalidType] if the [resource] + /// does not belong to the collection. This exception will be converted to HTTP 409 + /// error. + FutureOr create(String collection, Resource resource); + + /// Returns the resource from [collection] by [id]. + FutureOr get(String collection, String id); + + /// Updates the resource identified by [collection] and [id]. + /// If the resource was modified during update, returns the modified resource. + /// Otherwise returns null. + FutureOr update(String collection, String id, Resource resource); +} + +class CollectionNotFound implements Exception { + final String message; + + CollectionNotFound(this.message); +} + +class ResourceNotFound implements Exception { + final String message; + + ResourceNotFound(this.message); +} + +class UnsupportedOperation implements Exception { + final String message; + + UnsupportedOperation(this.message); +} + +class InvalidType implements Exception { + final String message; + + InvalidType(this.message); +} + +class ResourceExists implements Exception { + final String message; + + ResourceExists(this.message); +} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart new file mode 100644 index 00000000..3570ad0a --- /dev/null +++ b/lib/src/server/repository_controller.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/json_api_response.dart'; +import 'package:json_api/src/server/repository/repository.dart'; + +/// An opinionated implementation of [JsonApiController] +class RepositoryController implements JsonApiController { + final Repository _repo; + + RepositoryController(this._repo); + + @override + FutureOr addToRelationship(R request, String type, String id, + String relationship, Iterable identifiers) => + _do(() async { + final original = await _repo.get(type, id); + final updated = await _repo.update( + type, + id, + Resource(type, id, toMany: { + relationship: [...original.toMany[relationship], ...identifiers] + })); + return JsonApiResponse.toMany( + type, id, relationship, updated.toMany[relationship]); + }); + + @override + FutureOr createResource( + R request, String type, Resource resource) => + _do(() async { + final modified = await _repo.create(type, resource); + if (modified == null) return JsonApiResponse.noContent(); + return JsonApiResponse.resourceCreated(modified); + }); + + @override + FutureOr deleteFromRelationship(R request, String type, + String id, String relationship, Iterable identifiers) { + // TODO: implement deleteFromRelationship + return null; + } + + @override + FutureOr deleteResource(R request, String type, String id) { + // TODO: implement deleteResource + return null; + } + + @override + FutureOr fetchCollection(R request, String type) { + // TODO: implement fetchCollection + return null; + } + + @override + FutureOr fetchRelated( + R request, String type, String id, String relationship) { + // TODO: implement fetchRelated + return null; + } + + @override + FutureOr fetchRelationship( + R request, String type, String id, String relationship) { + // TODO: implement fetchRelationship + return null; + } + + @override + FutureOr fetchResource( + R request, String type, String id) async { + return JsonApiResponse.resource(await _repo.get(type, id)); + } + + @override + FutureOr replaceToMany(R request, String type, String id, + String relationship, Iterable identifiers) => + _do(() async { + await _repo.update( + type, id, Resource(type, id, toMany: {relationship: identifiers})); + return JsonApiResponse.noContent(); + }); + + @override + FutureOr replaceToOne(R request, String type, String id, + String relationship, Identifier identifier) => + _do(() async { + await _repo.update( + type, id, Resource(type, id, toOne: {relationship: identifier})); + return JsonApiResponse.noContent(); + }); + + @override + FutureOr updateResource( + R request, String type, String id, Resource resource) => + _do(() async { + final modified = await _repo.update(type, id, resource); + if (modified == null) return JsonApiResponse.noContent(); + return JsonApiResponse.resource(modified); + }); + + FutureOr _do( + FutureOr Function() action) async { + try { + return await action(); + } on UnsupportedOperation catch (e) { + return JsonApiResponse.forbidden([ + JsonApiError( + status: '403', title: 'Unsupported operation', detail: e.message) + ]); + } on CollectionNotFound catch (e) { + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', title: 'Collection not found', detail: e.message) + ]); + } on ResourceNotFound catch (e) { + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', title: 'Resource not found', detail: e.message) + ]); + } on InvalidType catch (e) { + return JsonApiResponse.conflict([ + JsonApiError( + status: '409', title: 'Invalid resource type', detail: e.message) + ]); + } on ResourceExists catch (e) { + return JsonApiResponse.conflict([ + JsonApiError(status: '409', title: 'Resource exists', detail: e.message) + ]); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9b84cd27..3ab90161 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 3.2.2 +version: 4.0.0-dev.1 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: @@ -14,3 +14,4 @@ dev_dependencies: stream_channel: ^2.0.0 uuid: ^2.0.1 test_coverage: ^0.4.0 + shelf: ^0.7.5 diff --git a/test/functional/async_processing_test.dart b/test/functional/async_processing_test.dart new file mode 100644 index 00000000..ab73b3a2 --- /dev/null +++ b/test/functional/async_processing_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart new file mode 100644 index 00000000..1bb7dae6 --- /dev/null +++ b/test/functional/basic_crud_test.dart @@ -0,0 +1,392 @@ +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/repository/in_memory.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../helper/shelf_request_response_converter.dart'; + +void main() async { + HttpServer server; + UriAwareClient client; + final host = 'localhost'; + final port = 8081; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + client = UriAwareClient(design); + server = await serve( + RequestHandler( + ShelfRequestResponseConverter(), + RepositoryController(InMemoryRepository({ + 'books': {}, + 'people': {}, + 'companies': {}, + 'noServerId': {}, + 'fruits': {}, + 'apples': {} + }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4())), + design), + host, + port); + }); + + tearDown(() async { + client.close(); + await server.close(); + }); + + group('Creating Resources', () { + test('id generated on the server', () async { + final person = + Resource('people', null, attributes: {'name': 'Martin Fowler'}); + final r = await client.createResource(person); + expect(r.isSuccessful, isTrue); + expect(r.isFailed, isFalse); + expect(r.statusCode, 201); + expect(r.location, isNotNull); + final created = r.data.unwrap(); + expect(created.type, person.type); + expect(created.id, isNotNull); + expect(created.attributes, equals(person.attributes)); + final r1 = await JsonApiClient().fetchResource(r.location); + expect(r1.isSuccessful, isTrue); + expect(r1.statusCode, 200); + expectResourcesEqual(r1.data.unwrap(), created); + }); + + test('id generated on the client, the resource is not modified', () async { + final person = + Resource('people', '123', attributes: {'name': 'Martin Fowler'}); + final r = await client.createResource(person); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.location, isNull); + expect(r.data, isNull); + final r1 = await client.fetchResource(person.type, person.id); + expect(r1.isSuccessful, isTrue); + expect(r1.statusCode, 200); + expectResourcesEqual(r1.data.unwrap(), person); + }); + + test('403 when the id can not be generated', () async { + final r = await client.createResource(Resource('noServerId', null)); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 403); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '403'); + expect(error.title, 'Unsupported operation'); + expect(error.detail, 'Id generation is not supported'); + }); + + test('404 when the collection does not exist', () async { + final r = await client.createResource(Resource('unicorns', null)); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when the related resource does not exist (to-one)', () async { + final book = Resource('books', null, + toOne: {'publisher': Identifier('companies', '123')}); + final r = await client.createResource(book); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '123' does not exist in 'companies'"); + }); + + test('404 when the related resource does not exist (to-many)', () async { + final book = Resource('books', null, toMany: { + 'authors': [Identifier('people', '123')] + }); + final r = await client.createResource(book); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '123' does not exist in 'people'"); + }); + + test('409 when the resource type does not match collection', () async { + final r = await JsonApiClient().createResource( + design.collectionUri('fruits'), Resource('cucumbers', null)); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Invalid resource type'); + expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); + }); + + test('409 when the resource with this id already exists', () async { + final apple = Resource('apples', '123'); + await client.createResource(apple); + final r = await client.createResource(apple); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Resource exists'); + expect(error.detail, 'Resource with this type and id already exists'); + }); + }, testOn: 'vm'); + + group('Updating Resources and Relationships', () { + setUp(() async { + await client.createResource( + Resource('people', '1', attributes: {'name': 'Martin Fowler'})); + await client.createResource( + Resource('people', '2', attributes: {'name': 'Kent Beck'})); + await client.createResource( + Resource('people', '3', attributes: {'name': 'Robert Martin'})); + await client.createResource(Resource('companies', '1', + attributes: {'name': 'Addison-Wesley Professional'})); + await client.createResource( + Resource('companies', '2', attributes: {'name': 'Prentice Hall'})); + await client.createResource(Resource('books', '1', attributes: { + 'title': 'Refactoring', + 'ISBN-10': '0134757599' + }, toOne: { + 'publisher': Identifier('companies', '1') + }, toMany: { + 'authors': [Identifier('people', '1'), Identifier('people', '2')] + })); + }); + + group('Resources', () { + test('Update resource attributes and relationships', () async { + final r = + await client.updateResource(Resource('books', '1', attributes: { + 'title': 'Refactoring. Improving the Design of Existing Code', + 'pages': 448 + }, toOne: { + 'publisher': null + }, toMany: { + 'authors': [Identifier('people', '1')], + 'reviewers': [Identifier('people', '2')] + })); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().attributes['title'], + 'Refactoring. Improving the Design of Existing Code'); + expect(r.data.unwrap().attributes['pages'], 448); + expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); + expect(r.data.unwrap().toOne['publisher'], isNull); + expect(r.data.unwrap().toMany['authors'], + equals([Identifier('people', '1')])); + expect(r.data.unwrap().toMany['reviewers'], + equals([Identifier('people', '2')])); + + final r1 = await client.fetchResource('books', '1'); + expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); + }); + + test('404 when the target resource does not exist', () async { + final r = + await client.updateResource(Resource('books', '42'), id: '42'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + + test('409 when the resource type does not match the collection', + () async { + final r = await client.updateResource(Resource('books', '1'), + collection: 'people'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Invalid resource type'); + expect(error.detail, "Type 'books' does not belong in 'people'"); + }); + }); + + group('Updatng a to-one relationship', () { + test('successfully', () async { + final r = await client.replaceToOne( + 'books', '1', 'publisher', Identifier('companies', '2')); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toOne['publisher'].id, '2'); + }); + + test('404 when collection not found', () async { + final r = await client.replaceToOne( + 'unicorns', '1', 'breed', Identifier('companies', '2')); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.replaceToOne( + 'books', '42', 'publisher', Identifier('companies', '2')); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Deleting a to-one relationship', () { + test('successfully', () async { + final r = await client.deleteToOne('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toOne['publisher'], isNull); + }); + + test('404 when collection not found', () async { + final r = await client.deleteToOne('unicorns', '1', 'breed'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.deleteToOne('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Replacing a to-many relationship', () { + test('successfully', () async { + final r = await client.replaceToMany( + 'books', '1', 'authors', [Identifier('people', '1')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 1); + expect(r1.data.unwrap().toMany['authors'].first.id, '1'); + }); + + test('404 when collection not found', () async { + final r = await client.replaceToMany( + 'unicorns', '1', 'breed', [Identifier('companies', '2')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.replaceToMany( + 'books', '42', 'publisher', [Identifier('companies', '2')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Adding to a to-many relationship', () { + test('successfully', () async { + final r = await client.addToRelationship( + 'books', '1', 'authors', [Identifier('people', '3')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 3); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '3'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 3); + }); + + test('404 when collection not found', () async { + final r = await client.addToRelationship( + 'unicorns', '1', 'breed', [Identifier('companies', '3')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.addToRelationship( + 'books', '42', 'publisher', [Identifier('companies', '3')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + }, testOn: 'vm'); +} + +void expectResourcesEqual(Resource a, Resource b) { + expect(a.type, equals(b.type)); + expect(a.id, equals(b.id)); + expect(a.attributes, equals(b.attributes)); + expect(a.toOne, equals(b.toOne)); + expect(a.toMany, equals(b.toMany)); +} diff --git a/test/functional/forbidden_operations_test.dart b/test/functional/forbidden_operations_test.dart new file mode 100644 index 00000000..ab73b3a2 --- /dev/null +++ b/test/functional/forbidden_operations_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/test/functional/no_content_test.dart b/test/functional/no_content_test.dart new file mode 100644 index 00000000..ab73b3a2 --- /dev/null +++ b/test/functional/no_content_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/test/helper/shelf_request_response_converter.dart b/test/helper/shelf_request_response_converter.dart new file mode 100644 index 00000000..4a314305 --- /dev/null +++ b/test/helper/shelf_request_response_converter.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:json_api/server.dart'; +import 'package:shelf/shelf.dart' as shelf; + +class ShelfRequestResponseConverter + implements HttpAdapter { + @override + FutureOr createResponse( + int statusCode, String body, Map headers) => + shelf.Response(statusCode, body: body, headers: headers); + + @override + FutureOr getBody(shelf.Request request) => request.readAsString(); + + @override + FutureOr getMethod(shelf.Request request) => request.method; + + @override + FutureOr getUri(shelf.Request request) => request.requestedUri; +} diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index f8648f03..93f32c38 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -12,11 +12,6 @@ void main() { expect(id.id, '123'); }); - test('Has key', () { - expect(Resource('apples', '123').key, 'apples:123'); - expect(Resource('apples', null).key, 'apples:null'); - }); - test('toString', () { expect(Resource('appless', '42', attributes: {'color': 'red'}).toString(), 'Resource(appless:42 {color: red})'); diff --git a/test/functional/crud_test.dart b/tmp/crud_test.dart similarity index 93% rename from test/functional/crud_test.dart rename to tmp/crud_test.dart index c19ae802..6ea06669 100644 --- a/test/functional/crud_test.dart +++ b/tmp/crud_test.dart @@ -8,8 +8,8 @@ import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; -import '../../example/server/controller/crud_controller.dart'; -import '../../example/server/shelf_request_response_converter.dart'; +import '../../../example/server/controller/crud_controller.dart'; +import '../../../example/server/shelf_request_response_converter.dart'; /// Basic CRUD operations void main() async { @@ -62,7 +62,7 @@ void main() async { group('Fetch', () { test('a primary resource', () async { final r = await client.fetchResource(book.type, book.id); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().attributes['title'], 'Design Patterns'); expect(r.data.unwrap().toOne['publisher'].type, publisher.type); @@ -74,14 +74,14 @@ void main() async { test('a non-existing primary resource', () async { final r = await client.fetchResource('books', '1'); - expect(r.status, 404); + expect(r.statusCode, 404); expect(r.isSuccessful, isFalse); expect(r.document.errors.first.detail, 'Resource not found'); }); test('a primary collection', () async { final r = await client.fetchCollection('people'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().length, 4); expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); @@ -90,7 +90,7 @@ void main() async { test('a non-existing primary collection', () async { final r = await client.fetchCollection('unicorns'); - expect(r.status, 404); + expect(r.statusCode, 404); expect(r.isSuccessful, isFalse); expect(r.document.errors.first.detail, 'Collection not found'); }); @@ -98,7 +98,7 @@ void main() async { test('a related resource', () async { final r = await client.fetchRelatedResource(book.type, book.id, 'publisher'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().attributes['name'], 'Addison-Wesley'); }); @@ -106,7 +106,7 @@ void main() async { test('a related collection', () async { final r = await client.fetchRelatedCollection(book.type, book.id, 'authors'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().length, 4); expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); @@ -115,7 +115,7 @@ void main() async { test('a to-one relationship', () async { final r = await client.fetchToOne(book.type, book.id, 'publisher'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().type, publisher.type); expect(r.data.unwrap().id, publisher.id); @@ -123,7 +123,7 @@ void main() async { test('a generic to-one relationship', () async { final r = await client.fetchRelationship(book.type, book.id, 'publisher'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); final data = r.data; @@ -137,7 +137,7 @@ void main() async { test('a to-many relationship', () async { final r = await client.fetchToMany(book.type, book.id, 'authors'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().length, 4); expect(r.data.unwrap().first.type, people.first.type); @@ -146,7 +146,7 @@ void main() async { test('a generic to-many relationship', () async { final r = await client.fetchRelationship(book.type, book.id, 'authors'); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); final data = r.data; if (data is ToMany) { @@ -164,7 +164,7 @@ void main() async { await client.deleteResource(book.type, book.id); final r = await client.fetchResource(book.type, book.id); - expect(r.status, 404); + expect(r.statusCode, 404); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); }); @@ -193,7 +193,7 @@ void main() async { final book = Resource('books', null, attributes: {'title': 'The Lord of the Rings'}); final r0 = await client.createResource(book); - expect(r0.status, 201); + expect(r0.statusCode, 201); final r1 = await JsonApiClient().fetchResource(r0.location); expect(r1.data.unwrap().attributes, equals(book.attributes)); expect(r1.data.unwrap().type, equals(book.type)); @@ -205,7 +205,7 @@ void main() async { await client.updateResource(book.replace(attributes: {'pageCount': 416})); final r = await client.fetchResource(book.type, book.id); - expect(r.status, 200); + expect(r.statusCode, 200); expect(r.isSuccessful, isTrue); expect(r.data.unwrap().attributes['pageCount'], 416); }); diff --git a/test/functional/pagination_test.dart b/tmp/pagination_test.dart similarity index 92% rename from test/functional/pagination_test.dart rename to tmp/pagination_test.dart index 33717a37..b6bb8dff 100644 --- a/test/functional/pagination_test.dart +++ b/tmp/pagination_test.dart @@ -7,8 +7,8 @@ import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/server/controller/paginating_controller.dart'; -import '../../example/server/shelf_request_response_converter.dart'; +import '../../../example/server/controller/paginating_controller.dart'; +import '../../../example/server/shelf_request_response_converter.dart'; /// Pagination void main() async { diff --git a/test/functional/sorting_test.dart b/tmp/sorting_test.dart similarity index 95% rename from test/functional/sorting_test.dart rename to tmp/sorting_test.dart index c71d3ac2..9886f7d4 100644 --- a/test/functional/sorting_test.dart +++ b/tmp/sorting_test.dart @@ -7,8 +7,8 @@ import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; -import '../../example/server/controller/sorting_controller.dart'; -import '../../example/server/shelf_request_response_converter.dart'; +import '../../../example/server/controller/sorting_controller.dart'; +import '../../../example/server/shelf_request_response_converter.dart'; /// Sorting void main() async { From d7d633a4c7a8d96faa872d6cdc34d8eb065d1111 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Jan 2020 11:43:48 -0800 Subject: [PATCH 14/99] wip --- lib/server.dart | 5 + lib/src/document/identifier.dart | 3 + lib/src/document/resource.dart | 3 +- ..._memory.dart => in_memory_repository.dart} | 16 + lib/src/server/repository/repository.dart | 16 + lib/src/server/repository_controller.dart | 93 ++-- test/functional/basic_crud_test.dart | 419 +++++++++++++++++- test/helper/expect_resources_equal.dart | 10 + test/unit/document/identifier_test.dart | 8 + test/unit/document/resource_test.dart | 7 + 10 files changed, 543 insertions(+), 37 deletions(-) rename lib/src/server/repository/{in_memory.dart => in_memory_repository.dart} (86%) create mode 100644 test/helper/expect_resources_equal.dart create mode 100644 test/unit/document/identifier_test.dart diff --git a/lib/server.dart b/lib/server.dart index 71eb9267..1b684ae6 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,7 +5,12 @@ library server; export 'package:json_api/src/server/json_api_controller.dart'; export 'package:json_api/src/server/json_api_controller_base.dart'; +export 'package:json_api/src/server/json_api_request.dart'; export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/pagination.dart'; +export 'package:json_api/src/server/repository/in_memory_repository.dart'; +export 'package:json_api/src/server/repository/repository.dart'; +export 'package:json_api/src/server/repository_controller.dart'; export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/response_document_factory.dart'; export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 5b033127..82b8b848 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -34,4 +34,7 @@ class Identifier { @override bool operator ==(other) => equals(other); + + @override + int get hashCode => 0; } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 3cfb6767..67db9596 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -33,7 +33,8 @@ class Resource { Map> toMany}) : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), - toMany = Map.unmodifiable(toMany ?? {}) { + toMany = Map.unmodifiable( + (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { DocumentException.throwIfNull(type, "Resource 'type' must not be null"); } diff --git a/lib/src/server/repository/in_memory.dart b/lib/src/server/repository/in_memory_repository.dart similarity index 86% rename from lib/src/server/repository/in_memory.dart rename to lib/src/server/repository/in_memory_repository.dart index f3d3c452..9e306d7d 100644 --- a/lib/src/server/repository/in_memory.dart +++ b/lib/src/server/repository/in_memory_repository.dart @@ -72,6 +72,22 @@ class InMemoryRepository implements Repository { return updated; } + @override + FutureOr delete(String type, String id) async { + await get(type, id); + _collections[type].remove(id); + return null; + } + + @override + FutureOr> getCollection(String collection) { + if (_collections.containsKey(collection)) { + return Collection( + _collections[collection].values, _collections[collection].length); + } + throw CollectionNotFound("Collection '$collection' does not exist"); + } + InvalidType _invalidType(Resource resource, String collection) { return InvalidType( "Type '${resource.type}' does not belong in '$collection'"); diff --git a/lib/src/server/repository/repository.dart b/lib/src/server/repository/repository.dart index 72a23c2c..8e89e6e2 100644 --- a/lib/src/server/repository/repository.dart +++ b/lib/src/server/repository/repository.dart @@ -29,6 +29,22 @@ abstract class Repository { /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. FutureOr update(String collection, String id, Resource resource); + + /// Deletes the resource identified by [type] and [id] + FutureOr delete(String type, String id); + + /// Returns a collection of resources + FutureOr> getCollection(String collection); +} + +/// A collection of elements (e.g. resources) returned by the server. +class Collection { + final Iterable elements; + + /// Total count of the elements on the server. May be null. + final int total; + + Collection(this.elements, [this.total]); } class CollectionNotFound implements Exception { diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 3570ad0a..63aeeb5b 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -20,7 +20,7 @@ class RepositoryController implements JsonApiController { type, id, Resource(type, id, toMany: { - relationship: [...original.toMany[relationship], ...identifiers] + relationship: {...original.toMany[relationship], ...identifiers} })); return JsonApiResponse.toMany( type, id, relationship, updated.toMany[relationship]); @@ -37,42 +37,75 @@ class RepositoryController implements JsonApiController { @override FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers) { - // TODO: implement deleteFromRelationship - return null; - } + String id, String relationship, Iterable identifiers) => + _do(() async { + final original = await _repo.get(type, id); + final updated = await _repo.update( + type, + id, + Resource(type, id, toMany: { + relationship: {...original.toMany[relationship]} + ..removeAll(identifiers) + })); + return JsonApiResponse.toMany( + type, id, relationship, updated.toMany[relationship]); + }); @override - FutureOr deleteResource(R request, String type, String id) { - // TODO: implement deleteResource - return null; - } + FutureOr deleteResource(R request, String type, String id) => + _do(() async { + await _repo.delete(type, id); + return JsonApiResponse.noContent(); + }); @override - FutureOr fetchCollection(R request, String type) { - // TODO: implement fetchCollection - return null; - } + FutureOr fetchCollection(R request, String collection) => + _do(() async { + final c = await _repo.getCollection(collection); + return JsonApiResponse.collection(c.elements, total: c.total); + }); @override FutureOr fetchRelated( - R request, String type, String id, String relationship) { - // TODO: implement fetchRelated - return null; - } + R request, String type, String id, String relationship) => + _do(() async { + final resource = await _repo.get(type, id); + if (resource.toOne.containsKey(relationship)) { + final identifier = resource.toOne[relationship]; + return JsonApiResponse.resource( + await _repo.get(identifier.type, identifier.id)); + } + if (resource.toMany.containsKey(relationship)) { + final related = []; + for (final identifier in resource.toMany[relationship]) { + related.add(await _repo.get(identifier.type, identifier.id)); + } + return JsonApiResponse.collection(related); + } + return _relationshipNotFound(relationship, type, id); + }); @override FutureOr fetchRelationship( - R request, String type, String id, String relationship) { - // TODO: implement fetchRelationship - return null; - } + R request, String type, String id, String relationship) => + _do(() async { + final resource = await _repo.get(type, id); + if (resource.toOne.containsKey(relationship)) { + return JsonApiResponse.toOne( + type, id, relationship, resource.toOne[relationship]); + } + if (resource.toMany.containsKey(relationship)) { + return JsonApiResponse.toMany( + type, id, relationship, resource.toMany[relationship]); + } + return _relationshipNotFound(relationship, type, id); + }); @override - FutureOr fetchResource( - R request, String type, String id) async { - return JsonApiResponse.resource(await _repo.get(type, id)); - } + FutureOr fetchResource(R request, String type, String id) => + _do(() async { + return JsonApiResponse.resource(await _repo.get(type, id)); + }); @override FutureOr replaceToMany(R request, String type, String id, @@ -131,4 +164,14 @@ class RepositoryController implements JsonApiController { ]); } } + + JsonApiResponse _relationshipNotFound( + String relationship, String type, String id) { + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Relationship not found', + detail: "Relationship '$relationship' does not exist in '$type:$id'") + ]); + } } diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart index 1bb7dae6..6ce71e35 100644 --- a/test/functional/basic_crud_test.dart +++ b/test/functional/basic_crud_test.dart @@ -2,14 +2,16 @@ import 'dart:io'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/repository/in_memory.dart'; +import 'package:json_api/src/server/repository/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; +import '../helper/expect_resources_equal.dart'; import '../helper/shelf_request_response_converter.dart'; void main() async { @@ -157,7 +159,7 @@ void main() async { }); }, testOn: 'vm'); - group('Updating Resources and Relationships', () { + group('Updating and Fetching Resources and Relationships', () { setUp(() async { await client.createResource( Resource('people', '1', attributes: {'name': 'Martin Fowler'})); @@ -179,7 +181,7 @@ void main() async { })); }); - group('Resources', () { + group('Updating Resources', () { test('Update resource attributes and relationships', () async { final r = await client.updateResource(Resource('books', '1', attributes: { @@ -233,6 +235,305 @@ void main() async { }); }); + group('Fetching Resource', () { + test('successful', () async { + final r = await client.fetchResource('people', '1'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().id, '1'); + }); + + test('successful compound', () async { + final r = + await client.fetchResource('books', '1', parameters: Include([''])); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = await client.fetchResource('unicorns', '1'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchResource('people', '42'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'people'"); + }); + }); + + group('Fetching Resources with', () { + test('successful', () async { + final r = await client.fetchResource('people', '1'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = await client.fetchResource('unicorns', '1'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchResource('people', '42'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'people'"); + }); + }); + + group('Fetching primary collections', () { + test('successful', () async { + final r = await client.fetchCollection('people'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 3); + }); + test('404', () async { + final r = await client.fetchCollection('unicorns'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + }); + + group('Fetching Related Resources', () { + test('successful', () async { + final r = await client.fetchRelatedResource('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().type, 'companies'); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = + await client.fetchRelatedResource('unicorns', '1', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelatedResource('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelatedResource('books', '1', 'owner'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'owner' does not exist in 'books:1'"); + }); + }); + + group('Fetching Related Collections', () { + test('successful', () async { + final r = await client.fetchRelatedCollection('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); + }); + + test('404 on collection', () async { + final r = + await client.fetchRelatedCollection('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelatedCollection('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelatedCollection('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); + + group('Fetching a to-one relationship', () { + test('successful', () async { + final r = await client.fetchToOne('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().type, 'companies'); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = await client.fetchToOne('unicorns', '1', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchToOne('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchToOne('books', '1', 'owner'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'owner' does not exist in 'books:1'"); + }); + }); + + group('Fetching a to-many relationship', () { + test('successful', () async { + final r = await client.fetchToMany('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.type, 'people'); + }); + + test('404 on collection', () async { + final r = await client.fetchToMany('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchToMany('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchToMany('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); + + group('Fetching a generic relationship', () { + test('successful to-one', () async { + final r = await client.fetchRelationship('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + final rel = r.data; + if (rel is ToOne) { + expect(rel.unwrap().type, 'companies'); + expect(rel.unwrap().id, '1'); + } else { + fail('Not a ToOne relationship'); + } + }); + + test('successful to-many', () async { + final r = await client.fetchRelationship('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + final rel = r.data; + if (rel is ToMany) { + expect(rel.unwrap().length, 2); + expect(rel.unwrap().first.id, '1'); + expect(rel.unwrap().first.type, 'people'); + expect(rel.unwrap().last.id, '2'); + expect(rel.unwrap().last.type, 'people'); + } else { + fail('Not a ToMany relationship'); + } + }); + + test('404 on collection', () async { + final r = await client.fetchRelationship('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelationship('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect( + r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelationship('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); + group('Updatng a to-one relationship', () { test('successfully', () async { final r = await client.replaceToOne( @@ -343,7 +644,7 @@ void main() async { }); group('Adding to a to-many relationship', () { - test('successfully', () async { + test('successfully adding a new identifier', () async { final r = await client.addToRelationship( 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); @@ -356,6 +657,19 @@ void main() async { expect(r1.data.unwrap().toMany['authors'].length, 3); }); + test('successfully adding an existing identifier', () async { + final r = await client.addToRelationship( + 'books', '1', 'authors', [Identifier('people', '2')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 2); + }); + test('404 when collection not found', () async { final r = await client.addToRelationship( 'unicorns', '1', 'breed', [Identifier('companies', '3')]); @@ -380,13 +694,96 @@ void main() async { expect(error.detail, "Resource '42' does not exist in 'books'"); }); }); + + group('Deleting from a to-many relationship', () { + test('successfully deleting an identifier', () async { + final r = await client.deleteFromToMany( + 'books', '1', 'authors', [Identifier('people', '1')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 1); + expect(r.data.unwrap().first.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 1); + }); + + test('successfully deleting a non-present identifier', () async { + final r = await client.deleteFromToMany( + 'books', '1', 'authors', [Identifier('people', '3')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 2); + }); + + test('404 when collection not found', () async { + final r = await client.deleteFromToMany( + 'unicorns', '1', 'breed', [Identifier('companies', '1')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.deleteFromToMany( + 'books', '42', 'publisher', [Identifier('companies', '1')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); }, testOn: 'vm'); -} -void expectResourcesEqual(Resource a, Resource b) { - expect(a.type, equals(b.type)); - expect(a.id, equals(b.id)); - expect(a.attributes, equals(b.attributes)); - expect(a.toOne, equals(b.toOne)); - expect(a.toMany, equals(b.toMany)); + group('Deleting Resources', () { + setUp(() async { + await client.createResource(Resource('apples', '1')); + }); + test('successful', () async { + final r = await client.deleteResource('apples', '1'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('apples', '1'); + expect(r1.isSuccessful, isFalse); + expect(r1.statusCode, 404); + }); + + test('404 when the collection does not exist', () async { + final r = await client.deleteResource('unicorns', '42'); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when the resource does not exist', () async { + final r = await client.deleteResource('books', '42'); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }, testOn: 'vm'); } diff --git a/test/helper/expect_resources_equal.dart b/test/helper/expect_resources_equal.dart new file mode 100644 index 00000000..4a9898c2 --- /dev/null +++ b/test/helper/expect_resources_equal.dart @@ -0,0 +1,10 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void expectResourcesEqual(Resource a, Resource b) { + expect(a.type, equals(b.type)); + expect(a.id, equals(b.id)); + expect(a.attributes, equals(b.attributes)); + expect(a.toOne, equals(b.toOne)); + expect(a.toMany, equals(b.toMany)); +} diff --git a/test/unit/document/identifier_test.dart b/test/unit/document/identifier_test.dart new file mode 100644 index 00000000..05d485c4 --- /dev/null +++ b/test/unit/document/identifier_test.dart @@ -0,0 +1,8 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + test('equal identifiers are detected by Set', () { + expect({Identifier('foo', '1'), Identifier('foo', '1')}.length, 1); + }); +} diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 93f32c38..9b2d3178 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -12,6 +12,13 @@ void main() { expect(id.id, '123'); }); + test('Removes duplicate identifiers in toMany relationships', () { + final r = Resource('type', 'id', toMany: { + 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] + }); + expect(r.toMany['rel'].length, 1); + }); + test('toString', () { expect(Resource('appless', '42', attributes: {'color': 'red'}).toString(), 'Resource(appless:42 {color: red})'); From b1af864d40984587713e1b87e99aa11966e0f3c9 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 27 Jan 2020 00:19:06 -0800 Subject: [PATCH 15/99] wip --- example/server/controller/colors.dart | 24 -- .../server/controller/crud_controller.dart | 205 ------------------ .../controller/paginating_controller.dart | 24 -- .../server/controller/sorting_controller.dart | 32 --- example/server/server.dart | 13 +- lib/server.dart | 5 +- lib/src/document/link.dart | 17 +- lib/src/document/primary_data.dart | 11 +- lib/src/document/resource_data.dart | 9 +- lib/src/query/fields.dart | 8 +- lib/src/query/include.dart | 4 +- lib/src/query/sort.dart | 3 +- .../in_memory_repository.dart | 6 +- lib/src/server/json_api_controller_base.dart | 76 ------- lib/src/server/json_api_request.dart | 17 +- lib/src/server/json_api_response.dart | 78 +------ .../server/{repository => }/repository.dart | 0 lib/src/server/repository_controller.dart | 63 +++++- lib/src/server/response_document_factory.dart | 22 +- lib/src/server/target.dart | 33 ++- test/functional/basic_crud_test.dart | 20 +- test/functional/compound_document_test.dart | 171 +++++++++++++++ test/unit/document/link_test.dart | 41 ++++ test/unit/document/resource_data_test.dart | 36 +++ test/unit/query/fields_test.dart | 10 +- test/unit/query/include_test.dart | 14 +- test/unit/query/sort_test.dart | 12 +- test/unit/server/request_handler_test.dart | 141 +++++++++++- 28 files changed, 565 insertions(+), 530 deletions(-) delete mode 100644 example/server/controller/colors.dart delete mode 100644 example/server/controller/crud_controller.dart delete mode 100644 example/server/controller/paginating_controller.dart delete mode 100644 example/server/controller/sorting_controller.dart rename lib/src/server/{repository => }/in_memory_repository.dart (96%) delete mode 100644 lib/src/server/json_api_controller_base.dart rename lib/src/server/{repository => }/repository.dart (100%) create mode 100644 test/functional/compound_document_test.dart create mode 100644 test/unit/document/link_test.dart diff --git a/example/server/controller/colors.dart b/example/server/controller/colors.dart deleted file mode 100644 index 7630dc20..00000000 --- a/example/server/controller/colors.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:uuid/uuid.dart'; - -final Map colors = Map.fromIterable( - const [ - ['black', '000000'], - ['silver', 'c0c0c0'], - ['gray', '808080'], - ['white', 'ffffff'], - ['maroon', '800000'], - ['red', 'ff0000'], - ['purple', '800080'], - ['fuchsia', 'ff00ff'], - ['green', '008000'], - ['lime', '00ff00'], - ['olive', '808000'], - ['yellow', 'ffff00'], - ['navy', '000080'], - ['blue', '0000ff'], - ['teal', '008080'], - ['aqua', '00ffff'], - ].map((c) => Resource('colors', Uuid().v4(), - attributes: {'name': c[0], 'rgb': c[1]})), - key: (r) => r.generateId); diff --git a/example/server/controller/crud_controller.dart b/example/server/controller/crud_controller.dart deleted file mode 100644 index 04a898db..00000000 --- a/example/server/controller/crud_controller.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart' as shelf; - -/// This is an example controller allowing simple CRUD operations on resources. -class CRUDController implements JsonApiController { - /// Generates a new GUID - final String Function() generateId; - - /// Returns true is the [type] is supported by the server - final bool Function(String type) isTypeSupported; - final _store = >{}; - - CRUDController(this.generateId, this.isTypeSupported); - - @override - FutureOr createResource( - shelf.Request request, String type, Resource resource) { - if (resource.type != type) { - return JsonApiResponse.conflict( - [JsonApiError(detail: 'Incompatible type')]); - } - final repo = _repo(type); - if (resource.id != null) { - if (repo.containsKey(resource.id)) { - return JsonApiResponse.conflict( - [JsonApiError(detail: 'Resource already exists')]); - } - repo[resource.id] = resource; - return JsonApiResponse.noContent(); - } - final id = generateId(); - repo[id] = resource.replace(id: id); - return JsonApiResponse.resourceCreated(repo[id]); - } - - @override - FutureOr fetchResource( - shelf.Request request, String type, String id) { - final repo = _repo(type); - if (repo.containsKey(id)) { - return JsonApiResponse.resource(repo[id]); - } - return JsonApiResponse.notFound( - [JsonApiError(detail: 'Resource not found', status: '404')]); - } - - @override - FutureOr addToRelationship( - shelf.Request request, - String type, - String id, - String relationship, - Iterable identifiers) { - final resource = _repo(type)[id]; - final ids = [...resource.toMany[relationship], ...identifiers]; - _repo(type)[id] = - resource.replace(toMany: {...resource.toMany, relationship: ids}); - return JsonApiResponse.toMany(type, id, relationship, ids); - } - - @override - FutureOr deleteFromRelationship( - shelf.Request request, - String type, - String id, - String relationship, - Iterable identifiers) { - final resource = _repo(type)[id]; - final rel = [...resource.toMany[relationship]]; - rel.removeWhere(identifiers.contains); - final toMany = {...resource.toMany}; - toMany[relationship] = rel; - _repo(type)[id] = resource.replace(toMany: toMany); - - return JsonApiResponse.toMany(type, id, relationship, rel); - } - - @override - FutureOr deleteResource( - shelf.Request request, String type, String id) { - final repo = _repo(type); - if (!repo.containsKey(id)) { - return JsonApiResponse.notFound( - [JsonApiError(detail: 'Resource not found')]); - } - final resource = repo[id]; - repo.remove(id); - final relationships = {...resource.toOne, ...resource.toMany}; - if (relationships.isNotEmpty) { - return JsonApiResponse.meta({'relationships': relationships.length}); - } - return JsonApiResponse.noContent(); - } - - @override - FutureOr fetchCollection( - shelf.Request request, String type) { - final repo = _repo(type); -// final include = Include.fromUri(request.requestedUri); -// final includedResources = []; - - return JsonApiResponse.collection(repo.values); - } - - Iterable _getRelated(Resource resource, String relationship) { - if (resource.toOne.containsKey(relationship)) { - final related = _getResource(resource.toOne[relationship]); - if (related != null) { - return [related]; - } - } - if (resource.toMany.containsKey(relationship)) { - return resource.toMany[relationship] - .map(_getResource) - .skipWhile((_) => _ == null); - } - return []; - } - - Resource _getResource(Identifier id) { - if (id == null) return null; - return _repo(id.type)[id]; - } - - @override - FutureOr fetchRelated( - shelf.Request request, String type, String id, String relationship) { - final resource = _repo(type)[id]; - if (resource == null) { - return JsonApiResponse.notFound( - [JsonApiError(detail: 'Resource not found')]); - } - if (resource.toOne.containsKey(relationship)) { - final related = resource.toOne[relationship]; - if (related == null) { - return JsonApiResponse.relatedResource(null); - } - return JsonApiResponse.relatedResource(_repo(related.type)[related.id]); - } - if (resource.toMany.containsKey(relationship)) { - return JsonApiResponse.relatedCollection( - resource.toMany[relationship].map((r) => _repo(r.type)[r.id])); - } - return JsonApiResponse.notFound( - [JsonApiError(detail: 'Relatioship not found')]); - } - - @override - FutureOr fetchRelationship( - shelf.Request request, String type, String id, String relationship) { - final r = _repo(type)[id]; - if (r.toOne.containsKey(relationship)) { - return JsonApiResponse.toOne( - type, id, relationship, r.toOne[relationship]); - } - if (r.toMany.containsKey(relationship)) { - return JsonApiResponse.toMany( - type, id, relationship, r.toMany[relationship]); - } - return JsonApiResponse.notFound( - [JsonApiError(detail: 'Relationship not found')]); - } - - @override - FutureOr updateResource( - shelf.Request request, String type, String id, Resource resource) { - final current = _repo(type)[id]; -// if (resource.hasAllMembersOf(current)) { -// _repo(type)[id] = resource; -// return JsonApiResponse.noContent(); -// } -// _repo(type)[id] = resource.withExtraMembersFrom(current); - return JsonApiResponse.resourceUpdated(_repo(type)[id]); - } - - @override - FutureOr replaceToMany(shelf.Request request, String type, - String id, String relationship, Iterable identifiers) { - final resource = _repo(type)[id]; - final toMany = {...resource.toMany, relationship: identifiers.toList()}; - _repo(type)[id] = resource.replace(toMany: toMany); - return JsonApiResponse.toMany(type, id, relationship, identifiers); - } - - @override - FutureOr replaceToOne(shelf.Request request, String type, - String id, String relationship, Identifier identifier) { - _repo(type)[id] = - _repo(type)[id].replace(toOne: {relationship: identifier}); - return JsonApiResponse.noContent(); - } - - Map _repo(String type) { - if (!isTypeSupported(type)) { - throw JsonApiResponse.notFound( - [JsonApiError(detail: 'Collection not found')]); - } - - _store.putIfAbsent(type, () => {}); - return _store[type]; - } -} diff --git a/example/server/controller/paginating_controller.dart b/example/server/controller/paginating_controller.dart deleted file mode 100644 index 51c7af6e..00000000 --- a/example/server/controller/paginating_controller.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:shelf/shelf.dart' as shelf; - -import 'colors.dart'; - -class PaginatingController extends JsonApiControllerBase { - final Pagination _pagination; - - PaginatingController(this._pagination); - - @override - FutureOr fetchCollection( - shelf.Request request, String type) { - final page = Page.fromUri(request.requestedUri); - final offset = _pagination.offset(page); - final limit = _pagination.limit(page); - return JsonApiResponse.collection(colors.values.skip(offset).take(limit), - total: colors.length); - } -} diff --git a/example/server/controller/sorting_controller.dart b/example/server/controller/sorting_controller.dart deleted file mode 100644 index 424d1b8b..00000000 --- a/example/server/controller/sorting_controller.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart' as shelf; -import 'package:uuid/uuid.dart'; - -class SortingController extends JsonApiControllerBase { - @override - FutureOr fetchCollection( - shelf.Request request, String type) { - final sort = Sort.fromUri(request.requestedUri); - final namesSorted = [...names]; - sort.toList().reversed.forEach((field) { - namesSorted.sort((a, b) { - final attrA = a.attributes[field.name].toString(); - final attrB = b.attributes[field.name].toString(); - if (attrA == attrB) return 0; - return attrA.compareTo(attrB) * field.comparisonFactor; - }); - }); - return JsonApiResponse.collection(namesSorted); - } -} - -final firstNames = const ['Emma', 'Liam', 'Olivia', 'Noah']; -final lastNames = const ['Smith', 'Johnson', 'Williams', 'Brown']; -final names = firstNames - .map((first) => lastNames.map((last) => Resource('names', Uuid().v4(), - attributes: {'firstName': first, 'lastName': last}))) - .expand((_) => _); diff --git a/example/server/server.dart b/example/server/server.dart index 49db6081..2755aa2c 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -6,7 +6,6 @@ import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; import 'package:uuid/uuid.dart'; -import 'controller/crud_controller.dart'; import 'shelf_request_response_converter.dart'; /// This example shows how to build a simple CRUD server on top of Dart Shelf @@ -16,10 +15,10 @@ void main() async { final baseUri = Uri(scheme: 'http', host: host, port: port); /// You may also try PaginatingController - final controller = CRUDController(Uuid().v4, (_) => true); - final jsonApiHandler = RequestHandler( - ShelfRequestResponseConverter(), controller, UriDesign.standard(baseUri)); - - await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); - print('Serving at $baseUri'); +// final controller = CRUDController(Uuid().v4, (_) => true); +// final jsonApiHandler = RequestHandler( +// ShelfRequestResponseConverter(), controller, UriDesign.standard(baseUri)); +// +// await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); +// print('Serving at $baseUri'); } diff --git a/lib/server.dart b/lib/server.dart index 1b684ae6..d0e36def 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,13 +3,12 @@ /// The server API is not stable. Expect breaking changes. library server; +export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_controller.dart'; -export 'package:json_api/src/server/json_api_controller_base.dart'; export 'package:json_api/src/server/json_api_request.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/pagination.dart'; -export 'package:json_api/src/server/repository/in_memory_repository.dart'; -export 'package:json_api/src/server/repository/repository.dart'; +export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/response_document_factory.dart'; diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 6926aa2f..d9d78a2c 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -12,7 +12,12 @@ class Link { /// Reconstructs the link from the [json] object static Link fromJson(Object json) { if (json is String) return Link(Uri.parse(json)); - if (json is Map) return LinkObject.fromJson(json); + if (json is Map) { + final href = json['href']; + if (href is String) { + return LinkObject(Uri.parse(href), meta: json['meta']); + } + } throw DocumentException( 'A JSON:API link must be a JSON string or a JSON object'); } @@ -40,16 +45,6 @@ class LinkObject extends Link { LinkObject(Uri href, {this.meta}) : super(href); - static LinkObject fromJson(Object json) { - if (json is Map) { - final href = json['href']; - if (href is String) { - return LinkObject(Uri.parse(href), meta: json['meta']); - } - } - throw DocumentException('A JSON:API link object must be a JSON object'); - } - @override Object toJson() => { 'href': uri.toString(), diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index dfcacdfe..6ca4da90 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -9,13 +9,14 @@ import 'package:json_api/src/document/resource_object.dart'; abstract class PrimaryData { /// In a Compound document this member contains the included resources. /// May be empty or null. - final Iterable included; + final List included; /// The top-level `links` object. May be empty or null. final Map links; PrimaryData({Iterable included, Map links}) - : included = (included == null) ? null : List.unmodifiable(included), + : included = + (included == null) ? null : List.unmodifiable(_unique(included)), links = (links == null) ? null : Map.unmodifiable(links); /// The `self` link. May be null. @@ -23,7 +24,7 @@ abstract class PrimaryData { /// Documents with included resources are called compound /// Details: http://jsonapi.org/format/#document-compound-documents - bool get isCompound => included != null && included.isNotEmpty; + bool get isCompound => included != null; /// Top-level JSON object Map toJson() => { @@ -31,3 +32,7 @@ abstract class PrimaryData { if (included != null) ...{'included': included} }; } + +Iterable _unique(Iterable included) => + Map.fromIterable(included, + key: (_) => '${_.type}:${_.id}').values; diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 77e6b958..828ea5fa 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -16,15 +16,16 @@ class ResourceData extends PrimaryData { static ResourceData fromJson(Object json) { if (json is Map) { + Iterable resources; final included = json['included']; - final resources = []; if (included is List) { - resources.addAll(included.map(ResourceObject.fromJson)); + resources = included.map(ResourceObject.fromJson); + } else if (included != null) { + throw DocumentException("The 'included' value must be a JSON array"); } final data = nullable(ResourceObject.fromJson)(json['data']); return ResourceData(data, - links: Link.mapFromJson(json['links'] ?? {}), - included: resources.isNotEmpty ? resources : null); + links: Link.mapFromJson(json['links'] ?? {}), included: resources); } throw DocumentException( "A JSON:API resource document must be a JSON object and contain the 'data' member"); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 40aa1611..0ca58dbc 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -13,13 +13,15 @@ class Fields extends QueryParameters { /// ``` /// ?fields[articles]=title,body&fields[people]=name /// ``` - Fields(Map> fields) + Fields(Map> fields) : _fields = {...fields}, super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); /// Extracts the requested fields from the [uri]. - static Fields fromUri(Uri uri) => Fields(uri.queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.split(','))) + static Fields fromUri(Uri uri) => + Fields(uri.queryParametersAll.map((k, v) => MapEntry( + _regex.firstMatch(k)?.group(1), + v.expand((_) => _.split(',')).toList())) ..removeWhere((k, v) => k == null)); List operator [](String key) => _fields[key]; diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index dbdff279..12ea56d4 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -17,8 +17,8 @@ class Include extends QueryParameters with IterableMixin { : _resources = [...resources], super({'include': resources.join(',')}); - static Include fromUri(Uri uri) => - Include((uri.queryParameters['include'] ?? '').split(',')); + static Include fromUri(Uri uri) => Include( + (uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? [])); @override Iterator get iterator => _resources.iterator; diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 533e3181..108e98c8 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -21,7 +21,8 @@ class Sort extends QueryParameters with IterableMixin { super({'sort': fields.join(',')}); static Sort fromUri(Uri uri) => - Sort((uri.queryParameters['sort'] ?? '').split(',').map(SortField.parse)); + Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? []) + .map(SortField.parse)); @override Iterator get iterator => _fields.iterator; diff --git a/lib/src/server/repository/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart similarity index 96% rename from lib/src/server/repository/in_memory_repository.dart rename to lib/src/server/in_memory_repository.dart index 9e306d7d..b2ad8c4c 100644 --- a/lib/src/server/repository/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; -import 'package:json_api/src/server/repository/repository.dart'; +import 'package:json_api/src/server/repository.dart'; typedef IdGenerator = String Function(String collection); typedef TypeAttributionCriteria = bool Function(String collection, String type); @@ -42,7 +42,7 @@ class InMemoryRepository implements Repository { } @override - FutureOr get(String collection, String id) { + FutureOr get(String collection, String id) async { if (_collections.containsKey(collection)) { final resource = _collections[collection][id]; if (resource == null) { @@ -80,7 +80,7 @@ class InMemoryRepository implements Repository { } @override - FutureOr> getCollection(String collection) { + FutureOr> getCollection(String collection) async { if (_collections.containsKey(collection)) { return Collection( _collections[collection].values, _collections[collection].length); diff --git a/lib/src/server/json_api_controller_base.dart b/lib/src/server/json_api_controller_base.dart deleted file mode 100644 index 30b1931c..00000000 --- a/lib/src/server/json_api_controller_base.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; - -abstract class JsonApiControllerBase implements JsonApiController { - @override - FutureOr addToRelationship(R request, String type, String id, - String relationship, Iterable identifiers) { - throw _forbidden; - } - - @override - FutureOr createResource( - request, String type, Resource resource) { - throw _forbidden; - } - - @override - FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers) { - throw _forbidden; - } - - @override - FutureOr deleteResource(R request, String type, String id) { - throw _forbidden; - } - - @override - FutureOr fetchCollection(R request, String type) { - throw _forbidden; - } - - @override - FutureOr fetchRelated( - request, String type, String id, String relationship) { - throw _forbidden; - } - - @override - FutureOr fetchRelationship( - request, String type, String id, String relationship) { - throw _forbidden; - } - - @override - FutureOr fetchResource(R request, String type, String id) { - throw _forbidden; - } - - @override - FutureOr replaceToMany(R request, String type, String id, - String relationship, Iterable identifiers) { - throw _forbidden; - } - - @override - FutureOr replaceToOne(R request, String type, String id, - String relationship, Identifier identifier) { - throw _forbidden; - } - - @override - FutureOr updateResource( - request, String type, String id, Resource resource) { - throw _forbidden; - } - - final _forbidden = JsonApiResponse.forbidden([ - JsonApiError( - status: '403', - detail: 'This request is not supported by the server', - title: '403 Forbidden') - ]); -} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index 6a6e1598..6834edce 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -13,8 +13,9 @@ abstract class JsonApiRequest { static JsonApiRequest createResource(String type) => _CreateResource(type); - static JsonApiRequest invalidRequest(String method) => - _InvalidRequest(method); + /// Creates a request which always returns the [response] + static JsonApiRequest predefinedResponse(JsonApiResponse response) => + _PredefinedResponse(response); static JsonApiRequest fetchResource(String type, String id) => _FetchResource(type, id); @@ -151,17 +152,15 @@ class _FetchResource implements JsonApiRequest { _FetchResource(this.type, this.id); } -class _InvalidRequest implements JsonApiRequest { - final String method; +class _PredefinedResponse implements JsonApiRequest { + final JsonApiResponse response; @override FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - // TODO: implement call - return null; - } + JsonApiController controller, Object jsonPayload, R request) => + response; - _InvalidRequest(this.method); + _PredefinedResponse(this.response); } class _UpdateRelationship implements JsonApiRequest { diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 4a2bbc30..f07eed56 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -17,10 +17,6 @@ abstract class JsonApiResponse { static JsonApiResponse meta(Map meta) => _Meta(meta); - static JsonApiResponse relatedCollection(Iterable collection, - {Iterable included, int total}) => - _RelatedCollection(collection, included: included, total: total); - static JsonApiResponse collection(Iterable collection, {Iterable included, int total}) => _Collection(collection, included: included, total: total); @@ -29,16 +25,9 @@ abstract class JsonApiResponse { {Iterable included}) => _Resource(resource, included: included); - static JsonApiResponse relatedResource(Resource resource, - {Iterable included}) => - _RelatedResource(resource, included: included); - static JsonApiResponse resourceCreated(Resource resource) => _ResourceCreated(resource); - static JsonApiResponse resourceUpdated(Resource resource) => - _ResourceUpdated(resource); - static JsonApiResponse seeOther(String type, String id) => _SeeOther(type, id); @@ -63,8 +52,10 @@ abstract class JsonApiResponse { static JsonApiResponse notFound(Iterable errors) => _Error(404, errors); - static JsonApiResponse methodNotAllowed(Iterable errors) => - _Error(405, errors); + /// The allowed methods can be specified in [allow] + static JsonApiResponse methodNotAllowed(Iterable errors, + {Iterable allow}) => + _Error(405, errors, headers: {'Allow': allow.join(', ')}); static JsonApiResponse conflict(Iterable errors) => _Error(409, errors); @@ -123,8 +114,10 @@ class _Accepted extends JsonApiResponse { class _Error extends JsonApiResponse { final Iterable errors; + final Map headers; - const _Error(int status, this.errors) : super(status); + const _Error(int status, this.errors, {this.headers = const {}}) + : super(status); @override Document buildDocument(ResponseDocumentFactory builder, Uri self) => @@ -132,7 +125,7 @@ class _Error extends JsonApiResponse { @override Map buildHeaders(UriDesign design) => - {'Content-Type': Document.contentType}; + {...headers, 'Content-Type': Document.contentType}; } class _Meta extends JsonApiResponse { @@ -149,34 +142,16 @@ class _Meta extends JsonApiResponse { {'Content-Type': Document.contentType}; } -class _RelatedCollection extends JsonApiResponse { - final Iterable collection; - final Iterable included; - final int total; - - const _RelatedCollection(this.collection, {this.included, this.total}) - : super(200); - - @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => - builder.makeRelatedCollectionDocument(self, collection, total: total); - - @override - Map buildHeaders(UriDesign design) => - {'Content-Type': Document.contentType}; -} - -class _RelatedResource extends JsonApiResponse { +class _Resource extends JsonApiResponse { final Resource resource; final Iterable included; - const _RelatedResource(this.resource, {this.included}) : super(200); + const _Resource(this.resource, {this.included}) : super(200); @override Document buildDocument( ResponseDocumentFactory builder, Uri self) => - builder.makeRelatedResourceDocument(self, resource); + builder.makeResourceDocument(self, resource, included: included); @override Map buildHeaders(UriDesign design) => @@ -202,37 +177,6 @@ class _ResourceCreated extends JsonApiResponse { }; } -class _Resource extends JsonApiResponse { - final Resource resource; - final Iterable included; - - const _Resource(this.resource, {this.included}) : super(200); - - @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource, included: included); - - @override - Map buildHeaders(UriDesign design) => - {'Content-Type': Document.contentType}; -} - -class _ResourceUpdated extends JsonApiResponse { - final Resource resource; - - _ResourceUpdated(this.resource) : super(200); - - @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource); - - @override - Map buildHeaders(UriDesign design) => - {'Content-Type': Document.contentType}; -} - class _SeeOther extends JsonApiResponse { final String type; final String id; diff --git a/lib/src/server/repository/repository.dart b/lib/src/server/repository.dart similarity index 100% rename from lib/src/server/repository/repository.dart rename to lib/src/server/repository.dart diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 63aeeb5b..a02f7766 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,15 +1,19 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/repository/repository.dart'; +import 'package:json_api/src/server/repository.dart'; + +typedef UriReader = FutureOr Function(R request); /// An opinionated implementation of [JsonApiController] class RepositoryController implements JsonApiController { final Repository _repo; + final UriReader _getUri; - RepositoryController(this._repo); + RepositoryController(this._repo, this._getUri); @override FutureOr addToRelationship(R request, String type, String id, @@ -62,7 +66,18 @@ class RepositoryController implements JsonApiController { FutureOr fetchCollection(R request, String collection) => _do(() async { final c = await _repo.getCollection(collection); - return JsonApiResponse.collection(c.elements, total: c.total); + final uri = _getUri(request); + final include = Include.fromUri(uri); + + final resources = []; + for (final resource in c.elements) { + for (final path in include) { + resources.addAll(await _getRelated(resource, path.split('.'))); + } + } + + return JsonApiResponse.collection(c.elements, + total: c.total, included: include.isEmpty ? null : resources); }); @override @@ -72,13 +87,12 @@ class RepositoryController implements JsonApiController { final resource = await _repo.get(type, id); if (resource.toOne.containsKey(relationship)) { final identifier = resource.toOne[relationship]; - return JsonApiResponse.resource( - await _repo.get(identifier.type, identifier.id)); + return JsonApiResponse.resource(await _getByIdentifier(identifier)); } if (resource.toMany.containsKey(relationship)) { final related = []; for (final identifier in resource.toMany[relationship]) { - related.add(await _repo.get(identifier.type, identifier.id)); + related.add(await _getByIdentifier(identifier)); } return JsonApiResponse.collection(related); } @@ -104,9 +118,44 @@ class RepositoryController implements JsonApiController { @override FutureOr fetchResource(R request, String type, String id) => _do(() async { - return JsonApiResponse.resource(await _repo.get(type, id)); + final uri = _getUri(request); + final include = Include.fromUri(uri); + final resource = await _repo.get(type, id); + final resources = []; + for (final path in include) { + resources.addAll(await _getRelated(resource, path.split('.'))); + } + return JsonApiResponse.resource(resource, + included: include.isEmpty ? null : resources); }); + FutureOr _getByIdentifier(Identifier identifier) => + _repo.get(identifier.type, identifier.id); + + Future> _getRelated( + Resource resource, + Iterable path, + ) async { + if (path.isEmpty) return []; + final resources = []; + final ids = []; + + if (resource.toOne.containsKey(path.first)) { + ids.add(resource.toOne[path.first]); + } else if (resource.toMany.containsKey(path.first)) { + ids.addAll(resource.toMany[path.first]); + } + for (final id in ids) { + final r = await _getByIdentifier(id); + if (path.length > 1) { + resources.addAll(await _getRelated(r, path.skip(1))); + } else { + resources.add(r); + } + } + return resources; + } + @override FutureOr replaceToMany(R request, String type, String id, String relationship, Iterable identifiers) => diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index 99765e92..1c99cc59 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -9,7 +9,7 @@ class ResponseDocumentFactory { Document makeErrorDocument(Iterable errors) => Document.error(errors, api: _api); - /// A document containing a collection of (primary) resources + /// A document containing a collection of resources Document makeCollectionDocument( Uri self, Iterable collection, {int total, Iterable included}) => @@ -19,16 +19,7 @@ class ResponseDocumentFactory { 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), - links: {'self': Link(self), ..._navigation(self, total)}), - api: _api); - - /// A document containing a single (primary) resource + /// A document containing a single resource Document makeResourceDocument(Uri self, Resource resource, {Iterable included}) => Document( @@ -51,15 +42,6 @@ class ResponseDocumentFactory { makeResourceDocument( _urlFactory.resourceUri(resource.type, resource.id), resource); - /// A document containing a single related resource - Document makeRelatedResourceDocument( - Uri self, Resource resource, {Iterable included}) => - Document( - ResourceData(nullable(_resourceObject)(resource), - links: {'self': Link(self)}, - included: included?.map(_resourceObject)), - api: _api); - /// A document containing a to-many relationship Document makeToManyDocument( Uri self, diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index 0e231ff6..c6690821 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -1,4 +1,6 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/uri_design.dart'; /// The target of a JSON:API request URI. The URI target and the request method @@ -54,7 +56,7 @@ class _Collection implements Target { case 'POST': return JsonApiRequest.createResource(type); default: - return JsonApiRequest.invalidRequest(method); + return _methodNoAllowed(['GET', 'POST']); } } } @@ -72,14 +74,14 @@ class _Resource implements Target { @override JsonApiRequest getRequest(String method) { switch (method.toUpperCase()) { - case 'GET': - return JsonApiRequest.fetchResource(type, id); case 'DELETE': return JsonApiRequest.deleteResource(type, id); + case 'GET': + return JsonApiRequest.fetchResource(type, id); case 'PATCH': return JsonApiRequest.updateResource(type, id); default: - return JsonApiRequest.invalidRequest(method); + return _methodNoAllowed(['DELETE', 'GET', 'PATCH']); } } } @@ -103,7 +105,7 @@ class _Related implements Target { case 'GET': return JsonApiRequest.fetchRelated(type, id, relationship); default: - return JsonApiRequest.invalidRequest(method); + return _methodNoAllowed(['GET']); } } } @@ -124,16 +126,16 @@ class _Relationship implements Target { @override JsonApiRequest getRequest(String method) { switch (method.toUpperCase()) { + case 'DELETE': + return JsonApiRequest.deleteFromRelationship(type, id, relationship); case 'GET': return JsonApiRequest.fetchRelationship(type, id, relationship); case 'PATCH': return JsonApiRequest.updateRelationship(type, id, relationship); case 'POST': return JsonApiRequest.addToRelationship(type, id, relationship); - case 'DELETE': - return JsonApiRequest.deleteFromRelationship(type, id, relationship); default: - return JsonApiRequest.invalidRequest(method); + return _methodNoAllowed(['DELETE', 'GET', 'PATCH', 'POST']); } } } @@ -146,5 +148,18 @@ class _Invalid implements Target { @override JsonApiRequest getRequest(String method) => - JsonApiRequest.invalidRequest(method); + JsonApiRequest.predefinedResponse(JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server') + ])); } + +JsonApiRequest _methodNoAllowed(Iterable allow) => + JsonApiRequest.predefinedResponse(JsonApiResponse.methodNotAllowed([ + JsonApiError( + status: '405', + title: 'Method Not Allowed', + detail: 'Allowed methods: ${allow.join(', ')}') + ], allow: allow)); diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart index 6ce71e35..81be67f4 100644 --- a/test/functional/basic_crud_test.dart +++ b/test/functional/basic_crud_test.dart @@ -4,7 +4,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/repository/in_memory_repository.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:json_api/uri_design.dart'; import 'package:shelf/shelf_io.dart'; @@ -24,17 +24,19 @@ void main() async { setUp(() async { client = UriAwareClient(design); + final repository = InMemoryRepository({ + 'books': {}, + 'people': {}, + 'companies': {}, + 'noServerId': {}, + 'fruits': {}, + 'apples': {} + }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4()); server = await serve( RequestHandler( ShelfRequestResponseConverter(), - RepositoryController(InMemoryRepository({ - 'books': {}, - 'people': {}, - 'companies': {}, - 'noServerId': {}, - 'fruits': {}, - 'apples': {} - }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4())), + RepositoryController( + repository, ShelfRequestResponseConverter().getUri), design), host, port); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart new file mode 100644 index 00000000..bb35f5d7 --- /dev/null +++ b/test/functional/compound_document_test.dart @@ -0,0 +1,171 @@ +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:test/test.dart'; + +import '../helper/expect_resources_equal.dart'; +import '../helper/shelf_request_response_converter.dart'; + +void main() async { + HttpServer server; + UriAwareClient client; + final host = 'localhost'; + final port = 8082; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + final wonderland = + Resource('countries', '1', attributes: {'name': 'Wonderland'}); + final alice = Resource('people', '1', + attributes: {'name': 'Alice'}, + toOne: {'birthplace': Identifier.of(wonderland)}); + final bob = Resource('people', '2', + attributes: {'name': 'Bob'}, + toOne: {'birthplace': Identifier.of(wonderland)}); + final comment1 = Resource('comments', '1', + attributes: {'text': 'First comment!'}, + toOne: {'author': Identifier.of(bob)}); + final comment2 = Resource('comments', '2', + attributes: {'text': 'Oh hi Bob'}, + toOne: {'author': Identifier.of(alice)}); + final post = Resource('posts', '1', attributes: { + 'title': 'Hello World' + }, toOne: { + 'author': Identifier.of(alice) + }, toMany: { + 'comments': [Identifier.of(comment1), Identifier.of(comment2)], + 'tags': [] + }); + + setUp(() async { + client = UriAwareClient(design); + final repository = InMemoryRepository({ + 'posts': {'1': post}, + 'comments': {'1': comment1, '2': comment2}, + 'people': {'1': alice, '2': bob}, + 'countries': {'1': wonderland}, + 'tags': {} + }); + final converter = ShelfRequestResponseConverter(); + final controller = RepositoryController(repository, converter.getUri); + server = + await serve(RequestHandler(converter, controller, design), host, port); + }); + + tearDown(() async { + client.close(); + await server.close(); + }); + + group('Compound document', () { + group('Single Resouces', () { + test('included == null by default', () async { + final r = await client.fetchResource('posts', '1'); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included, isNull); + }); + + test('included == [] when requested but nothing to include', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['tags'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included, []); + }); + + test('can include first-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + }); + + test('can include second-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments.author'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included.first.unwrap(), bob); + expectResourcesEqual(r.data.included.last.unwrap(), alice); + }); + + test('can include third-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments.author.birthplace'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 1); + expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + }); + + test('can include first- and second-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments', 'comments.author'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 4); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expectResourcesEqual(r.data.included[2].unwrap(), bob); + expectResourcesEqual(r.data.included[3].unwrap(), alice); + }); + }); + + group('Resource Collection', () { + test('included == null by default', () async { + final r = await client.fetchCollection('posts'); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included, isNull); + }); + + test('included == [] when requested but nothing to include', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['tags'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included, []); + }); + + test('can include first-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + }); + + test('can include second-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments.author'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included.first.unwrap(), bob); + expectResourcesEqual(r.data.included.last.unwrap(), alice); + }); + + test('can include third-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments.author.birthplace'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 1); + expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + }); + + test('can include first- and second-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments', 'comments.author'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 4); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expectResourcesEqual(r.data.included[2].unwrap(), bob); + expectResourcesEqual(r.data.included[3].unwrap(), alice); + }); + }); + }, testOn: 'vm'); +} diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart new file mode 100644 index 00000000..b17245c9 --- /dev/null +++ b/test/unit/document/link_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/document_exception.dart'; +import 'package:test/test.dart'; + +void main() { + test('link can encoded and decoded', () { + final link = Link(Uri.parse('http://example.com')); + expect(Link.fromJson(json.decode(json.encode(link))).uri.toString(), + 'http://example.com'); + }); + + test('link object can be parsed from JSON', () { + final link = + LinkObject(Uri.parse('http://example.com'), meta: {'foo': 'bar'}); + + final parsed = Link.fromJson(json.decode(json.encode(link))); + expect(parsed.uri.toString(), 'http://example.com'); + if (parsed is LinkObject) { + expect(parsed.meta['foo'], 'bar'); + } else { + fail('LinkObject expected'); + } + }); + + test('a map of link object can be parsed from JSON', () { + final links = Link.mapFromJson({ + 'first': 'http://example.com/first', + 'last': 'http://example.com/last' + }); + expect(links['first'].uri.toString(), 'http://example.com/first'); + expect(links['last'].uri.toString(), 'http://example.com/last'); + }); + + test('link throws DocumentException on invalid JSON', () { + expect(() => Link.fromJson([]), throwsA(TypeMatcher())); + expect( + () => Link.mapFromJson([]), throwsA(TypeMatcher())); + }); +} diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 4c4c71ec..a788871f 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:test/test.dart'; void main() { @@ -40,6 +41,41 @@ void main() { expect(data.self.toString(), '/self'); }); + group('included resources decoding', () { + test('null decodes to null', () { + final data = ResourceData.fromJson(json.decode(json.encode({ + 'data': {'type': 'apples', 'id': '1'} + }))); + expect(data.included, isNull); + }); + test('[] decodes to []', () { + final data = ResourceData.fromJson(json.decode(json.encode({ + 'data': {'type': 'apples', 'id': '1'}, + 'included': [] + }))); + expect(data.included, equals([])); + }); + test('non empty [] decodes to non-emoty []', () { + final data = ResourceData.fromJson(json.decode(json.encode({ + 'data': {'type': 'apples', 'id': '1'}, + 'included': [ + { + 'data': {'type': 'oranges', 'id': '1'} + } + ] + }))); + expect(data.included, isNotEmpty); + }); + test('invalid value throws DocumentException', () { + expect( + () => ResourceData.fromJson(json.decode(json.encode({ + 'data': {'type': 'apples', 'id': '1'}, + 'included': {} + }))), + throwsA(TypeMatcher())); + }); + }); + group('custom links', () { final res = ResourceObject('apples', '1'); test('recognizes custom links', () { diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index 70d25c00..bc56b1d6 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -2,7 +2,7 @@ import 'package:json_api/src/query/fields.dart'; import 'package:test/test.dart'; void main() { - test('Can decode url', () { + test('Can decode url without duplicate keys', () { final uri = Uri.parse( '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); final fields = Fields.fromUri(uri); @@ -10,6 +10,14 @@ void main() { expect(fields['people'], ['name']); }); + test('Can decode url with duplicate keys', () { + final uri = Uri.parse( + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); + final fields = Fields.fromUri(uri); + expect(fields['articles'], ['title', 'body']); + expect(fields['people'], ['name', 'age']); + }); + test('Can add to uri', () { final fields = Fields({ 'articles': ['title', 'body'], diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index 4cb87dc9..02303ec9 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -2,13 +2,19 @@ import 'package:json_api/src/query/include.dart'; import 'package:test/test.dart'; void main() { - test('Can decode url', () { + test('Can decode url without duplicate keys', () { final uri = Uri.parse('/articles/1?include=author,comments.author'); final include = Include.fromUri(uri); - expect(include.length, 2); - expect(include.first, 'author'); - expect(include.last, 'comments.author'); + expect(include, equals(['author', 'comments.author'])); }); + + test('Can decode url with duplicate keys', () { + final uri = + Uri.parse('/articles/1?include=author,comments.author&include=tags'); + final include = Include.fromUri(uri); + expect(include, equals(['author', 'comments.author', 'tags'])); + }); + test('Can add to uri', () { final uri = Uri.parse('/articles/1'); final include = Include(['author', 'comments.author']); diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index 133a171f..2de4d793 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -2,7 +2,7 @@ import 'package:json_api/src/query/sort.dart'; import 'package:test/test.dart'; void main() { - test('Can decode url', () { + test('Can decode url wthout duplicate keys', () { final uri = Uri.parse('/articles?sort=-created,title'); final sort = Sort.fromUri(uri); expect(sort.length, 2); @@ -12,6 +12,16 @@ void main() { expect(sort.last.name, 'title'); }); + test('Can decode url with duplicate keys', () { + final uri = Uri.parse('/articles?sort=-created&sort=title'); + final sort = Sort.fromUri(uri); + expect(sort.length, 2); + expect(sort.first.isDesc, true); + expect(sort.first.name, 'created'); + expect(sort.last.isAsc, true); + expect(sort.last.name, 'title'); + }); + test('Can add to uri', () { final sort = Sort([Desc('created'), Asc('title')]); final uri = Uri.parse('/articles'); diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/request_handler_test.dart index 58659188..62bd9e2f 100644 --- a/test/unit/server/request_handler_test.dart +++ b/test/unit/server/request_handler_test.dart @@ -14,7 +14,7 @@ void main() { group('HTTP Handler', () { test('returns `bad request` on incomplete relationship', () async { final rq = TestRequest( - uriDesign.relationshipUri('books', '1', 'author'), 'patch', '{}'); + uriDesign.relationshipUri('books', '1', 'author'), 'PATCH', '{}'); final rs = await handler.call(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -25,7 +25,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'post', '"ololo"abc'); + TestRequest(uriDesign.collectionUri('books'), 'POST', '"ololo"abc'); final rs = await handler.call(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -37,7 +37,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON:API object', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'post', '"oops"'); + TestRequest(uriDesign.collectionUri('books'), 'POST', '"oops"'); final rs = await handler.call(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -49,7 +49,7 @@ void main() { test('returns `bad request` when payload violates JSON:API', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'post', '{"data": {}}'); + TestRequest(uriDesign.collectionUri('books'), 'POST', '{"data": {}}'); final rs = await handler.call(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -57,6 +57,63 @@ void main() { expect(error.title, 'Bad request'); expect(error.detail, "Resource 'type' must not be null"); }); + + test('returns `not found` if URI is not recognized', () async { + final rq = + TestRequest(Uri.parse('http://localhost/a/b/c/d/e'), 'GET', ''); + final rs = await handler.call(rq); + expect(rs.statusCode, 404); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '404'); + expect(error.title, 'Not Found'); + expect(error.detail, 'The requested URL does exist on the server'); + }); + + test('returns `method not allowed` for resource collection', () async { + final rq = TestRequest(uriDesign.collectionUri('books'), 'DELETE', ''); + final rs = await handler.call(rq); + expect(rs.statusCode, 405); + expect(rs.headers['Allow'], 'GET, POST'); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '405'); + expect(error.title, 'Method Not Allowed'); + expect(error.detail, 'Allowed methods: GET, POST'); + }); + + test('returns `method not allowed` for resource ', () async { + final rq = TestRequest(uriDesign.resourceUri('books', '1'), 'POST', ''); + final rs = await handler.call(rq); + expect(rs.statusCode, 405); + expect(rs.headers['Allow'], 'DELETE, GET, PATCH'); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '405'); + expect(error.title, 'Method Not Allowed'); + expect(error.detail, 'Allowed methods: DELETE, GET, PATCH'); + }); + + test('returns `method not allowed` for related ', () async { + final rq = + TestRequest(uriDesign.relatedUri('books', '1', 'author'), 'POST', ''); + final rs = await handler.call(rq); + expect(rs.statusCode, 405); + expect(rs.headers['Allow'], 'GET'); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '405'); + expect(error.title, 'Method Not Allowed'); + expect(error.detail, 'Allowed methods: GET'); + }); + + test('returns `method not allowed` for relationship ', () async { + final rq = TestRequest( + uriDesign.relationshipUri('books', '1', 'author'), 'PUT', ''); + final rs = await handler.call(rq); + expect(rs.statusCode, 405); + expect(rs.headers['Allow'], 'DELETE, GET, PATCH, POST'); + final error = Document.fromJson(json.decode(rs.body), null).errors.first; + expect(error.status, '405'); + expect(error.title, 'Method Not Allowed'); + expect(error.detail, 'Allowed methods: DELETE, GET, PATCH, POST'); + }); }); } @@ -94,4 +151,78 @@ class TestResponse { TestResponse(this.statusCode, this.body, this.headers); } -class DummyController extends JsonApiControllerBase {} +class DummyController implements JsonApiController { + @override + FutureOr addToRelationship(request, String type, String id, + String relationship, Iterable identifiers) { + // TODO: implement addToRelationship + return null; + } + + @override + FutureOr createResource( + request, String type, Resource resource) { + // TODO: implement createResource + return null; + } + + @override + FutureOr deleteFromRelationship(request, String type, + String id, String relationship, Iterable identifiers) { + // TODO: implement deleteFromRelationship + return null; + } + + @override + FutureOr deleteResource(request, String type, String id) { + // TODO: implement deleteResource + return null; + } + + @override + FutureOr fetchCollection(request, String type) { + // TODO: implement fetchCollection + return null; + } + + @override + FutureOr fetchRelated( + request, String type, String id, String relationship) { + // TODO: implement fetchRelated + return null; + } + + @override + FutureOr fetchRelationship( + request, String type, String id, String relationship) { + // TODO: implement fetchRelationship + return null; + } + + @override + FutureOr fetchResource(request, String type, String id) { + // TODO: implement fetchResource + return null; + } + + @override + FutureOr replaceToMany(request, String type, String id, + String relationship, Iterable identifiers) { + // TODO: implement replaceToMany + return null; + } + + @override + FutureOr replaceToOne(request, String type, String id, + String relationship, Identifier identifier) { + // TODO: implement replaceToOne + return null; + } + + @override + FutureOr updateResource( + request, String type, String id, Resource resource) { + // TODO: implement updateResource + return null; + } +} From b9ec15a817a8ba243b83ee27a91e8076b54bccc2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 30 Jan 2020 01:14:14 -0800 Subject: [PATCH 16/99] wip --- example/README.md | 15 -- example/client.dart | 19 ++ example/fetch_collection.dart | 12 - example/server.dart | 46 ++++ example/server/server.dart | 24 -- .../shelf_request_response_converter.dart | 21 -- lib/client.dart | 4 +- lib/document.dart | 1 + lib/http.dart | 7 + lib/server.dart | 5 +- lib/src/client/dart_http_client.dart | 27 ++ lib/src/client/json_api_client.dart | 145 ++++------- .../{response.dart => json_api_response.dart} | 0 ...i_aware_client.dart => simple_client.dart} | 14 +- lib/src/document/document_exception.dart | 3 - lib/src/document/relationship.dart | 5 +- lib/src/document/resource_data.dart | 5 +- lib/src/document/resource_object.dart | 2 +- lib/src/http/http_handler.dart | 23 ++ lib/src/http/http_request.dart | 21 ++ lib/src/http/http_response.dart | 16 ++ lib/src/http/logging_http_handler.dart | 25 ++ lib/src/http/normalize.dart | 3 + lib/src/server/dart_server_handler.dart | 25 ++ lib/src/server/json_api_controller.dart | 61 +++-- lib/src/server/json_api_request.dart | 200 --------------- lib/src/server/json_api_server.dart | 119 +++++++++ lib/src/server/repository_controller.dart | 127 ++++----- lib/src/server/request_handler.dart | 77 ------ lib/src/server/target.dart | 165 ------------ lib/uri_design.dart | 73 ++++-- test/e2e/client_server_interaction.dart | 16 ++ test/functional/async_processing_test.dart | 1 - test/functional/basic_crud_test.dart | 32 +-- test/functional/compound_document_test.dart | 23 +- .../functional/forbidden_operations_test.dart | 1 - test/functional/no_content_test.dart | 1 - test/helper/shelf_adapter.dart | 18 ++ .../shelf_request_response_converter.dart | 21 -- .../client/request_document_factory_test.dart | 11 + .../unit/document/identifier_object_test.dart | 5 + test/unit/http/logging_http_handler_test.dart | 18 ++ test/unit/query/fields_test.dart | 15 ++ test/unit/query/include_test.dart | 7 + test/unit/query/merge_test.dart | 14 + test/unit/query/page_test.dart | 7 + test/unit/query/sort_test.dart | 7 + test/unit/server/request_handler_test.dart | 169 +++--------- tmp/crud_test.dart | 242 ------------------ 49 files changed, 722 insertions(+), 1176 deletions(-) delete mode 100644 example/README.md create mode 100644 example/client.dart delete mode 100644 example/fetch_collection.dart create mode 100644 example/server.dart delete mode 100644 example/server/server.dart delete mode 100644 example/server/shelf_request_response_converter.dart create mode 100644 lib/http.dart create mode 100644 lib/src/client/dart_http_client.dart rename lib/src/client/{response.dart => json_api_response.dart} (100%) rename lib/src/client/{uri_aware_client.dart => simple_client.dart} (96%) create mode 100644 lib/src/http/http_handler.dart create mode 100644 lib/src/http/http_request.dart create mode 100644 lib/src/http/http_response.dart create mode 100644 lib/src/http/logging_http_handler.dart create mode 100644 lib/src/http/normalize.dart create mode 100644 lib/src/server/dart_server_handler.dart delete mode 100644 lib/src/server/json_api_request.dart create mode 100644 lib/src/server/json_api_server.dart delete mode 100644 lib/src/server/request_handler.dart delete mode 100644 lib/src/server/target.dart create mode 100644 test/e2e/client_server_interaction.dart delete mode 100644 test/functional/async_processing_test.dart delete mode 100644 test/functional/forbidden_operations_test.dart delete mode 100644 test/functional/no_content_test.dart create mode 100644 test/helper/shelf_adapter.dart delete mode 100644 test/helper/shelf_request_response_converter.dart create mode 100644 test/unit/client/request_document_factory_test.dart create mode 100644 test/unit/http/logging_http_handler_test.dart create mode 100644 test/unit/query/merge_test.dart delete mode 100644 tmp/crud_test.dart diff --git a/example/README.md b/example/README.md deleted file mode 100644 index 557a1555..00000000 --- a/example/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# JSON:API examples - -## [Server](server/server.dart) -This is a simple JSON:API server which is used in the tests. It provides an API to a collection to car companies and models. -You can run it locally to play around. - -- In you console run `dart example/server.dart`, this will start the server at port 8080. -- Open http://localhost:8080/companies in the browser. - -## [Fetch example](./fetch_collection.dart) -With the server running, call -``` -dart example/fetch_collection.dart -``` -This will make a `fetchCollection()` call and print the response. \ No newline at end of file diff --git a/example/client.dart b/example/client.dart new file mode 100644 index 00000000..a57a2cd2 --- /dev/null +++ b/example/client.dart @@ -0,0 +1,19 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/uri_design.dart'; + +/// This example shows how to use the JSON:API client. +/// Run the server first! +void main() { + /// Use the same URI design as the server + final uriDesign = UriDesign.standard(Uri.parse('http://localhost:8080')); + /// There are two clients in this library: + /// - JsonApiClient, the main implementation, most flexible but a bit verbose + /// - SimpleClient, less boilerplate but not as flexible + /// The example will show both in parallel + final client = JsonApiClient(); + final simpleClient = SimpleClient(uriDesign); + + + + +} diff --git a/example/fetch_collection.dart b/example/fetch_collection.dart deleted file mode 100644 index 64490941..00000000 --- a/example/fetch_collection.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/uri_design.dart'; - -/// Start `dart example/server/server.dart` first -void main() async { - final base = Uri.parse('http://localhost:8080'); - final client = UriAwareClient(UriDesign.standard(base)); - await client.createResource( - Resource('messages', '1', attributes: {'text': 'Hello World'})); - client.close(); -} diff --git a/example/server.dart b/example/server.dart new file mode 100644 index 00000000..35f65d19 --- /dev/null +++ b/example/server.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/uri_design.dart'; + +/// This example shows how to run a simple JSON:API server using the built-in +/// HTTP server (dart:io). +/// Run it: `dart example/server.dart` +void main() async { + /// Listening on this port + final port = 8080; + + /// Listening on the localhost + final address = 'localhost'; + + /// Base URI to let the URI design detect the request target properly + final base = Uri(host: address, port: port, scheme: 'http'); + + /// Use the standard URI design + final uriDesign = UriDesign.standard(base); + + /// Resource repository supports two kind of entities: writers and books + final repo = InMemoryRepository({'writers': {}, 'books': {}}); + + /// Controller provides JSON:API interface to the repository + final controller = RepositoryController(repo); + + /// The JSON:API server uses the given URI design to route requests to the controller + final jsonApiServer = JsonApiServer(uriDesign, controller); + + /// We will be logging the requests and responses to the console + final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, + onRequest: (r) => print('>> ${r.method} ${r.uri}'), + onResponse: (r) => print('<< ${r.statusCode}')); + + /// The handler for the built-in HTTP server + final serverHandler = DartServerHandler(loggingJsonApiServer); + + /// Start the server + final server = await HttpServer.bind(address, port); + print('Listening on $base'); + + /// Each HTTP request will be processed by the handler + await server.forEach(serverHandler); +} diff --git a/example/server/server.dart b/example/server/server.dart deleted file mode 100644 index 2755aa2c..00000000 --- a/example/server/server.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/request_handler.dart'; -import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:uuid/uuid.dart'; - -import 'shelf_request_response_converter.dart'; - -/// This example shows how to build a simple CRUD server on top of Dart Shelf -void main() async { - final host = 'localhost'; - final port = 8080; - final baseUri = Uri(scheme: 'http', host: host, port: port); - - /// You may also try PaginatingController -// final controller = CRUDController(Uuid().v4, (_) => true); -// final jsonApiHandler = RequestHandler( -// ShelfRequestResponseConverter(), controller, UriDesign.standard(baseUri)); -// -// await serve(jsonApiHandler, InternetAddress.loopbackIPv4, port); -// print('Serving at $baseUri'); -} diff --git a/example/server/shelf_request_response_converter.dart b/example/server/shelf_request_response_converter.dart deleted file mode 100644 index 4a314305..00000000 --- a/example/server/shelf_request_response_converter.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart' as shelf; - -class ShelfRequestResponseConverter - implements HttpAdapter { - @override - FutureOr createResponse( - int statusCode, String body, Map headers) => - shelf.Response(statusCode, body: body, headers: headers); - - @override - FutureOr getBody(shelf.Request request) => request.readAsString(); - - @override - FutureOr getMethod(shelf.Request request) => request.method; - - @override - FutureOr getUri(shelf.Request request) => request.requestedUri; -} diff --git a/lib/client.dart b/lib/client.dart index 89767e22..45f18d26 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,7 +1,7 @@ library client; export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/json_api_response.dart'; export 'package:json_api/src/client/request_document_factory.dart'; -export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; -export 'package:json_api/src/client/uri_aware_client.dart'; +export 'package:json_api/src/client/simple_client.dart'; diff --git a/lib/document.dart b/lib/document.dart index 8b6977af..2db0e09c 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -2,6 +2,7 @@ library document; export 'package:json_api/src/document/api.dart'; export 'package:json_api/src/document/document.dart'; +export 'package:json_api/src/document/document_exception.dart'; export 'package:json_api/src/document/identifier.dart'; export 'package:json_api/src/document/identifier_object.dart'; export 'package:json_api/src/document/json_api_error.dart'; diff --git a/lib/http.dart b/lib/http.dart new file mode 100644 index 00000000..b62b5ec3 --- /dev/null +++ b/lib/http.dart @@ -0,0 +1,7 @@ +/// This is a thin HTTP layer abstraction used by the client and the server. +library http; + +export 'package:json_api/src/http/http_handler.dart'; +export 'package:json_api/src/http/http_request.dart'; +export 'package:json_api/src/http/http_response.dart'; +export 'package:json_api/src/http/logging_http_handler.dart'; diff --git a/lib/server.dart b/lib/server.dart index d0e36def..ce77b41c 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,13 +3,12 @@ /// The server API is not stable. Expect breaking changes. library server; +export 'package:json_api/src/server/dart_server_handler.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_controller.dart'; -export 'package:json_api/src/server/json_api_request.dart'; export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; -export 'package:json_api/src/server/request_handler.dart'; export 'package:json_api/src/server/response_document_factory.dart'; -export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/client/dart_http_client.dart b/lib/src/client/dart_http_client.dart new file mode 100644 index 00000000..ff6c38bd --- /dev/null +++ b/lib/src/client/dart_http_client.dart @@ -0,0 +1,27 @@ +import 'package:http/http.dart'; +import 'package:json_api/http.dart'; + +/// A handler using the Dart's built-in http client +class DartHttpClient implements HttpHandler { + @override + Future call(HttpRequest request) async { + final response = await _send(Request(request.method, request.uri) + ..headers.addAll(request.headers) + ..body = request.body); + return HttpResponse(response.statusCode, + body: response.body, headers: response.headers); + } + + /// Calls the inner client's `close()`. You have to either call this method + /// or close the inner client yourself! + /// + /// See https://pub.dev/documentation/http/latest/http/Client/close.html + void close() => _client.close(); + + DartHttpClient([Client client]) : _client = client ?? Client(); + + final Client _client; + + Future _send(Request dartRequest) async => + Response.fromStream(await _client.send(dartRequest)); +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 5d89400b..92511b28 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,32 +1,15 @@ import 'dart:async'; import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/src/client/dart_http_client.dart'; +import 'package:json_api/src/client/json_api_response.dart'; import 'package:json_api/src/client/request_document_factory.dart'; -import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/status_code.dart'; /// The JSON:API Client. -/// -/// [JsonApiClient] works on top of Dart's built-in HTTP client. -/// ```dart -/// import 'package:http/http.dart'; -/// import 'package:json_api/client.dart'; -/// -/// /// Start `dart example/hybrid_server.dart` first! -/// void main() async { -/// final jsonApiClient = JsonApiClient(); -/// final url = Uri.parse('http://localhost:8080/companies'); -/// final response = await jsonApiClient.fetchCollection(url); -/// jsonApiClient.close(); // Don't forget to close the inner http client -/// print('The collection page size is ${response.data.collection.length}'); -/// final resource = response.data.unwrap().first; -/// print('The last element is ${resource}'); -/// resource.attributes.forEach((k, v) => print('Attribute $k is $v')); -/// } -/// ``` class JsonApiClient { /// Fetches a resource collection by sending a GET query to the [uri]. /// Use [headers] to pass extra HTTP headers. @@ -143,97 +126,77 @@ class JsonApiClient { _call(_post(uri, headers, _factory.toManyDocument(identifiers)), ToMany.fromJson); - /// Closes the internal HTTP client. You have to either call this method or - /// close the client yourself. - /// - /// See [httpClient.close] - void close() => _http.close(); - /// Creates an instance of JSON:API client. /// You have to create and pass an instance of the [httpClient] yourself. /// Do not forget to call [httpClient.close] when you're done using /// the JSON:API client. /// The [onHttpCall] hook, if passed, gets called when an http response is /// received from the HTTP Client. - JsonApiClient( - {RequestDocumentFactory builder, - OnHttpCall onHttpCall, - http.Client httpClient}) + JsonApiClient({RequestDocumentFactory builder, HttpHandler httpClient}) : _factory = builder ?? RequestDocumentFactory(api: Api(version: '1.0')), - _http = httpClient ?? http.Client(), - _onHttpCall = onHttpCall ?? _doNothing; + _http = httpClient ?? DartHttpClient(); - final http.Client _http; - final OnHttpCall _onHttpCall; + final HttpHandler _http; final RequestDocumentFactory _factory; - http.Request _get(Uri uri, Map headers, + HttpRequest _get(Uri uri, Map headers, QueryParameters queryParameters) => - http.Request( - 'GET', (queryParameters ?? QueryParameters({})).addToUri(uri)) - ..headers.addAll({ - ...headers ?? {}, - 'Accept': Document.contentType, - }); - - http.Request _post(Uri uri, Map headers, Document doc) => - http.Request('POST', uri) - ..headers.addAll({ - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }) - ..body = json.encode(doc); - - http.Request _delete(Uri uri, Map headers) => - http.Request('DELETE', uri) - ..headers.addAll({ - ...headers ?? {}, - 'Accept': Document.contentType, - }); - - http.Request _deleteWithBody( + HttpRequest('GET', (queryParameters ?? QueryParameters({})).addToUri(uri), + headers: { + ...headers ?? {}, + 'Accept': Document.contentType, + }); + + HttpRequest _post(Uri uri, Map headers, Document doc) => + HttpRequest('POST', uri, + headers: { + ...headers ?? {}, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, + }, + body: jsonEncode(doc)); + + HttpRequest _delete(Uri uri, Map headers) => + HttpRequest('DELETE', uri, headers: { + ...headers ?? {}, + 'Accept': Document.contentType, + }); + + HttpRequest _deleteWithBody( Uri uri, Map headers, Document doc) => - http.Request('DELETE', uri) - ..headers.addAll({ - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }) - ..body = json.encode(doc); - - http.Request _patch(uri, Map headers, Document doc) => - http.Request('PATCH', uri) - ..headers.addAll({ - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }) - ..body = json.encode(doc); + HttpRequest('DELETE', uri, + headers: { + ...headers ?? {}, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, + }, + body: jsonEncode(doc)); + + HttpRequest _patch(uri, Map headers, Document doc) => + HttpRequest('PATCH', uri, + headers: { + ...headers ?? {}, + 'Accept': Document.contentType, + 'Content-Type': Document.contentType, + }, + body: jsonEncode(doc)); Future> _call( - http.Request request, D Function(Object _) decodePrimaryData) async { - final response = await http.Response.fromStream(await _http.send(request)); - _onHttpCall(request, response); - if (response.body.isEmpty) { + HttpRequest request, D Function(Object _) decodePrimaryData) async { + final response = await _http(request); + final document = response.body.isEmpty ? null : jsonDecode(response.body); + if (document == null) { return JsonApiResponse(response.statusCode, response.headers); } - final body = json.decode(response.body); if (StatusCode(response.statusCode).isPending) { return JsonApiResponse(response.statusCode, response.headers, - asyncDocument: body == null + asyncDocument: document == null ? null - : Document.fromJson(body, ResourceData.fromJson)); + : Document.fromJson(document, ResourceData.fromJson)); } return JsonApiResponse(response.statusCode, response.headers, - document: - body == null ? null : Document.fromJson(body, decodePrimaryData)); + document: document == null + ? null + : Document.fromJson(document, decodePrimaryData)); } } - -/// Defines the hook which gets called when the HTTP response is received from -/// the HTTP Client. -typedef OnHttpCall = void Function( - http.Request request, http.Response response); - -void _doNothing(http.Request request, http.Response response) {} diff --git a/lib/src/client/response.dart b/lib/src/client/json_api_response.dart similarity index 100% rename from lib/src/client/response.dart rename to lib/src/client/json_api_response.dart diff --git a/lib/src/client/uri_aware_client.dart b/lib/src/client/simple_client.dart similarity index 96% rename from lib/src/client/uri_aware_client.dart rename to lib/src/client/simple_client.dart index fb34253a..c90eb840 100644 --- a/lib/src/client/uri_aware_client.dart +++ b/lib/src/client/simple_client.dart @@ -1,12 +1,14 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/src/client/dart_http_client.dart'; import 'package:json_api/uri_design.dart'; /// A wrapper over [JsonApiClient] making use of the given UrlFactory. /// This wrapper reduces the boilerplate code but is not as flexible /// as [JsonApiClient]. -class UriAwareClient { +class SimpleClient { /// Creates a new resource. /// /// If [collection] is specified, the resource will be added to that collection, @@ -165,12 +167,10 @@ class UriAwareClient { _uriFactory.relationshipUri(type, id, relationship), identifier, headers: headers); - /// Closes the internal client. You have to either call this method or - /// close the client yourself. - void close() => _client.close(); - - UriAwareClient(this._uriFactory, {JsonApiClient jsonApiClient}) - : _client = jsonApiClient ?? JsonApiClient(); + SimpleClient(this._uriFactory, + {JsonApiClient jsonApiClient, HttpHandler httpHandler}) + : _client = jsonApiClient ?? + JsonApiClient(httpClient: httpHandler ?? DartHttpClient()); final JsonApiClient _client; final UriFactory _uriFactory; } diff --git a/lib/src/document/document_exception.dart b/lib/src/document/document_exception.dart index 917e2f2d..55b0002b 100644 --- a/lib/src/document/document_exception.dart +++ b/lib/src/document/document_exception.dart @@ -3,9 +3,6 @@ class DocumentException implements Exception { /// Human-readable text explaining the issue.. final String message; - @override - String toString() => message; - DocumentException(this.message); /// Throws a [DocumentException] with the [message] if [value] is null. diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 5dc48caa..73a3e146 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -139,8 +139,5 @@ class ToMany extends Relationship { /// Converts to Iterable. /// For empty relationships returns an empty List. - Iterable unwrap() => linkage.map((_) => _.unwrap()).toList(); - - /// Same as [unwrap()] - Iterable get identifiers => unwrap(); + Iterable unwrap() => linkage.map((_) => _.unwrap()); } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 828ea5fa..fb525516 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -12,7 +12,10 @@ class ResourceData extends PrimaryData { ResourceData(this.resourceObject, {Iterable included, Map links}) : super( - included: included, links: {...?resourceObject?.links, ...?links}); + included: included, + links: (resourceObject?.links == null && links == null) + ? null + : {...?resourceObject?.links, ...?links}); static ResourceData fromJson(Object json) { if (json is Map) { diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 8f0afc09..a95c3f91 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -80,7 +80,7 @@ class ResourceObject { if (rel is ToOne) { toOne[name] = rel.unwrap(); } else if (rel is ToMany) { - toMany[name] = rel.identifiers; + toMany[name] = rel.unwrap(); } else { incomplete[name] = rel; } diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart new file mode 100644 index 00000000..794702b9 --- /dev/null +++ b/lib/src/http/http_handler.dart @@ -0,0 +1,23 @@ +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A callable class which converts requests to responses +abstract class HttpHandler { + /// Sends the request over the network and returns the received response + Future call(HttpRequest request); + + /// Creates an instance of [HttpHandler] from a function + static HttpHandler fromFunction(HttpHandlerFunc f) => _HandlerFromFunction(f); +} + +/// This typedef is compatible with [HttpHandler] +typedef HttpHandlerFunc = Future Function(HttpRequest request); + +class _HandlerFromFunction implements HttpHandler { + @override + Future call(HttpRequest request) => _f(request); + + const _HandlerFromFunction(this._f); + + final HttpHandlerFunc _f; +} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart new file mode 100644 index 00000000..fd088f3f --- /dev/null +++ b/lib/src/http/http_request.dart @@ -0,0 +1,21 @@ +import 'package:json_api/src/http/normalize.dart'; + +/// The request which is sent by the client and received by the server +class HttpRequest { + /// Requested URI + final Uri uri; + + /// Request method, uppercase + final String method; + + /// Request body + final String body; + + /// Request headers. Unmodifiable. Lowercase keys + final Map headers; + + HttpRequest(String method, this.uri, + {this.body = '', Map headers}) + : headers = normalize(headers), + method = method.toUpperCase(); +} diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart new file mode 100644 index 00000000..a263a33a --- /dev/null +++ b/lib/src/http/http_response.dart @@ -0,0 +1,16 @@ +import 'normalize.dart'; + +/// The response sent by the server and received by the client +class HttpResponse { + /// Response status code + final int statusCode; + + /// Response body + final String body; + + /// Response headers. Unmodifiable. Lowercase keys + final Map headers; + + HttpResponse(this.statusCode, {this.body = '', Map headers}) + : headers = normalize(headers); +} diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart new file mode 100644 index 00000000..ccbd23d9 --- /dev/null +++ b/lib/src/http/logging_http_handler.dart @@ -0,0 +1,25 @@ +import 'package:json_api/src/http/http_handler.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A wrapper over [HttpHandler] which allows logging +class LoggingHttpHandler implements HttpHandler { + /// The wrapped handler + final HttpHandler wrapped; + + /// This function will be called before the request is sent + final void Function(HttpRequest) onRequest; + + /// This function will be called after the response is received + final void Function(HttpResponse) onResponse; + + @override + Future call(HttpRequest request) async { + onRequest?.call(request); + final response = await wrapped(request); + onResponse?.call(response); + return response; + } + + LoggingHttpHandler(this.wrapped, {this.onRequest, this.onResponse}); +} diff --git a/lib/src/http/normalize.dart b/lib/src/http/normalize.dart new file mode 100644 index 00000000..a6e85ccd --- /dev/null +++ b/lib/src/http/normalize.dart @@ -0,0 +1,3 @@ +/// Makes the keys lowercase, wraps to unmodifiable map +Map normalize(Map headers) => Map.unmodifiable( + (headers ?? const {}).map((k, v) => MapEntry(k.toLowerCase(), v))); diff --git a/lib/src/server/dart_server_handler.dart b/lib/src/server/dart_server_handler.dart new file mode 100644 index 00000000..78034efe --- /dev/null +++ b/lib/src/server/dart_server_handler.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:io' as dart; + +import 'package:json_api/http.dart'; + +class DartServerHandler { + final HttpHandler _handler; + + DartServerHandler(this._handler); + + Future call(dart.HttpRequest request) async { + final response = await _handler(await _convertRequest(request)); + response.headers.forEach(request.response.headers.add); + request.response.statusCode = response.statusCode; + request.response.write(response.body); + await request.response.close(); + } + + Future _convertRequest(dart.HttpRequest r) async { + final body = await r.cast>().transform(utf8.decoder).join(); + final headers = {}; + r.headers.forEach((k, v) => headers[k] = v.join(', ')); + return HttpRequest(r.method, r.requestedUri, body: body, headers: headers); + } +} diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index d4fb82ea..0f52b5a7 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -1,7 +1,10 @@ import 'dart:async'; -import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/server/json_api_response.dart'; +import 'package:json_api/uri_design.dart'; /// The Controller consolidates all possible requests a JSON:API server /// may handle. The controller is agnostic to the request, therefore it is @@ -9,20 +12,22 @@ import 'package:json_api/src/server/json_api_response.dart'; /// [JsonApiResponse] object or a [Future] of it. /// /// The response may either be a successful or an error. -abstract class JsonApiController { - /// Finds an returns a primary resource collection of the given [type]. +abstract class JsonApiController { + /// Finds an returns a primary resource collection. /// Use [JsonApiResponse.collection] to return a successful response. /// Use [JsonApiResponse.notFound] if the collection does not exist. /// /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchCollection(R request, String type); + FutureOr fetchCollection( + HttpRequest request, CollectionTarget target); - /// Finds an returns a primary resource of the given [type] and [id]. + /// Finds an returns a primary resource. /// Use [JsonApiResponse.resource] to return a successful response. /// Use [JsonApiResponse.notFound] if the resource does not exist. /// /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchResource(R request, String type, String id); + FutureOr fetchResource( + HttpRequest request, ResourceTarget target); /// Finds an returns a related resource or a collection of related resources. /// Use [JsonApiResponse.relatedResource] or [JsonApiResponse.relatedCollection] to return a successful response. @@ -30,24 +35,25 @@ abstract class JsonApiController { /// /// See https://jsonapi.org/format/#fetching-resources FutureOr fetchRelated( - R request, String type, String id, String relationship); + HttpRequest request, RelatedTarget target); - /// Finds an returns a relationship of a primary resource identified by [type] and [id]. + /// Finds an returns a relationship of a primary resource. /// Use [JsonApiResponse.toOne] or [JsonApiResponse.toMany] to return a successful response. /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. /// /// See https://jsonapi.org/format/#fetching-relationships FutureOr fetchRelationship( - R request, String type, String id, String relationship); + HttpRequest request, RelationshipTarget target); - /// Deletes the resource identified by [type] and [id]. + /// Deletes the resource. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. /// Use [JsonApiResponse.notFound] if the resource does not exist. /// /// See https://jsonapi.org/format/#crud-deleting - FutureOr deleteResource(R request, String type, String id); + FutureOr deleteResource( + HttpRequest request, ResourceTarget target); - /// Creates a new [resource] in the collection of the given [type]. + /// Creates a new resource in the collection. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. /// Use [JsonApiResponse.notFound] if the collection does not exist. /// Use [JsonApiResponse.forbidden] if the server does not support this operation. @@ -56,41 +62,40 @@ abstract class JsonApiController { /// /// See https://jsonapi.org/format/#crud-creating FutureOr createResource( - R request, String type, Resource resource); + HttpRequest request, CollectionTarget target, Resource resource); - /// Updates the resource identified by [type] and [id]. The [resource] argument - /// contains the data to update/replace. + /// Updates the resource. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. /// /// See https://jsonapi.org/format/#crud-updating FutureOr updateResource( - R request, String type, String id, Resource resource); + HttpRequest request, ResourceTarget target, Resource resource); - /// Replaces the to-one relationship with the given [identifier]. + /// Replaces the to-one relationship. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toOne]. /// /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - FutureOr replaceToOne(R request, String type, String id, - String relationship, Identifier identifier); + FutureOr replaceToOne( + HttpRequest request, RelationshipTarget target, Identifier identifier); - /// Replaces the to-many relationship with the given [identifiers]. + /// Replaces the to-many relationship. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr replaceToMany(R request, String type, String id, - String relationship, Iterable identifiers); + FutureOr replaceToMany(HttpRequest request, + RelationshipTarget target, Iterable identifiers); - /// Removes the given [identifiers] from the to-many relationship. + /// Removes the given identifiers from the to-many relationship. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers); + FutureOr deleteFromRelationship(HttpRequest request, + RelationshipTarget target, Iterable identifiers); - /// Adds the given [identifiers] to the to-many relationship. + /// Adds the given identifiers to the to-many relationship. /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr addToRelationship(R request, String type, String id, - String relationship, Iterable identifiers); + FutureOr addToRelationship(HttpRequest request, + RelationshipTarget target, Iterable identifiers); } diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart deleted file mode 100644 index 6834edce..00000000 --- a/lib/src/server/json_api_request.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -abstract class JsonApiRequest { - /// Calls the appropriate method of [controller] - FutureOr call( - JsonApiController controller, Object jsonPayload, R request); - - static JsonApiRequest fetchCollection(String type) => _FetchCollection(type); - - static JsonApiRequest createResource(String type) => _CreateResource(type); - - /// Creates a request which always returns the [response] - static JsonApiRequest predefinedResponse(JsonApiResponse response) => - _PredefinedResponse(response); - - static JsonApiRequest fetchResource(String type, String id) => - _FetchResource(type, id); - - static JsonApiRequest deleteResource(String type, String id) => - _DeleteResource(type, id); - - static JsonApiRequest updateResource(String type, String id) => - _UpdateResource(type, id); - - static JsonApiRequest fetchRelated( - String type, String id, String relationship) => - _FetchRelated(type, id, relationship); - - static JsonApiRequest fetchRelationship( - String type, String id, String relationship) => - _FetchRelationship(type, id, relationship); - - static JsonApiRequest updateRelationship( - String type, String id, String relationship) => - _UpdateRelationship(type, id, relationship); - - static JsonApiRequest addToRelationship( - String type, String id, String relationship) => - _AddToRelationship(type, id, relationship); - - static JsonApiRequest deleteFromRelationship( - String type, String id, String relationship) => - _DeleteFromRelationship(type, id, relationship); -} - -/// Exception thrown by [JsonApiRequest] when an `updateRelationship` request -/// receives an incomplete relationship object. -class IncompleteRelationshipException implements Exception {} - -class _AddToRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.addToRelationship(request, type, id, relationship, - ToMany.fromJson(jsonPayload).unwrap()); - - _AddToRelationship(this.type, this.id, this.relationship); -} - -class _CreateResource implements JsonApiRequest { - final String type; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.createResource( - request, type, ResourceData.fromJson(jsonPayload).unwrap()); - - _CreateResource(this.type); -} - -class _DeleteFromRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.deleteFromRelationship(request, type, id, relationship, - ToMany.fromJson(jsonPayload).unwrap()); - - _DeleteFromRelationship(this.type, this.id, this.relationship); -} - -class _DeleteResource implements JsonApiRequest { - final String type; - final String id; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.deleteResource(request, type, id); - - _DeleteResource(this.type, this.id); -} - -class _FetchCollection implements JsonApiRequest { - final String type; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchCollection(request, type); - - _FetchCollection(this.type); -} - -class _FetchRelated implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchRelated(request, type, id, relationship); - - _FetchRelated(this.type, this.id, this.relationship); -} - -class _FetchRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchRelationship(request, type, id, relationship); - - _FetchRelationship(this.type, this.id, this.relationship); -} - -class _FetchResource implements JsonApiRequest { - final String type; - final String id; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.fetchResource(request, type, id); - - _FetchResource(this.type, this.id); -} - -class _PredefinedResponse implements JsonApiRequest { - final JsonApiResponse response; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - response; - - _PredefinedResponse(this.response); -} - -class _UpdateRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) { - final r = Relationship.fromJson(jsonPayload); - if (r is ToOne) { - return controller.replaceToOne( - request, type, id, relationship, r.unwrap()); - } - if (r is ToMany) { - return controller.replaceToMany( - request, type, id, relationship, r.unwrap()); - } - throw IncompleteRelationshipException(); - } - - _UpdateRelationship(this.type, this.id, this.relationship); -} - -class _UpdateResource implements JsonApiRequest { - final String type; - final String id; - - _UpdateResource(this.type, this.id); - - @override - FutureOr call( - JsonApiController controller, Object jsonPayload, R request) => - controller.updateResource( - request, type, id, ResourceData.fromJson(jsonPayload).unwrap()); -} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart new file mode 100644 index 00000000..182a2006 --- /dev/null +++ b/lib/src/server/json_api_server.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/uri_design.dart'; + +class JsonApiServer implements HttpHandler { + @override + Future call(HttpRequest request) async { + final response = await _do(request); + return HttpResponse(response.statusCode, + body: jsonEncode(response.buildDocument(_factory, request.uri)), + headers: response.buildHeaders(_uriDesign)); + } + + JsonApiServer(this._uriDesign, this._controller, + {ResponseDocumentFactory documentFactory}) + : _factory = documentFactory ?? ResponseDocumentFactory(_uriDesign); + + final UriDesign _uriDesign; + final JsonApiController _controller; + final ResponseDocumentFactory _factory; + + Future _do(HttpRequest request) async { + try { + return await _dispatch(request); + } on JsonApiResponse catch (e) { + return e; + } on FormatException catch (e) { + return JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Invalid JSON. ${e.message}') + ]); + } on DocumentException catch (e) { + return JsonApiResponse.badRequest([ + JsonApiError(status: '400', title: 'Bad request', detail: e.message) + ]); + } + } + + FutureOr _dispatch(HttpRequest request) async { + final target = _uriDesign.matchTarget(request.uri); + if (target is CollectionTarget) { + switch (request.method) { + case 'GET': + return _controller.fetchCollection(request, target); + case 'POST': + return _controller.createResource(request, target, + ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['GET', 'POST']); + } + } else if (target is ResourceTarget) { + switch (request.method) { + case 'DELETE': + return _controller.deleteResource(request, target); + case 'GET': + return _controller.fetchResource(request, target); + case 'PATCH': + return _controller.updateResource(request, target, + ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['DELETE', 'GET', 'PATCH']); + } + } else if (target is RelatedTarget) { + switch (request.method) { + case 'GET': + return _controller.fetchRelated(request, target); + default: + return _allow(['GET']); + } + } else if (target is RelationshipTarget) { + switch (request.method) { + case 'DELETE': + return _controller.deleteFromRelationship(request, target, + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + case 'GET': + return _controller.fetchRelationship(request, target); + case 'PATCH': + final rel = Relationship.fromJson(jsonDecode(request.body)); + if (rel is ToOne) { + return _controller.replaceToOne(request, target, rel.unwrap()); + } + if (rel is ToMany) { + return _controller.replaceToMany(request, target, rel.unwrap()); + } + return JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Incomplete relationship object') + ]); + case 'POST': + return _controller.addToRelationship(request, target, + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['DELETE', 'GET', 'PATCH', 'POST']); + } + } + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server') + ]); + } + + JsonApiResponse _allow(Iterable allow) => + JsonApiResponse.methodNotAllowed([ + JsonApiError( + status: '405', + title: 'Method Not Allowed', + detail: 'Allowed methods: ${allow.join(', ')}') + ], allow: allow); +} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index a02f7766..4989fe76 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,73 +1,78 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; +import 'package:json_api/uri_design.dart'; typedef UriReader = FutureOr Function(R request); /// An opinionated implementation of [JsonApiController] -class RepositoryController implements JsonApiController { +class RepositoryController implements JsonApiController { final Repository _repo; - final UriReader _getUri; - RepositoryController(this._repo, this._getUri); + RepositoryController(this._repo); @override - FutureOr addToRelationship(R request, String type, String id, - String relationship, Iterable identifiers) => + FutureOr addToRelationship(HttpRequest request, + RelationshipTarget target, Iterable identifiers) => _do(() async { - final original = await _repo.get(type, id); + final original = await _repo.get(target.type, target.id); final updated = await _repo.update( - type, - id, - Resource(type, id, toMany: { - relationship: {...original.toMany[relationship], ...identifiers} + target.type, + target.id, + Resource(target.type, target.id, toMany: { + target.relationship: { + ...original.toMany[target.relationship], + ...identifiers + } })); - return JsonApiResponse.toMany( - type, id, relationship, updated.toMany[relationship]); + return JsonApiResponse.toMany(target.type, target.id, + target.relationship, updated.toMany[target.relationship]); }); @override FutureOr createResource( - R request, String type, Resource resource) => + HttpRequest request, CollectionTarget target, Resource resource) => _do(() async { - final modified = await _repo.create(type, resource); + final modified = await _repo.create(target.type, resource); if (modified == null) return JsonApiResponse.noContent(); return JsonApiResponse.resourceCreated(modified); }); @override - FutureOr deleteFromRelationship(R request, String type, - String id, String relationship, Iterable identifiers) => + FutureOr deleteFromRelationship(HttpRequest request, + RelationshipTarget target, Iterable identifiers) => _do(() async { - final original = await _repo.get(type, id); + final original = await _repo.get(target.type, target.id); final updated = await _repo.update( - type, - id, - Resource(type, id, toMany: { - relationship: {...original.toMany[relationship]} + target.type, + target.id, + Resource(target.type, target.id, toMany: { + target.relationship: {...original.toMany[target.relationship]} ..removeAll(identifiers) })); - return JsonApiResponse.toMany( - type, id, relationship, updated.toMany[relationship]); + return JsonApiResponse.toMany(target.type, target.id, + target.relationship, updated.toMany[target.relationship]); }); @override - FutureOr deleteResource(R request, String type, String id) => + FutureOr deleteResource( + HttpRequest request, ResourceTarget target) => _do(() async { - await _repo.delete(type, id); + await _repo.delete(target.type, target.id); return JsonApiResponse.noContent(); }); @override - FutureOr fetchCollection(R request, String collection) => + FutureOr fetchCollection( + HttpRequest request, CollectionTarget target) => _do(() async { - final c = await _repo.getCollection(collection); - final uri = _getUri(request); - final include = Include.fromUri(uri); + final c = await _repo.getCollection(target.type); + final include = Include.fromUri(request.uri); final resources = []; for (final resource in c.elements) { @@ -82,45 +87,47 @@ class RepositoryController implements JsonApiController { @override FutureOr fetchRelated( - R request, String type, String id, String relationship) => + HttpRequest request, RelatedTarget target) => _do(() async { - final resource = await _repo.get(type, id); - if (resource.toOne.containsKey(relationship)) { - final identifier = resource.toOne[relationship]; + final resource = await _repo.get(target.type, target.id); + if (resource.toOne.containsKey(target.relationship)) { + final identifier = resource.toOne[target.relationship]; return JsonApiResponse.resource(await _getByIdentifier(identifier)); } - if (resource.toMany.containsKey(relationship)) { + if (resource.toMany.containsKey(target.relationship)) { final related = []; - for (final identifier in resource.toMany[relationship]) { + for (final identifier in resource.toMany[target.relationship]) { related.add(await _getByIdentifier(identifier)); } return JsonApiResponse.collection(related); } - return _relationshipNotFound(relationship, type, id); + return _relationshipNotFound( + target.relationship, target.type, target.id); }); @override FutureOr fetchRelationship( - R request, String type, String id, String relationship) => + HttpRequest request, RelationshipTarget target) => _do(() async { - final resource = await _repo.get(type, id); - if (resource.toOne.containsKey(relationship)) { - return JsonApiResponse.toOne( - type, id, relationship, resource.toOne[relationship]); + final resource = await _repo.get(target.type, target.id); + if (resource.toOne.containsKey(target.relationship)) { + return JsonApiResponse.toOne(target.type, target.id, + target.relationship, resource.toOne[target.relationship]); } - if (resource.toMany.containsKey(relationship)) { - return JsonApiResponse.toMany( - type, id, relationship, resource.toMany[relationship]); + if (resource.toMany.containsKey(target.relationship)) { + return JsonApiResponse.toMany(target.type, target.id, + target.relationship, resource.toMany[target.relationship]); } - return _relationshipNotFound(relationship, type, id); + return _relationshipNotFound( + target.relationship, target.type, target.id); }); @override - FutureOr fetchResource(R request, String type, String id) => + FutureOr fetchResource( + HttpRequest request, ResourceTarget target) => _do(() async { - final uri = _getUri(request); - final include = Include.fromUri(uri); - final resource = await _repo.get(type, id); + final include = Include.fromUri(request.uri); + final resource = await _repo.get(target.type, target.id); final resources = []; for (final path in include) { resources.addAll(await _getRelated(resource, path.split('.'))); @@ -157,28 +164,34 @@ class RepositoryController implements JsonApiController { } @override - FutureOr replaceToMany(R request, String type, String id, - String relationship, Iterable identifiers) => + FutureOr replaceToMany(HttpRequest request, + RelationshipTarget target, Iterable identifiers) => _do(() async { await _repo.update( - type, id, Resource(type, id, toMany: {relationship: identifiers})); + target.type, + target.id, + Resource(target.type, target.id, + toMany: {target.relationship: identifiers})); return JsonApiResponse.noContent(); }); @override - FutureOr replaceToOne(R request, String type, String id, - String relationship, Identifier identifier) => + FutureOr replaceToOne(HttpRequest request, + RelationshipTarget target, Identifier identifier) => _do(() async { await _repo.update( - type, id, Resource(type, id, toOne: {relationship: identifier})); + target.type, + target.id, + Resource(target.type, target.id, + toOne: {target.relationship: identifier})); return JsonApiResponse.noContent(); }); @override FutureOr updateResource( - R request, String type, String id, Resource resource) => + HttpRequest request, ResourceTarget target, Resource resource) => _do(() async { - final modified = await _repo.update(type, id, resource); + final modified = await _repo.update(target.type, target.id, resource); if (modified == null) return JsonApiResponse.noContent(); return JsonApiResponse.resource(modified); }); diff --git a/lib/src/server/request_handler.dart b/lib/src/server/request_handler.dart deleted file mode 100644 index 2d01fda3..00000000 --- a/lib/src/server/request_handler.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/response_document_factory.dart'; -import 'package:json_api/uri_design.dart'; - -/// HTTP handler -class RequestHandler { - /// Processes the incoming HTTP [request] and returns a response - Future call(Request request) async { - final uri = await _httpAdapter.getUri(request); - final method = await _httpAdapter.getMethod(request); - final requestBody = await _httpAdapter.getBody(request); - final requestTarget = Target.of(uri, _design); - final jsonApiRequest = requestTarget.getRequest(method); - JsonApiResponse jsonApiResponse; - try { - final requestDoc = requestBody.isEmpty ? null : json.decode(requestBody); - jsonApiResponse = - await jsonApiRequest.call(_controller, requestDoc, request); - } on JsonApiResponse catch (e) { - jsonApiResponse = e; - } on IncompleteRelationshipException { - jsonApiResponse = JsonApiResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ]); - } on FormatException catch (e) { - jsonApiResponse = JsonApiResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Invalid JSON. ${e.message}') - ]); - } on DocumentException catch (e) { - jsonApiResponse = JsonApiResponse.badRequest([ - JsonApiError(status: '400', title: 'Bad request', detail: e.message) - ]); - } - final statusCode = jsonApiResponse.statusCode; - final headers = jsonApiResponse.buildHeaders(_design); - final responseDocument = jsonApiResponse.buildDocument(_docFactory, uri); - return _httpAdapter.createResponse( - statusCode, json.encode(responseDocument), headers); - } - - /// Creates an instance of the handler. - RequestHandler(this._httpAdapter, this._controller, this._design, - {Pagination pagination}) - : _docFactory = ResponseDocumentFactory(_design, - pagination: pagination ?? Pagination.none(), - api: Api(version: '1.0')); - final HttpAdapter _httpAdapter; - final JsonApiController _controller; - final UriDesign _design; - final ResponseDocumentFactory _docFactory; -} - -/// The adapter is responsible -abstract class HttpAdapter { - FutureOr getMethod(Request request); - - FutureOr getUri(Request request); - - FutureOr getBody(Request request); - - FutureOr createResponse( - int statusCode, String body, Map headers); -} diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart deleted file mode 100644 index c6690821..00000000 --- a/lib/src/server/target.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/uri_design.dart'; - -/// The target of a JSON:API request URI. The URI target and the request method -/// uniquely identify the meaning of the JSON:API request. -abstract class Target { - /// Returns the request corresponding to the request [method]. - JsonApiRequest getRequest(String method); - - /// Returns the target of the [url] according to the [design] - static Target of(Uri uri, UriDesign design) { - final builder = _Builder(); - design.matchTarget(uri, builder); - return builder.target ?? _Invalid(uri); - } -} - -class _Builder implements OnTargetMatch { - Target target; - - @override - void collection(String type) { - target = _Collection(type); - } - - @override - void resource(String type, String id) { - target = _Resource(type, id); - } - - @override - void related(String type, String id, String rel) { - target = _Related(type, id, rel); - } - - @override - void relationship(String type, String id, String rel) { - target = _Relationship(type, id, rel); - } -} - -/// The target of a URI referring a resource collection -class _Collection implements Target { - /// Resource type - final String type; - - const _Collection(this.type); - - @override - JsonApiRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return JsonApiRequest.fetchCollection(type); - case 'POST': - return JsonApiRequest.createResource(type); - default: - return _methodNoAllowed(['GET', 'POST']); - } - } -} - -/// The target of a URI referring to a single resource -class _Resource implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - const _Resource(this.type, this.id); - - @override - JsonApiRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'DELETE': - return JsonApiRequest.deleteResource(type, id); - case 'GET': - return JsonApiRequest.fetchResource(type, id); - case 'PATCH': - return JsonApiRequest.updateResource(type, id); - default: - return _methodNoAllowed(['DELETE', 'GET', 'PATCH']); - } - } -} - -/// The target of a URI referring a related resource or collection -class _Related implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const _Related(this.type, this.id, this.relationship); - - @override - JsonApiRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'GET': - return JsonApiRequest.fetchRelated(type, id, relationship); - default: - return _methodNoAllowed(['GET']); - } - } -} - -/// The target of a URI referring a relationship -class _Relationship implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const _Relationship(this.type, this.id, this.relationship); - - @override - JsonApiRequest getRequest(String method) { - switch (method.toUpperCase()) { - case 'DELETE': - return JsonApiRequest.deleteFromRelationship(type, id, relationship); - case 'GET': - return JsonApiRequest.fetchRelationship(type, id, relationship); - case 'PATCH': - return JsonApiRequest.updateRelationship(type, id, relationship); - case 'POST': - return JsonApiRequest.addToRelationship(type, id, relationship); - default: - return _methodNoAllowed(['DELETE', 'GET', 'PATCH', 'POST']); - } - } -} - -/// Request URI target which is not recognized by the URL Design. -class _Invalid implements Target { - final Uri uri; - - const _Invalid(this.uri); - - @override - JsonApiRequest getRequest(String method) => - JsonApiRequest.predefinedResponse(JsonApiResponse.notFound([ - JsonApiError( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ])); -} - -JsonApiRequest _methodNoAllowed(Iterable allow) => - JsonApiRequest.predefinedResponse(JsonApiResponse.methodNotAllowed([ - JsonApiError( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${allow.join(', ')}') - ], allow: allow)); diff --git a/lib/uri_design.dart b/lib/uri_design.dart index 6bb153b2..6cc6d4c5 100644 --- a/lib/uri_design.dart +++ b/lib/uri_design.dart @@ -26,23 +26,57 @@ abstract class UriFactory { /// 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 [onTargetMatch] will be called with the target parameters - void matchTarget(Uri uri, OnTargetMatch onTargetMatch); + /// Returns the target of the [uri] or null. + Target matchTarget(Uri uri); } -abstract class OnTargetMatch { - /// Called when a URI targets a collection. - void collection(String type); +abstract class Target {} - /// Called when a URI targets a resource. - void resource(String type, String id); +/// The target of a URI referring a resource collection +class CollectionTarget implements Target { + /// Resource type + final String type; - /// Called when a URI targets a related resource or collection. - void related(String type, String id, String relationship); + const CollectionTarget(this.type); +} + +/// The target of a URI referring to a single resource +class ResourceTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + const ResourceTarget(this.type, this.id); +} + +/// The target of a URI referring a related resource or collection +class RelatedTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelatedTarget(this.type, this.id, this.relationship); +} + +/// The target of a URI referring a relationship +class RelationshipTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; - /// Called when a URI targets a relationship. - void relationship(String type, String id, String relationship); + const RelationshipTarget(this.type, this.id, this.relationship); } class _Standard implements UriDesign { @@ -69,21 +103,22 @@ class _Standard implements UriDesign { Uri resourceUri(String type, String id) => _appendToBase([type, id]); @override - void matchTarget(Uri uri, OnTargetMatch match) { - if (!uri.toString().startsWith(_base.toString())) return; + Target matchTarget(Uri uri) { + if (!uri.toString().startsWith(_base.toString())) return null; final s = uri.pathSegments.sublist(_base.pathSegments.length); if (s.length == 1) { - match.collection(s[0]); + return CollectionTarget(s[0]); } else if (s.length == 2) { - match.resource(s[0], s[1]); + return ResourceTarget(s[0], s[1]); } else if (s.length == 3) { - match.related(s[0], s[1], s[2]); + return RelatedTarget(s[0], s[1], s[2]); } else if (s.length == 4 && s[2] == _relationships) { - match.relationship(s[0], s[1], s[3]); + return RelationshipTarget(s[0], s[1], s[3]); } + return null; } - _Standard(this._base); + const _Standard(this._base); static const _relationships = 'relationships'; diff --git a/test/e2e/client_server_interaction.dart b/test/e2e/client_server_interaction.dart new file mode 100644 index 00000000..aba957bf --- /dev/null +++ b/test/e2e/client_server_interaction.dart @@ -0,0 +1,16 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +void main() { + group('Client-Server interation over HTTP', () { + final port = 8088; + final host = 'localhost'; + final uri = UriDesign.standard(Uri(host: host, port: port)); + final repo = InMemoryRepository({'people': {}, 'books': {}}); + final server = JsonApiServer(uri, RepositoryController(repo)); + test('can create and fetch resources', () { + + }); + }, testOn: 'vm'); +} diff --git a/test/functional/async_processing_test.dart b/test/functional/async_processing_test.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/test/functional/async_processing_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart index 81be67f4..f39c57a1 100644 --- a/test/functional/basic_crud_test.dart +++ b/test/functional/basic_crud_test.dart @@ -1,29 +1,25 @@ -import 'dart:io'; - import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; import '../helper/expect_resources_equal.dart'; -import '../helper/shelf_request_response_converter.dart'; void main() async { - HttpServer server; - UriAwareClient client; + SimpleClient client; + JsonApiServer server; final host = 'localhost'; - final port = 8081; + final port = 80; final base = Uri(scheme: 'http', host: host, port: port); final design = UriDesign.standard(base); setUp(() async { - client = UriAwareClient(design); final repository = InMemoryRepository({ 'books': {}, 'people': {}, @@ -32,19 +28,8 @@ void main() async { 'fruits': {}, 'apples': {} }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4()); - server = await serve( - RequestHandler( - ShelfRequestResponseConverter(), - RepositoryController( - repository, ShelfRequestResponseConverter().getUri), - design), - host, - port); - }); - - tearDown(() async { - client.close(); - await server.close(); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, httpHandler: server); }); group('Creating Resources', () { @@ -60,7 +45,8 @@ void main() async { expect(created.type, person.type); expect(created.id, isNotNull); expect(created.attributes, equals(person.attributes)); - final r1 = await JsonApiClient().fetchResource(r.location); + final r1 = + await JsonApiClient(httpClient: server).fetchResource(r.location); expect(r1.isSuccessful, isTrue); expect(r1.statusCode, 200); expectResourcesEqual(r1.data.unwrap(), created); @@ -134,7 +120,7 @@ void main() async { }); test('409 when the resource type does not match collection', () async { - final r = await JsonApiClient().createResource( + final r = await JsonApiClient(httpClient: server).createResource( design.collectionUri('fruits'), Resource('cucumbers', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index bb35f5d7..a3360883 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; @@ -7,17 +5,15 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; import 'package:test/test.dart'; import '../helper/expect_resources_equal.dart'; -import '../helper/shelf_request_response_converter.dart'; void main() async { - HttpServer server; - UriAwareClient client; + SimpleClient client; + JsonApiServer server; final host = 'localhost'; - final port = 8082; + final port = 80; final base = Uri(scheme: 'http', host: host, port: port); final design = UriDesign.standard(base); final wonderland = @@ -44,7 +40,7 @@ void main() async { }); setUp(() async { - client = UriAwareClient(design); + client = SimpleClient(design); final repository = InMemoryRepository({ 'posts': {'1': post}, 'comments': {'1': comment1, '2': comment2}, @@ -52,15 +48,8 @@ void main() async { 'countries': {'1': wonderland}, 'tags': {} }); - final converter = ShelfRequestResponseConverter(); - final controller = RepositoryController(repository, converter.getUri); - server = - await serve(RequestHandler(converter, controller, design), host, port); - }); - - tearDown(() async { - client.close(); - await server.close(); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, httpHandler: server); }); group('Compound document', () { diff --git a/test/functional/forbidden_operations_test.dart b/test/functional/forbidden_operations_test.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/test/functional/forbidden_operations_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/functional/no_content_test.dart b/test/functional/no_content_test.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/test/functional/no_content_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/helper/shelf_adapter.dart b/test/helper/shelf_adapter.dart new file mode 100644 index 00000000..bdd4944d --- /dev/null +++ b/test/helper/shelf_adapter.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:json_api/http.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:shelf/shelf.dart'; + +class ShelfAdapter { + final JsonApiServer _server; + + ShelfAdapter(this._server); + + FutureOr call(Request request) async { + final rq = HttpRequest(request.method, request.requestedUri, + body: await request.readAsString(), headers: request.headers); + final rs = await _server(rq); + return Response(rs.statusCode, body: rs.body, headers: rs.headers); + } +} diff --git a/test/helper/shelf_request_response_converter.dart b/test/helper/shelf_request_response_converter.dart deleted file mode 100644 index 4a314305..00000000 --- a/test/helper/shelf_request_response_converter.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/server.dart'; -import 'package:shelf/shelf.dart' as shelf; - -class ShelfRequestResponseConverter - implements HttpAdapter { - @override - FutureOr createResponse( - int statusCode, String body, Map headers) => - shelf.Response(statusCode, body: body, headers: headers); - - @override - FutureOr getBody(shelf.Request request) => request.readAsString(); - - @override - FutureOr getMethod(shelf.Request request) => request.method; - - @override - FutureOr getUri(shelf.Request request) => request.requestedUri; -} diff --git a/test/unit/client/request_document_factory_test.dart b/test/unit/client/request_document_factory_test.dart new file mode 100644 index 00000000..03b88b51 --- /dev/null +++ b/test/unit/client/request_document_factory_test.dart @@ -0,0 +1,11 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + test('Generated documents contain no links object', () { + final doc = + RequestDocumentFactory().resourceDocument(Resource('apples', null)); + expect(doc.data.links, isNull); + }); +} diff --git a/test/unit/document/identifier_object_test.dart b/test/unit/document/identifier_object_test.dart index b9232835..531e6c4b 100644 --- a/test/unit/document/identifier_object_test.dart +++ b/test/unit/document/identifier_object_test.dart @@ -1,8 +1,13 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:test/test.dart'; void main() { test('type and id can not be null', () { expect(() => IdentifierObject(null, null), throwsArgumentError); }); + test('throws DocumentException when can not be decoded', () { + expect(() => IdentifierObject.fromJson([]), + throwsA(TypeMatcher())); + }); } diff --git a/test/unit/http/logging_http_handler_test.dart b/test/unit/http/logging_http_handler_test.dart new file mode 100644 index 00000000..bdaeb2ee --- /dev/null +++ b/test/unit/http/logging_http_handler_test.dart @@ -0,0 +1,18 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + test('Logging handler can log', () async { + final rq = HttpRequest('get', Uri.parse('http://localhost')); + final rs = HttpResponse(200, body: 'Hello'); + HttpRequest loggedRq; + HttpResponse loggedRs; + final logger = LoggingHttpHandler( + HttpHandler.fromFunction(((_) async => rs)), + onResponse: (_) => loggedRs = _, + onRequest: (_) => loggedRq = _); + await logger(rq); + expect(loggedRq, same(rq)); + expect(loggedRs, same(rs)); + }); +} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index bc56b1d6..a5dfb041 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -2,6 +2,21 @@ import 'package:json_api/src/query/fields.dart'; import 'package:test/test.dart'; void main() { + test('emptiness', () { + expect(Fields({}).isEmpty, isTrue); + expect(Fields({}).isNotEmpty, isFalse); + + expect( + Fields({ + 'foo': ['bar'] + }).isEmpty, + isFalse); + expect( + Fields({ + 'foo': ['bar'] + }).isNotEmpty, + isTrue); + }); test('Can decode url without duplicate keys', () { final uri = Uri.parse( '/articles?include=author&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 02303ec9..bac10ffa 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -2,6 +2,13 @@ import 'package:json_api/src/query/include.dart'; import 'package:test/test.dart'; void main() { + test('emptiness', () { + expect(Include([]).isEmpty, isTrue); + expect(Include([]).isNotEmpty, isFalse); + expect(Include(['foo']).isEmpty, isFalse); + expect(Include(['foo']).isNotEmpty, isTrue); + }); + test('Can decode url without duplicate keys', () { final uri = Uri.parse('/articles/1?include=author,comments.author'); final include = Include.fromUri(uri); diff --git a/test/unit/query/merge_test.dart b/test/unit/query/merge_test.dart new file mode 100644 index 00000000..d62110da --- /dev/null +++ b/test/unit/query/merge_test.dart @@ -0,0 +1,14 @@ +import 'package:json_api/query.dart'; +import 'package:test/test.dart'; + +void main() { + test('parameters can be merged', () { + final params = Fields({ + 'comments': ['author'] + }) & + Include(['author']) & + Page({'limit': '10'}); + expect(params.addToUri(Uri()).query, + 'fields%5Bcomments%5D=author&include=author&page%5Blimit%5D=10'); + }); +} diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart index 931ca736..d84c80bb 100644 --- a/test/unit/query/page_test.dart +++ b/test/unit/query/page_test.dart @@ -2,6 +2,13 @@ import 'package:json_api/query.dart'; import 'package:test/test.dart'; void main() { + test('emptiness', () { + expect(Page({}).isEmpty, isTrue); + expect(Page({}).isNotEmpty, isFalse); + expect(Page({'foo': 'bar'}).isEmpty, isFalse); + expect(Page({'foo': 'bar'}).isNotEmpty, isTrue); + }); + test('Can decode url', () { final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); final page = Page.fromUri(uri); diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index 2de4d793..8075ff91 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -2,6 +2,13 @@ import 'package:json_api/src/query/sort.dart'; import 'package:test/test.dart'; void main() { + test('emptiness', () { + expect(Sort([]).isEmpty, isTrue); + expect(Sort([]).isNotEmpty, isFalse); + expect(Sort([Desc('created')]).isEmpty, isFalse); + expect(Sort([Desc('created')]).isNotEmpty, isTrue); + }); + test('Can decode url wthout duplicate keys', () { final uri = Uri.parse('/articles?sort=-created,title'); final sort = Sort.fromUri(uri); diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/request_handler_test.dart index 62bd9e2f..37e56a5c 100644 --- a/test/unit/server/request_handler_test.dart +++ b/test/unit/server/request_handler_test.dart @@ -1,21 +1,22 @@ -import 'dart:async'; import 'dart:convert'; import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; void main() { - final base = Uri.parse('http://localhost'); - var uriDesign = UriDesign.standard(base); - final handler = RequestHandler(TestAdapter(), DummyController(), uriDesign); + final url = UriDesign.standard(Uri.parse('http://exapmle.com')); + final server = + JsonApiServer(url, RepositoryController(InMemoryRepository({}))); group('HTTP Handler', () { test('returns `bad request` on incomplete relationship', () async { - final rq = TestRequest( - uriDesign.relationshipUri('books', '1', 'author'), 'PATCH', '{}'); - final rs = await handler.call(rq); + final rq = HttpRequest( + 'PATCH', url.relationshipUri('books', '1', 'author'), + body: '{}'); + final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); @@ -25,8 +26,8 @@ void main() { test('returns `bad request` when payload is not a valid JSON', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'POST', '"ololo"abc'); - final rs = await handler.call(rq); + HttpRequest('POST', url.collectionUri('books'), body: '"ololo"abc'); + final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); @@ -37,8 +38,8 @@ void main() { test('returns `bad request` when payload is not a valid JSON:API object', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'POST', '"oops"'); - final rs = await handler.call(rq); + HttpRequest('POST', url.collectionUri('books'), body: '"oops"'); + final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); @@ -49,8 +50,8 @@ void main() { test('returns `bad request` when payload violates JSON:API', () async { final rq = - TestRequest(uriDesign.collectionUri('books'), 'POST', '{"data": {}}'); - final rs = await handler.call(rq); + HttpRequest('POST', url.collectionUri('books'), body: '{"data": {}}'); + final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); @@ -59,9 +60,8 @@ void main() { }); test('returns `not found` if URI is not recognized', () async { - final rq = - TestRequest(Uri.parse('http://localhost/a/b/c/d/e'), 'GET', ''); - final rs = await handler.call(rq); + final rq = HttpRequest('GET', Uri.parse('http://localhost/a/b/c/d/e')); + final rs = await server(rq); expect(rs.statusCode, 404); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '404'); @@ -70,10 +70,10 @@ void main() { }); test('returns `method not allowed` for resource collection', () async { - final rq = TestRequest(uriDesign.collectionUri('books'), 'DELETE', ''); - final rs = await handler.call(rq); + final rq = HttpRequest('DELETE', url.collectionUri('books')); + final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['Allow'], 'GET, POST'); + expect(rs.headers['allow'], 'GET, POST'); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '405'); expect(error.title, 'Method Not Allowed'); @@ -81,10 +81,10 @@ void main() { }); test('returns `method not allowed` for resource ', () async { - final rq = TestRequest(uriDesign.resourceUri('books', '1'), 'POST', ''); - final rs = await handler.call(rq); + final rq = HttpRequest('POST', url.resourceUri('books', '1')); + final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['Allow'], 'DELETE, GET, PATCH'); + expect(rs.headers['allow'], 'DELETE, GET, PATCH'); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '405'); expect(error.title, 'Method Not Allowed'); @@ -92,11 +92,10 @@ void main() { }); test('returns `method not allowed` for related ', () async { - final rq = - TestRequest(uriDesign.relatedUri('books', '1', 'author'), 'POST', ''); - final rs = await handler.call(rq); + final rq = HttpRequest('POST', url.relatedUri('books', '1', 'author')); + final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['Allow'], 'GET'); + expect(rs.headers['allow'], 'GET'); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '405'); expect(error.title, 'Method Not Allowed'); @@ -104,11 +103,11 @@ void main() { }); test('returns `method not allowed` for relationship ', () async { - final rq = TestRequest( - uriDesign.relationshipUri('books', '1', 'author'), 'PUT', ''); - final rs = await handler.call(rq); + final rq = + HttpRequest('PUT', url.relationshipUri('books', '1', 'author')); + final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['Allow'], 'DELETE, GET, PATCH, POST'); + expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST'); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '405'); expect(error.title, 'Method Not Allowed'); @@ -116,113 +115,3 @@ void main() { }); }); } - -class TestAdapter implements HttpAdapter { - @override - FutureOr createResponse( - int statusCode, String body, Map headers) => - TestResponse(statusCode, body, headers); - - @override - FutureOr getBody(TestRequest request) => request.body; - - @override - FutureOr getMethod(TestRequest request) => request.method; - - @override - FutureOr getUri(TestRequest request) => request.uri; -} - -class TestRequest { - final Uri uri; - final String method; - final String body; - - TestRequest(this.uri, this.method, this.body); -} - -class TestResponse { - final int statusCode; - final String body; - final Map headers; - - Document get document => Document.fromJson(json.decode(body), null); - - TestResponse(this.statusCode, this.body, this.headers); -} - -class DummyController implements JsonApiController { - @override - FutureOr addToRelationship(request, String type, String id, - String relationship, Iterable identifiers) { - // TODO: implement addToRelationship - return null; - } - - @override - FutureOr createResource( - request, String type, Resource resource) { - // TODO: implement createResource - return null; - } - - @override - FutureOr deleteFromRelationship(request, String type, - String id, String relationship, Iterable identifiers) { - // TODO: implement deleteFromRelationship - return null; - } - - @override - FutureOr deleteResource(request, String type, String id) { - // TODO: implement deleteResource - return null; - } - - @override - FutureOr fetchCollection(request, String type) { - // TODO: implement fetchCollection - return null; - } - - @override - FutureOr fetchRelated( - request, String type, String id, String relationship) { - // TODO: implement fetchRelated - return null; - } - - @override - FutureOr fetchRelationship( - request, String type, String id, String relationship) { - // TODO: implement fetchRelationship - return null; - } - - @override - FutureOr fetchResource(request, String type, String id) { - // TODO: implement fetchResource - return null; - } - - @override - FutureOr replaceToMany(request, String type, String id, - String relationship, Iterable identifiers) { - // TODO: implement replaceToMany - return null; - } - - @override - FutureOr replaceToOne(request, String type, String id, - String relationship, Identifier identifier) { - // TODO: implement replaceToOne - return null; - } - - @override - FutureOr updateResource( - request, String type, String id, Resource resource) { - // TODO: implement updateResource - return null; - } -} diff --git a/tmp/crud_test.dart b/tmp/crud_test.dart deleted file mode 100644 index 6ea06669..00000000 --- a/tmp/crud_test.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import '../../../example/server/controller/crud_controller.dart'; -import '../../../example/server/shelf_request_response_converter.dart'; - -/// Basic CRUD operations -void main() async { - HttpServer server; - UriAwareClient client; - final host = 'localhost'; - final port = 8081; - final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); - final people = [ - 'Erich Gamma', - 'Richard Helm', - 'Ralph Johnson', - 'John Vlissides', - ] - .map((name) => name.split(' ')) - .map((name) => Resource('people', Uuid().v4(), - attributes: {'firstName': name.first, 'lastName': name.last})) - .toList(); - - final publisher = Resource('companies', Uuid().v4(), - attributes: {'name': 'Addison-Wesley'}); - - final book = Resource('books', Uuid().v4(), - attributes: {'title': 'Design Patterns'}, - toOne: {'publisher': Identifier.of(publisher)}, - toMany: {'authors': people.map(Identifier.of).toList()}); - - setUp(() async { - client = UriAwareClient(design); - final handler = RequestHandler( - ShelfRequestResponseConverter(), - CRUDController( - Uuid().v4, const ['people', 'books', 'companies'].contains), - design); - - server = await serve(handler, host, port); - - await for (final resource - in Stream.fromIterable([...people, publisher, book])) { - await client.createResource(resource); - } - }); - - tearDown(() async { - client.close(); - await server.close(); - }); - - group('Fetch', () { - test('a primary resource', () async { - final r = await client.fetchResource(book.type, book.id); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().attributes['title'], 'Design Patterns'); - expect(r.data.unwrap().toOne['publisher'].type, publisher.type); - expect(r.data.unwrap().toOne['publisher'].id, publisher.id); - expect(r.data.unwrap().toMany['authors'].length, 4); - expect(r.data.unwrap().toMany['authors'].first.type, 'people'); - expect(r.data.unwrap().toMany['authors'].last.type, 'people'); - }); - - test('a non-existing primary resource', () async { - final r = await client.fetchResource('books', '1'); - expect(r.statusCode, 404); - expect(r.isSuccessful, isFalse); - expect(r.document.errors.first.detail, 'Resource not found'); - }); - - test('a primary collection', () async { - final r = await client.fetchCollection('people'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 4); - expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); - expect(r.data.unwrap().first.attributes['lastName'], 'Gamma'); - }); - - test('a non-existing primary collection', () async { - final r = await client.fetchCollection('unicorns'); - expect(r.statusCode, 404); - expect(r.isSuccessful, isFalse); - expect(r.document.errors.first.detail, 'Collection not found'); - }); - - test('a related resource', () async { - final r = - await client.fetchRelatedResource(book.type, book.id, 'publisher'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().attributes['name'], 'Addison-Wesley'); - }); - - test('a related collection', () async { - final r = - await client.fetchRelatedCollection(book.type, book.id, 'authors'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 4); - expect(r.data.unwrap().first.attributes['firstName'], 'Erich'); - expect(r.data.unwrap().first.attributes['lastName'], 'Gamma'); - }); - - test('a to-one relationship', () async { - final r = await client.fetchToOne(book.type, book.id, 'publisher'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().type, publisher.type); - expect(r.data.unwrap().id, publisher.id); - }); - - test('a generic to-one relationship', () async { - final r = await client.fetchRelationship(book.type, book.id, 'publisher'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - - final data = r.data; - if (data is ToOne) { - expect(data.unwrap().type, publisher.type); - expect(data.unwrap().id, publisher.id); - } else { - fail('data is not ToOne'); - } - }); - - test('a to-many relationship', () async { - final r = await client.fetchToMany(book.type, book.id, 'authors'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 4); - expect(r.data.unwrap().first.type, people.first.type); - expect(r.data.unwrap().first.id, people.first.id); - }); - - test('a generic to-many relationship', () async { - final r = await client.fetchRelationship(book.type, book.id, 'authors'); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - final data = r.data; - if (data is ToMany) { - expect(data.unwrap().length, 4); - expect(data.unwrap().first.type, people.first.type); - expect(data.unwrap().first.id, people.first.id); - } else { - fail('data is not ToMany'); - } - }); - }, testOn: 'vm'); - - group('Delete', () { - test('a primary resource', () async { - await client.deleteResource(book.type, book.id); - - final r = await client.fetchResource(book.type, book.id); - expect(r.statusCode, 404); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - }); - - test('a to-one relationship', () async { - await client.deleteToOne(book.type, book.id, 'publisher'); - - final r = await client.fetchResource(book.type, book.id); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().toOne['publisher'], isNull); - }); - - test('in a to-many relationship', () async { - await client.deleteFromToMany( - book.type, book.id, 'authors', people.take(2).map(Identifier.of)); - - final r = await client.fetchToMany(book.type, book.id, 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().last.id, people.last.id); - }); - }, testOn: 'vm'); - - group('Create', () { - test('a primary resource, id assigned on the server', () async { - final book = Resource('books', null, - attributes: {'title': 'The Lord of the Rings'}); - final r0 = await client.createResource(book); - expect(r0.statusCode, 201); - final r1 = await JsonApiClient().fetchResource(r0.location); - expect(r1.data.unwrap().attributes, equals(book.attributes)); - expect(r1.data.unwrap().type, equals(book.type)); - }); - }, testOn: 'vm'); - - group('Update', () { - test('a primary resource', () async { - await client.updateResource(book.replace(attributes: {'pageCount': 416})); - - final r = await client.fetchResource(book.type, book.id); - expect(r.statusCode, 200); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().attributes['pageCount'], 416); - }); - - test('to-one relationship', () async { - await client.replaceToOne( - book.type, book.id, 'publisher', Identifier('companies', '100')); - - final r = await client.fetchResource(book.type, book.id); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().toOne['publisher'].id, '100'); - }); - - test('a to-many relationship by adding more identifiers', () async { - await client.addToRelationship( - book.type, book.id, 'authors', [Identifier('people', '100')]); - - final r = await client.fetchToMany(book.type, book.id, 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 5); - expect(r.data.unwrap().last.id, '100'); - }); - - test('a to-many relationship by replacing', () async { - await client.replaceToMany( - book.type, book.id, 'authors', [Identifier('people', '100')]); - - final r = await client.fetchToMany(book.type, book.id, 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.data.unwrap().length, 1); - expect(r.data.unwrap().first.id, '100'); - }); - }, testOn: 'vm'); -} From dcfc0f9da00abf1fe732a03ecde978db94c57b3c Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 31 Jan 2020 00:23:12 -0800 Subject: [PATCH 17/99] wip --- example/client.dart | 51 ++++++++++++++++--- example/server.dart | 5 +- lib/client.dart | 2 +- .../{dart_http_client.dart => dart_http.dart} | 10 +--- lib/src/client/json_api_client.dart | 15 +++--- lib/src/client/request_document_factory.dart | 2 +- lib/src/client/simple_client.dart | 8 +-- lib/src/http/http_request.dart | 8 ++- lib/src/http/http_response.dart | 8 ++- lib/src/server/json_api_server.dart | 3 +- test/e2e/client_server_interaction.dart | 48 +++++++++++++++-- test/functional/basic_crud_test.dart | 7 ++- test/functional/compound_document_test.dart | 3 +- ...er_test.dart => json_api_server_test.dart} | 2 +- 14 files changed, 121 insertions(+), 51 deletions(-) rename lib/src/client/{dart_http_client.dart => dart_http.dart} (62%) rename test/unit/server/{request_handler_test.dart => json_api_server_test.dart} (99%) diff --git a/example/client.dart b/example/client.dart index a57a2cd2..2e41f8fc 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,19 +1,56 @@ +import 'package:http/http.dart'; import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/client/dart_http.dart'; import 'package:json_api/uri_design.dart'; /// This example shows how to use the JSON:API client. /// Run the server first! -void main() { +void main() async { /// Use the same URI design as the server final uriDesign = UriDesign.standard(Uri.parse('http://localhost:8080')); - /// There are two clients in this library: - /// - JsonApiClient, the main implementation, most flexible but a bit verbose - /// - SimpleClient, less boilerplate but not as flexible - /// The example will show both in parallel - final client = JsonApiClient(); - final simpleClient = SimpleClient(uriDesign); + /// Create the HTTP client. We're using Dart's native client. + /// Do not forget to call [Client.close] when you're done using it. + final httpClient = Client(); + /// We'll use a logging handler to how the requests and responses + final httpHandler = LoggingHttpHandler(DartHttp(httpClient), + onRequest: print, onResponse: print); + /// The JSON:API client + final jsonApiClient = JsonApiClient(httpHandler); + /// We will use a wrapper over the JSON:API client to reduce boilerplate code. + /// This wrapper makes use of the URI design to build query URIs. + final client = SimpleClient(uriDesign, jsonApiClient); + + /// Create the first resource + await client.createResource( + Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); + + /// Create the second resource + await client.createResource(Resource('books', '2', attributes: { + 'title': 'Refactoring' + }, toMany: { + 'authors': [Identifier('writers', '1')] + })); + + /// Fetch the book, including its authors + final response = await client.fetchResource('books', '2', + parameters: Include(['authors'])); + + /// Extract the primary resource + final book = response.data.unwrap(); + + /// Extract the included resource + final author = response.data.included.first.unwrap(); + + print('Book: $book'); + print('Author: $author'); + + /// Do not forget to always close the HTTP client. + httpClient.close(); } diff --git a/example/server.dart b/example/server.dart index 35f65d19..23eef1fe 100644 --- a/example/server.dart +++ b/example/server.dart @@ -30,9 +30,8 @@ void main() async { final jsonApiServer = JsonApiServer(uriDesign, controller); /// We will be logging the requests and responses to the console - final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, - onRequest: (r) => print('>> ${r.method} ${r.uri}'), - onResponse: (r) => print('<< ${r.statusCode}')); + final loggingJsonApiServer = + LoggingHttpHandler(jsonApiServer, onRequest: print, onResponse: print); /// The handler for the built-in HTTP server final serverHandler = DartServerHandler(loggingJsonApiServer); diff --git a/lib/client.dart b/lib/client.dart index 45f18d26..85a81858 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -3,5 +3,5 @@ library client; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_response.dart'; export 'package:json_api/src/client/request_document_factory.dart'; -export 'package:json_api/src/client/status_code.dart'; export 'package:json_api/src/client/simple_client.dart'; +export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/src/client/dart_http_client.dart b/lib/src/client/dart_http.dart similarity index 62% rename from lib/src/client/dart_http_client.dart rename to lib/src/client/dart_http.dart index ff6c38bd..b08d1ff8 100644 --- a/lib/src/client/dart_http_client.dart +++ b/lib/src/client/dart_http.dart @@ -2,7 +2,7 @@ import 'package:http/http.dart'; import 'package:json_api/http.dart'; /// A handler using the Dart's built-in http client -class DartHttpClient implements HttpHandler { +class DartHttp implements HttpHandler { @override Future call(HttpRequest request) async { final response = await _send(Request(request.method, request.uri) @@ -12,13 +12,7 @@ class DartHttpClient implements HttpHandler { body: response.body, headers: response.headers); } - /// Calls the inner client's `close()`. You have to either call this method - /// or close the inner client yourself! - /// - /// See https://pub.dev/documentation/http/latest/http/Client/close.html - void close() => _client.close(); - - DartHttpClient([Client client]) : _client = client ?? Client(); + DartHttp(this._client); final Client _client; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 92511b28..4091c5c6 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/dart_http_client.dart'; import 'package:json_api/src/client/json_api_response.dart'; import 'package:json_api/src/client/request_document_factory.dart'; import 'package:json_api/src/client/status_code.dart'; @@ -127,14 +126,12 @@ class JsonApiClient { ToMany.fromJson); /// Creates an instance of JSON:API client. - /// You have to create and pass an instance of the [httpClient] yourself. - /// Do not forget to call [httpClient.close] when you're done using - /// the JSON:API client. - /// The [onHttpCall] hook, if passed, gets called when an http response is - /// received from the HTTP Client. - JsonApiClient({RequestDocumentFactory builder, HttpHandler httpClient}) - : _factory = builder ?? RequestDocumentFactory(api: Api(version: '1.0')), - _http = httpClient ?? DartHttpClient(); + /// Pass an instance of DartHttpClient (comes with this package) or + /// another instance of [HttpHandler]. + /// Use a custom [documentFactory] if you want to build the outgoing + /// documents in a special way. + JsonApiClient(this._http, {RequestDocumentFactory documentFactory}) + : _factory = documentFactory ?? RequestDocumentFactory(); final HttpHandler _http; final RequestDocumentFactory _factory; diff --git a/lib/src/client/request_document_factory.dart b/lib/src/client/request_document_factory.dart index b3992b50..bb29ff2e 100644 --- a/lib/src/client/request_document_factory.dart +++ b/lib/src/client/request_document_factory.dart @@ -17,7 +17,7 @@ class RequestDocumentFactory { Document(ToOne(nullable(IdentifierObject.fromIdentifier)(id)), api: _api); /// Creates an instance of the factory. - RequestDocumentFactory({Api api}) : _api = api; + RequestDocumentFactory({Api api}) : _api = api ?? Api(version: '1.0'); final Api _api; diff --git a/lib/src/client/simple_client.dart b/lib/src/client/simple_client.dart index c90eb840..cf29eb1c 100644 --- a/lib/src/client/simple_client.dart +++ b/lib/src/client/simple_client.dart @@ -1,8 +1,6 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/dart_http_client.dart'; import 'package:json_api/uri_design.dart'; /// A wrapper over [JsonApiClient] making use of the given UrlFactory. @@ -167,10 +165,8 @@ class SimpleClient { _uriFactory.relationshipUri(type, id, relationship), identifier, headers: headers); - SimpleClient(this._uriFactory, - {JsonApiClient jsonApiClient, HttpHandler httpHandler}) - : _client = jsonApiClient ?? - JsonApiClient(httpClient: httpHandler ?? DartHttpClient()); + SimpleClient(this._uriFactory, this._client); + final JsonApiClient _client; final UriFactory _uriFactory; } diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index fd088f3f..684ac0c8 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -14,8 +14,12 @@ class HttpRequest { /// Request headers. Unmodifiable. Lowercase keys final Map headers; + @override + String toString() => 'HttpRequest($method $uri)'; + HttpRequest(String method, this.uri, - {this.body = '', Map headers}) + {String body, Map headers}) : headers = normalize(headers), - method = method.toUpperCase(); + method = method.toUpperCase(), + body = body ?? ''; } diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index a263a33a..acaa2999 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -11,6 +11,10 @@ class HttpResponse { /// Response headers. Unmodifiable. Lowercase keys final Map headers; - HttpResponse(this.statusCode, {this.body = '', Map headers}) - : headers = normalize(headers); + @override + String toString() => 'HttpResponse($statusCode)'; + + HttpResponse(this.statusCode, {String body, Map headers}) + : headers = normalize(headers), + body = body ?? ''; } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 182a2006..7b560ee8 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -10,8 +10,9 @@ class JsonApiServer implements HttpHandler { @override Future call(HttpRequest request) async { final response = await _do(request); + final document = response.buildDocument(_factory, request.uri); return HttpResponse(response.statusCode, - body: jsonEncode(response.buildDocument(_factory, request.uri)), + body: document == null ? null : jsonEncode(document), headers: response.buildHeaders(_uriDesign)); } diff --git a/test/e2e/client_server_interaction.dart b/test/e2e/client_server_interaction.dart index aba957bf..cce25cac 100644 --- a/test/e2e/client_server_interaction.dart +++ b/test/e2e/client_server_interaction.dart @@ -1,16 +1,56 @@ +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; +import 'package:json_api/src/client/dart_http.dart'; import 'package:json_api/uri_design.dart'; +import 'package:pedantic/pedantic.dart'; import 'package:test/test.dart'; void main() { group('Client-Server interation over HTTP', () { final port = 8088; final host = 'localhost'; - final uri = UriDesign.standard(Uri(host: host, port: port)); - final repo = InMemoryRepository({'people': {}, 'books': {}}); - final server = JsonApiServer(uri, RepositoryController(repo)); - test('can create and fetch resources', () { + final design = + UriDesign.standard(Uri(host: host, port: port, scheme: 'http')); + final repo = InMemoryRepository({'writers': {}, 'books': {}}); + final jsonApiServer = JsonApiServer(design, RepositoryController(repo)); + final serverHandler = DartServerHandler(jsonApiServer); + Client httpClient; + SimpleClient client; + HttpServer server; + + setUp(() async { + server = await HttpServer.bind(host, port); + httpClient = Client(); + client = SimpleClient(design, JsonApiClient(DartHttp(httpClient))); + unawaited(server.forEach(serverHandler)); + }); + + tearDown(() async { + httpClient.close(); + await server.close(); + }); + + test('can create and fetch resources', () async { + await client.createResource( + Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); + + await client.createResource(Resource('books', '2', attributes: { + 'title': 'Refactoring' + }, toMany: { + 'authors': [Identifier('writers', '1')] + })); + + final response = await client.fetchResource('books', '2', + parameters: Include(['authors'])); + expect(response.data.unwrap().attributes['title'], 'Refactoring'); + expect(response.data.included.first.unwrap().attributes['name'], + 'Martin Fowler'); }); }, testOn: 'vm'); } diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart index f39c57a1..118d2f04 100644 --- a/test/functional/basic_crud_test.dart +++ b/test/functional/basic_crud_test.dart @@ -29,7 +29,7 @@ void main() async { 'apples': {} }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4()); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, httpHandler: server); + client = SimpleClient(design, JsonApiClient(server)); }); group('Creating Resources', () { @@ -45,8 +45,7 @@ void main() async { expect(created.type, person.type); expect(created.id, isNotNull); expect(created.attributes, equals(person.attributes)); - final r1 = - await JsonApiClient(httpClient: server).fetchResource(r.location); + final r1 = await JsonApiClient(server).fetchResource(r.location); expect(r1.isSuccessful, isTrue); expect(r1.statusCode, 200); expectResourcesEqual(r1.data.unwrap(), created); @@ -120,7 +119,7 @@ void main() async { }); test('409 when the resource type does not match collection', () async { - final r = await JsonApiClient(httpClient: server).createResource( + final r = await JsonApiClient(server).createResource( design.collectionUri('fruits'), Resource('cucumbers', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index a3360883..c4654c91 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -40,7 +40,6 @@ void main() async { }); setUp(() async { - client = SimpleClient(design); final repository = InMemoryRepository({ 'posts': {'1': post}, 'comments': {'1': comment1, '2': comment2}, @@ -49,7 +48,7 @@ void main() async { 'tags': {} }); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, httpHandler: server); + client = SimpleClient(design, JsonApiClient(server)); }); group('Compound document', () { diff --git a/test/unit/server/request_handler_test.dart b/test/unit/server/json_api_server_test.dart similarity index 99% rename from test/unit/server/request_handler_test.dart rename to test/unit/server/json_api_server_test.dart index 37e56a5c..075a6a90 100644 --- a/test/unit/server/request_handler_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -11,7 +11,7 @@ void main() { final server = JsonApiServer(url, RepositoryController(InMemoryRepository({}))); - group('HTTP Handler', () { + group('JsonApiServer', () { test('returns `bad request` on incomplete relationship', () async { final rq = HttpRequest( 'PATCH', url.relationshipUri('books', '1', 'author'), From b270130c11be23d31d297e2c1eace10d3684ba4f Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 2 Feb 2020 18:34:04 -0800 Subject: [PATCH 18/99] wip --- lib/src/document/resource.dart | 36 +- lib/src/http/http_request.dart | 3 - lib/src/http/http_response.dart | 3 - lib/src/server/in_memory_repository.dart | 18 +- pubspec.yaml | 2 +- ...rt => client_server_interaction_test.dart} | 0 test/functional/basic_crud_test.dart | 776 ------------------ test/functional/compound_document_test.dart | 182 ++-- .../crud/creating_resources_test.dart | 155 ++++ .../crud/deleting_resources_test.dart | 62 ++ .../crud/fetching_relationships_test.dart | 162 ++++ .../crud/fetching_resources_test.dart | 150 ++++ test/functional/crud/seed_resources.dart | 23 + .../crud/updating_relationships_test.dart | 240 ++++++ .../crud/updating_resources_test.dart | 85 ++ tmp/pagination_test.dart | 60 -- tmp/sorting_test.dart | 82 -- 17 files changed, 1002 insertions(+), 1037 deletions(-) rename test/e2e/{client_server_interaction.dart => client_server_interaction_test.dart} (100%) delete mode 100644 test/functional/basic_crud_test.dart create mode 100644 test/functional/crud/creating_resources_test.dart create mode 100644 test/functional/crud/deleting_resources_test.dart create mode 100644 test/functional/crud/fetching_relationships_test.dart create mode 100644 test/functional/crud/fetching_resources_test.dart create mode 100644 test/functional/crud/seed_resources.dart create mode 100644 test/functional/crud/updating_relationships_test.dart create mode 100644 test/functional/crud/updating_resources_test.dart delete mode 100644 tmp/pagination_test.dart delete mode 100644 tmp/sorting_test.dart diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 67db9596..3d2deffa 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -24,20 +24,6 @@ class Resource { /// Unmodifiable map of to-many relationships final Map> toMany; - /// Creates an instance of [Resource]. - /// The [type] can not be null. - /// The [id] may be null for the resources to be created on the server. - Resource(this.type, this.id, - {Map attributes, - Map toOne, - Map> toMany}) - : attributes = Map.unmodifiable(attributes ?? {}), - toOne = Map.unmodifiable(toOne ?? {}), - toMany = Map.unmodifiable( - (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { - DocumentException.throwIfNull(type, "Resource 'type' must not be null"); - } - /// Resource type and id combined String get key => '$type:$id'; @@ -62,4 +48,26 @@ class Resource { attributes: attributes ?? this.attributes, toOne: toOne ?? this.toOne, toMany: toMany ?? this.toMany); + + /// Creates an instance of [Resource]. + /// The [type] can not be null. + /// The [id] may be null for the resources to be created on the server. + Resource(this.type, this.id, + {Map attributes, + Map toOne, + Map> toMany}) + : attributes = Map.unmodifiable(attributes ?? {}), + toOne = Map.unmodifiable(toOne ?? {}), + toMany = Map.unmodifiable( + (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { + DocumentException.throwIfNull(type, "Resource 'type' must not be null"); + } + + /// Returns a resource to be created on the server (without the "id") + static Resource toCreate(String type, + {Map attributes, + Map toOne, + Map> toMany}) => + Resource(type, null, + attributes: attributes, toMany: toMany, toOne: toOne); } diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 684ac0c8..751d6daa 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -14,9 +14,6 @@ class HttpRequest { /// Request headers. Unmodifiable. Lowercase keys final Map headers; - @override - String toString() => 'HttpRequest($method $uri)'; - HttpRequest(String method, this.uri, {String body, Map headers}) : headers = normalize(headers), diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index acaa2999..2f94120e 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -11,9 +11,6 @@ class HttpResponse { /// Response headers. Unmodifiable. Lowercase keys final Map headers; - @override - String toString() => 'HttpResponse($statusCode)'; - HttpResponse(this.statusCode, {String body, Map headers}) : headers = normalize(headers), body = body ?? ''; diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index b2ad8c4c..d9e39102 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -3,14 +3,14 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/repository.dart'; -typedef IdGenerator = String Function(String collection); +typedef IdGenerator = String Function(); typedef TypeAttributionCriteria = bool Function(String collection, String type); final _typeEqualsCollection = ((t, s) => t == s); class InMemoryRepository implements Repository { final Map> _collections; - final IdGenerator _generateId; + final IdGenerator _nextId; final TypeAttributionCriteria _typeBelongs; @override @@ -26,10 +26,10 @@ class InMemoryRepository implements Repository { await get(relationship.type, relationship.id); } if (resource.id == null) { - final id = _generateId?.call(collection); - if (id == null) { + if (_nextId == null) { throw UnsupportedOperation('Id generation is not supported'); } + final id = _nextId(); final created = resource.replace(id: id); _collections[collection][created.id] = created; return created; @@ -61,6 +61,12 @@ class InMemoryRepository implements Repository { throw _invalidType(resource, collection); } final original = await get(collection, id); + if (resource.attributes.isEmpty && + resource.toOne.isEmpty && + resource.toMany.isEmpty && + resource.id == id) { + return null; + } final updated = Resource( original.type, original.id, @@ -94,7 +100,7 @@ class InMemoryRepository implements Repository { } InMemoryRepository(this._collections, - {TypeAttributionCriteria typeBelongs, IdGenerator generateId}) + {TypeAttributionCriteria typeBelongs, IdGenerator nextId}) : _typeBelongs = typeBelongs ?? _typeEqualsCollection, - _generateId = generateId; + _nextId = nextId; } diff --git a/pubspec.yaml b/pubspec.yaml index 3ab90161..565ac13e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-dev.1 +version: 4.0.0-dev.2 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: diff --git a/test/e2e/client_server_interaction.dart b/test/e2e/client_server_interaction_test.dart similarity index 100% rename from test/e2e/client_server_interaction.dart rename to test/e2e/client_server_interaction_test.dart diff --git a/test/functional/basic_crud_test.dart b/test/functional/basic_crud_test.dart deleted file mode 100644 index 118d2f04..00000000 --- a/test/functional/basic_crud_test.dart +++ /dev/null @@ -1,776 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import '../helper/expect_resources_equal.dart'; - -void main() async { - SimpleClient client; - JsonApiServer server; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); - - setUp(() async { - final repository = InMemoryRepository({ - 'books': {}, - 'people': {}, - 'companies': {}, - 'noServerId': {}, - 'fruits': {}, - 'apples': {} - }, generateId: (_) => _ == 'noServerId' ? null : Uuid().v4()); - server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); - }); - - group('Creating Resources', () { - test('id generated on the server', () async { - final person = - Resource('people', null, attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); - expect(r.isSuccessful, isTrue); - expect(r.isFailed, isFalse); - expect(r.statusCode, 201); - expect(r.location, isNotNull); - final created = r.data.unwrap(); - expect(created.type, person.type); - expect(created.id, isNotNull); - expect(created.attributes, equals(person.attributes)); - final r1 = await JsonApiClient(server).fetchResource(r.location); - expect(r1.isSuccessful, isTrue); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), created); - }); - - test('id generated on the client, the resource is not modified', () async { - final person = - Resource('people', '123', attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.location, isNull); - expect(r.data, isNull); - final r1 = await client.fetchResource(person.type, person.id); - expect(r1.isSuccessful, isTrue); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), person); - }); - - test('403 when the id can not be generated', () async { - final r = await client.createResource(Resource('noServerId', null)); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 403); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '403'); - expect(error.title, 'Unsupported operation'); - expect(error.detail, 'Id generation is not supported'); - }); - - test('404 when the collection does not exist', () async { - final r = await client.createResource(Resource('unicorns', null)); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when the related resource does not exist (to-one)', () async { - final book = Resource('books', null, - toOne: {'publisher': Identifier('companies', '123')}); - final r = await client.createResource(book); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'companies'"); - }); - - test('404 when the related resource does not exist (to-many)', () async { - final book = Resource('books', null, toMany: { - 'authors': [Identifier('people', '123')] - }); - final r = await client.createResource(book); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'people'"); - }); - - test('409 when the resource type does not match collection', () async { - final r = await JsonApiClient(server).createResource( - design.collectionUri('fruits'), Resource('cucumbers', null)); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); - }); - - test('409 when the resource with this id already exists', () async { - final apple = Resource('apples', '123'); - await client.createResource(apple); - final r = await client.createResource(apple); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Resource exists'); - expect(error.detail, 'Resource with this type and id already exists'); - }); - }, testOn: 'vm'); - - group('Updating and Fetching Resources and Relationships', () { - setUp(() async { - await client.createResource( - Resource('people', '1', attributes: {'name': 'Martin Fowler'})); - await client.createResource( - Resource('people', '2', attributes: {'name': 'Kent Beck'})); - await client.createResource( - Resource('people', '3', attributes: {'name': 'Robert Martin'})); - await client.createResource(Resource('companies', '1', - attributes: {'name': 'Addison-Wesley Professional'})); - await client.createResource( - Resource('companies', '2', attributes: {'name': 'Prentice Hall'})); - await client.createResource(Resource('books', '1', attributes: { - 'title': 'Refactoring', - 'ISBN-10': '0134757599' - }, toOne: { - 'publisher': Identifier('companies', '1') - }, toMany: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] - })); - }); - - group('Updating Resources', () { - test('Update resource attributes and relationships', () async { - final r = - await client.updateResource(Resource('books', '1', attributes: { - 'title': 'Refactoring. Improving the Design of Existing Code', - 'pages': 448 - }, toOne: { - 'publisher': null - }, toMany: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] - })); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().attributes['title'], - 'Refactoring. Improving the Design of Existing Code'); - expect(r.data.unwrap().attributes['pages'], 448); - expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.data.unwrap().toOne['publisher'], isNull); - expect(r.data.unwrap().toMany['authors'], - equals([Identifier('people', '1')])); - expect(r.data.unwrap().toMany['reviewers'], - equals([Identifier('people', '2')])); - - final r1 = await client.fetchResource('books', '1'); - expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); - }); - - test('404 when the target resource does not exist', () async { - final r = - await client.updateResource(Resource('books', '42'), id: '42'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - - test('409 when the resource type does not match the collection', - () async { - final r = await client.updateResource(Resource('books', '1'), - collection: 'people'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'books' does not belong in 'people'"); - }); - }); - - group('Fetching Resource', () { - test('successful', () async { - final r = await client.fetchResource('people', '1'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().id, '1'); - }); - - test('successful compound', () async { - final r = - await client.fetchResource('books', '1', parameters: Include([''])); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = await client.fetchResource('unicorns', '1'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchResource('people', '42'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'people'"); - }); - }); - - group('Fetching Resources with', () { - test('successful', () async { - final r = await client.fetchResource('people', '1'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = await client.fetchResource('unicorns', '1'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchResource('people', '42'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'people'"); - }); - }); - - group('Fetching primary collections', () { - test('successful', () async { - final r = await client.fetchCollection('people'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 3); - }); - test('404', () async { - final r = await client.fetchCollection('unicorns'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - }); - - group('Fetching Related Resources', () { - test('successful', () async { - final r = await client.fetchRelatedResource('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = - await client.fetchRelatedResource('unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchRelatedResource('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await client.fetchRelatedResource('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'owner' does not exist in 'books:1'"); - }); - }); - - group('Fetching Related Collections', () { - test('successful', () async { - final r = await client.fetchRelatedCollection('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); - }); - - test('404 on collection', () async { - final r = - await client.fetchRelatedCollection('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchRelatedCollection('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await client.fetchRelatedCollection('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); - }); - }); - - group('Fetching a to-one relationship', () { - test('successful', () async { - final r = await client.fetchToOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = await client.fetchToOne('unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await client.fetchToOne('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'owner' does not exist in 'books:1'"); - }); - }); - - group('Fetching a to-many relationship', () { - test('successful', () async { - final r = await client.fetchToMany('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.type, 'people'); - }); - - test('404 on collection', () async { - final r = await client.fetchToMany('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchToMany('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await client.fetchToMany('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); - }); - }); - - group('Fetching a generic relationship', () { - test('successful to-one', () async { - final r = await client.fetchRelationship('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - final rel = r.data; - if (rel is ToOne) { - expect(rel.unwrap().type, 'companies'); - expect(rel.unwrap().id, '1'); - } else { - fail('Not a ToOne relationship'); - } - }); - - test('successful to-many', () async { - final r = await client.fetchRelationship('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - final rel = r.data; - if (rel is ToMany) { - expect(rel.unwrap().length, 2); - expect(rel.unwrap().first.id, '1'); - expect(rel.unwrap().first.type, 'people'); - expect(rel.unwrap().last.id, '2'); - expect(rel.unwrap().last.type, 'people'); - } else { - fail('Not a ToMany relationship'); - } - }); - - test('404 on collection', () async { - final r = await client.fetchRelationship('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await client.fetchRelationship('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect( - r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await client.fetchRelationship('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); - }); - }); - - group('Updatng a to-one relationship', () { - test('successfully', () async { - final r = await client.replaceToOne( - 'books', '1', 'publisher', Identifier('companies', '2')); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'].id, '2'); - }); - - test('404 when collection not found', () async { - final r = await client.replaceToOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await client.replaceToOne( - 'books', '42', 'publisher', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Deleting a to-one relationship', () { - test('successfully', () async { - final r = await client.deleteToOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'], isNull); - }); - - test('404 when collection not found', () async { - final r = await client.deleteToOne('unicorns', '1', 'breed'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await client.deleteToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Replacing a to-many relationship', () { - test('successfully', () async { - final r = await client.replaceToMany( - 'books', '1', 'authors', [Identifier('people', '1')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); - expect(r1.data.unwrap().toMany['authors'].first.id, '1'); - }); - - test('404 when collection not found', () async { - final r = await client.replaceToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await client.replaceToMany( - 'books', '42', 'publisher', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Adding to a to-many relationship', () { - test('successfully adding a new identifier', () async { - final r = await client.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '3')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 3); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '3'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 3); - }); - - test('successfully adding an existing identifier', () async { - final r = await client.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '2')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); - }); - - test('404 when collection not found', () async { - final r = await client.addToRelationship( - 'unicorns', '1', 'breed', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await client.addToRelationship( - 'books', '42', 'publisher', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Deleting from a to-many relationship', () { - test('successfully deleting an identifier', () async { - final r = await client.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '1')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 1); - expect(r.data.unwrap().first.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); - }); - - test('successfully deleting a non-present identifier', () async { - final r = await client.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '3')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); - }); - - test('404 when collection not found', () async { - final r = await client.deleteFromToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await client.deleteFromToMany( - 'books', '42', 'publisher', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - }, testOn: 'vm'); - - group('Deleting Resources', () { - setUp(() async { - await client.createResource(Resource('apples', '1')); - }); - test('successful', () async { - final r = await client.deleteResource('apples', '1'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await client.fetchResource('apples', '1'); - expect(r1.isSuccessful, isFalse); - expect(r1.statusCode, 404); - }); - - test('404 when the collection does not exist', () async { - final r = await client.deleteResource('unicorns', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when the resource does not exist', () async { - final r = await client.deleteResource('books', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }, testOn: 'vm'); -} diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index c4654c91..f9e1fc39 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -51,109 +51,107 @@ void main() async { client = SimpleClient(design, JsonApiClient(server)); }); - group('Compound document', () { - group('Single Resouces', () { - test('included == null by default', () async { - final r = await client.fetchResource('posts', '1'); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included, isNull); - }); + group('Single Resouces', () { + test('included == null by default', () async { + final r = await client.fetchResource('posts', '1'); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included, isNull); + }); - test('included == [] when requested but nothing to include', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included, []); - }); + test('included == [] when requested but nothing to include', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['tags'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included, []); + }); - test('can include first-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - }); + test('can include first-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + }); - test('can include second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - }); + test('can include second-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments.author'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included.first.unwrap(), bob); + expectResourcesEqual(r.data.included.last.unwrap(), alice); + }); - test('can include third-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - }); + test('can include third-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments.author.birthplace'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 1); + expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + }); - test('can include first- and second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - }); + test('can include first- and second-level relatives', () async { + final r = await client.fetchResource('posts', '1', + parameters: Include(['comments', 'comments.author'])); + expectResourcesEqual(r.data.unwrap(), post); + expect(r.data.included.length, 4); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expectResourcesEqual(r.data.included[2].unwrap(), bob); + expectResourcesEqual(r.data.included[3].unwrap(), alice); }); + }); - group('Resource Collection', () { - test('included == null by default', () async { - final r = await client.fetchCollection('posts'); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included, isNull); - }); + group('Resource Collection', () { + test('included == null by default', () async { + final r = await client.fetchCollection('posts'); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included, isNull); + }); - test('included == [] when requested but nothing to include', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included, []); - }); + test('included == [] when requested but nothing to include', () async { + final r = + await client.fetchCollection('posts', parameters: Include(['tags'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included, []); + }); - test('can include first-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - }); + test('can include first-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + }); - test('can include second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - }); + test('can include second-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments.author'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 2); + expectResourcesEqual(r.data.included.first.unwrap(), bob); + expectResourcesEqual(r.data.included.last.unwrap(), alice); + }); - test('can include third-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - }); + test('can include third-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments.author.birthplace'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 1); + expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + }); - test('can include first- and second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - }); + test('can include first- and second-level relatives', () async { + final r = await client.fetchCollection('posts', + parameters: Include(['comments', 'comments.author'])); + expectResourcesEqual(r.data.unwrap().first, post); + expect(r.data.included.length, 4); + expectResourcesEqual(r.data.included[0].unwrap(), comment1); + expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expectResourcesEqual(r.data.included[2].unwrap(), bob); + expectResourcesEqual(r.data.included[3].unwrap(), alice); }); - }, testOn: 'vm'); + }); } diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart new file mode 100644 index 00000000..c8dc494f --- /dev/null +++ b/test/functional/crud/creating_resources_test.dart @@ -0,0 +1,155 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../../helper/expect_resources_equal.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + group('Server-genrated ID', () { + test('201 Created', () async { + final repository = InMemoryRepository({ + 'people': {}, + }, nextId: Uuid().v4); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + final person = + Resource.toCreate('people', attributes: {'name': 'Martin Fowler'}); + final r = await client.createResource(person); + expect(r.statusCode, 201); + expect(r.location, isNotNull); + expect(r.location, r.data.links['self'].uri); + final created = r.data.unwrap(); + expect(created.type, person.type); + expect(created.id, isNotNull); + expect(created.attributes, equals(person.attributes)); + final r1 = await JsonApiClient(server).fetchResource(r.location); + expect(r1.statusCode, 200); + expectResourcesEqual(r1.data.unwrap(), created); + }); + + test('403 when the id can not be generated', () async { + final repository = InMemoryRepository({'people': {}}); + client = SimpleClient( + design, + JsonApiClient( + JsonApiServer(design, RepositoryController(repository)))); + + final r = await client.createResource(Resource('people', null)); + expect(r.statusCode, 403); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '403'); + expect(error.title, 'Unsupported operation'); + expect(error.detail, 'Id generation is not supported'); + }); + }); + + group('Client-genrated ID', () { + setUp(() async { + final repository = InMemoryRepository({ + 'books': {}, + 'people': {}, + 'companies': {}, + 'noServerId': {}, + 'fruits': {}, + 'apples': {} + }); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + }); + test('204 No Content', () async { + final person = + Resource('people', '123', attributes: {'name': 'Martin Fowler'}); + final r = await client.createResource(person); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.location, isNull); + expect(r.data, isNull); + final r1 = await client.fetchResource(person.type, person.id); + expect(r1.isSuccessful, isTrue); + expect(r1.statusCode, 200); + expectResourcesEqual(r1.data.unwrap(), person); + }); + test('404 when the collection does not exist', () async { + final r = await client.createResource(Resource('unicorns', null)); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when the related resource does not exist (to-one)', () async { + final book = Resource('books', null, + toOne: {'publisher': Identifier('companies', '123')}); + final r = await client.createResource(book); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '123' does not exist in 'companies'"); + }); + + test('404 when the related resource does not exist (to-many)', () async { + final book = Resource('books', null, toMany: { + 'authors': [Identifier('people', '123')] + }); + final r = await client.createResource(book); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '123' does not exist in 'people'"); + }); + + test('409 when the resource type does not match collection', () async { + final r = await JsonApiClient(server).createResource( + design.collectionUri('fruits'), Resource('cucumbers', null)); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Invalid resource type'); + expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); + }); + + test('409 when the resource with this id already exists', () async { + final apple = Resource('apples', '123'); + await client.createResource(apple); + final r = await client.createResource(apple); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Resource exists'); + expect(error.detail, 'Resource with this type and id already exists'); + }); + }); +} diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart new file mode 100644 index 00000000..82431131 --- /dev/null +++ b/test/functional/crud/deleting_resources_test.dart @@ -0,0 +1,62 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +import 'seed_resources.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + final repository = + InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + await seedResources(client); + }); + + test('successful', () async { + final r = await client.deleteResource('books', '1'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.isSuccessful, isFalse); + expect(r1.statusCode, 404); + }); + + test('404 on collecton', () async { + final r = await client.deleteResource('unicorns', '42'); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.deleteResource('books', '42'); + expect(r.isSuccessful, isFalse); + expect(r.isFailed, isTrue); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); +} diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart new file mode 100644 index 00000000..1fafdb96 --- /dev/null +++ b/test/functional/crud/fetching_relationships_test.dart @@ -0,0 +1,162 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +import 'seed_resources.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + final repository = + InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + await seedResources(client); + }); + group('To-one', () { + test('200 OK', () async { + final r = await client.fetchToOne('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().type, 'companies'); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = await client.fetchToOne('unicorns', '1', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchToOne('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchToOne('books', '1', 'owner'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'owner' does not exist in 'books:1'"); + }); + }); + + group('To-many', () { + test('200 OK', () async { + final r = await client.fetchToMany('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.type, 'people'); + }); + + test('404 on collection', () async { + final r = await client.fetchToMany('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchToMany('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchToMany('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); + + group('Generc', () { + test('200 OK to-one', () async { + final r = await client.fetchRelationship('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + final rel = r.data; + if (rel is ToOne) { + expect(rel.unwrap().type, 'companies'); + expect(rel.unwrap().id, '1'); + } else { + fail('Not a ToOne relationship'); + } + }); + + test('200 OK to-many', () async { + final r = await client.fetchRelationship('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + final rel = r.data; + if (rel is ToMany) { + expect(rel.unwrap().length, 2); + expect(rel.unwrap().first.id, '1'); + expect(rel.unwrap().first.type, 'people'); + expect(rel.unwrap().last.id, '2'); + expect(rel.unwrap().last.type, 'people'); + } else { + fail('Not a ToMany relationship'); + } + }); + + test('404 on collection', () async { + final r = await client.fetchRelationship('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelationship('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelationship('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); +} diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart new file mode 100644 index 00000000..3b75021a --- /dev/null +++ b/test/functional/crud/fetching_resources_test.dart @@ -0,0 +1,150 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +import 'seed_resources.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + final repository = + InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + await seedResources(client); + }); + + group('Primary Resource', () { + test('200 OK', () async { + final r = await client.fetchResource('people', '1'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().id, '1'); + expect(r.data.unwrap().attributes['name'], 'Martin Fowler'); + }); + + test('404 on collection', () async { + final r = await client.fetchResource('unicorns', '1'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchResource('people', '42'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'people'"); + }); + }); + + group('Primary collections', () { + test('200 OK', () async { + final r = await client.fetchCollection('people'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 3); + expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); + }); + + test('404', () async { + final r = await client.fetchCollection('unicorns'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + }); + + group('Related Resource', () { + test('200 OK', () async { + final r = await client.fetchRelatedResource('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().type, 'companies'); + expect(r.data.unwrap().id, '1'); + }); + + test('404 on collection', () async { + final r = await client.fetchRelatedResource('unicorns', '1', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelatedResource('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelatedResource('books', '1', 'owner'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'owner' does not exist in 'books:1'"); + }); + }); + + group('Related Collection', () { + test('successful', () async { + final r = await client.fetchRelatedCollection('books', '1', 'authors'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); + }); + + test('404 on collection', () async { + final r = await client.fetchRelatedCollection('unicorns', '1', 'athors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Collection not found'); + expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.fetchRelatedCollection('books', '42', 'authors'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Resource not found'); + expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + }); + + test('404 on relationship', () async { + final r = await client.fetchRelatedCollection('books', '1', 'readers'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.errors.first.status, '404'); + expect(r.errors.first.title, 'Relationship not found'); + expect(r.errors.first.detail, + "Relationship 'readers' does not exist in 'books:1'"); + }); + }); +} diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart new file mode 100644 index 00000000..64549f3d --- /dev/null +++ b/test/functional/crud/seed_resources.dart @@ -0,0 +1,23 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; + +Future seedResources(SimpleClient client) async { + await client.createResource( + Resource('people', '1', attributes: {'name': 'Martin Fowler'})); + await client.createResource( + Resource('people', '2', attributes: {'name': 'Kent Beck'})); + await client.createResource( + Resource('people', '3', attributes: {'name': 'Robert Martin'})); + await client.createResource(Resource('companies', '1', + attributes: {'name': 'Addison-Wesley Professional'})); + await client.createResource( + Resource('companies', '2', attributes: {'name': 'Prentice Hall'})); + await client.createResource(Resource('books', '1', attributes: { + 'title': 'Refactoring', + 'ISBN-10': '0134757599' + }, toOne: { + 'publisher': Identifier('companies', '1') + }, toMany: { + 'authors': [Identifier('people', '1'), Identifier('people', '2')] + })); +} diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart new file mode 100644 index 00000000..ce0e8889 --- /dev/null +++ b/test/functional/crud/updating_relationships_test.dart @@ -0,0 +1,240 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +import 'seed_resources.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + final repository = + InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + await seedResources(client); + }); + + group('Updatng a to-one relationship', () { + test('204 No Content', () async { + final r = await client.replaceToOne( + 'books', '1', 'publisher', Identifier('companies', '2')); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toOne['publisher'].id, '2'); + }); + + test('404 on collection', () async { + final r = await client.replaceToOne( + 'unicorns', '1', 'breed', Identifier('companies', '2')); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.replaceToOne( + 'books', '42', 'publisher', Identifier('companies', '2')); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Deleting a to-one relationship', () { + test('204 No Content', () async { + final r = await client.deleteToOne('books', '1', 'publisher'); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toOne['publisher'], isNull); + }); + + test('404 on collection', () async { + final r = await client.deleteToOne('unicorns', '1', 'breed'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 on resource', () async { + final r = await client.deleteToOne('books', '42', 'publisher'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Replacing a to-many relationship', () { + test('204 No Content', () async { + final r = await client + .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 1); + expect(r1.data.unwrap().toMany['authors'].first.id, '1'); + }); + + test('404 when collection not found', () async { + final r = await client.replaceToMany( + 'unicorns', '1', 'breed', [Identifier('companies', '2')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.replaceToMany( + 'books', '42', 'publisher', [Identifier('companies', '2')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Adding to a to-many relationship', () { + test('successfully adding a new identifier', () async { + final r = await client.addToRelationship( + 'books', '1', 'authors', [Identifier('people', '3')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 3); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '3'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 3); + }); + + test('successfully adding an existing identifier', () async { + final r = await client.addToRelationship( + 'books', '1', 'authors', [Identifier('people', '2')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 2); + }); + + test('404 when collection not found', () async { + final r = await client.addToRelationship( + 'unicorns', '1', 'breed', [Identifier('companies', '3')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.addToRelationship( + 'books', '42', 'publisher', [Identifier('companies', '3')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); + + group('Deleting from a to-many relationship', () { + test('successfully deleting an identifier', () async { + final r = await client.deleteFromToMany( + 'books', '1', 'authors', [Identifier('people', '1')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 1); + expect(r.data.unwrap().first.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 1); + }); + + test('successfully deleting a non-present identifier', () async { + final r = await client.deleteFromToMany( + 'books', '1', 'authors', [Identifier('people', '3')]); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().length, 2); + expect(r.data.unwrap().first.id, '1'); + expect(r.data.unwrap().last.id, '2'); + + final r1 = await client.fetchResource('books', '1'); + expect(r1.data.unwrap().toMany['authors'].length, 2); + }); + + test('404 when collection not found', () async { + final r = await client.deleteFromToMany( + 'unicorns', '1', 'breed', [Identifier('companies', '1')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Collection not found'); + expect(error.detail, "Collection 'unicorns' does not exist"); + }); + + test('404 when resource not found', () async { + final r = await client.deleteFromToMany( + 'books', '42', 'publisher', [Identifier('companies', '1')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + }); +} diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart new file mode 100644 index 00000000..0943b2d8 --- /dev/null +++ b/test/functional/crud/updating_resources_test.dart @@ -0,0 +1,85 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/in_memory_repository.dart'; +import 'package:json_api/src/server/json_api_server.dart'; +import 'package:json_api/src/server/repository_controller.dart'; +import 'package:json_api/uri_design.dart'; +import 'package:test/test.dart'; + +import '../../helper/expect_resources_equal.dart'; +import 'seed_resources.dart'; + +void main() async { + SimpleClient client; + JsonApiServer server; + final host = 'localhost'; + final port = 80; + final base = Uri(scheme: 'http', host: host, port: port); + final design = UriDesign.standard(base); + + setUp(() async { + final repository = + InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); + server = JsonApiServer(design, RepositoryController(repository)); + client = SimpleClient(design, JsonApiClient(server)); + + await seedResources(client); + }); + + test('200 OK', () async { + final r = await client.updateResource(Resource('books', '1', attributes: { + 'title': 'Refactoring. Improving the Design of Existing Code', + 'pages': 448 + }, toOne: { + 'publisher': null + }, toMany: { + 'authors': [Identifier('people', '1')], + 'reviewers': [Identifier('people', '2')] + })); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 200); + expect(r.data.unwrap().attributes['title'], + 'Refactoring. Improving the Design of Existing Code'); + expect(r.data.unwrap().attributes['pages'], 448); + expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); + expect(r.data.unwrap().toOne['publisher'], isNull); + expect( + r.data.unwrap().toMany['authors'], equals([Identifier('people', '1')])); + expect(r.data.unwrap().toMany['reviewers'], + equals([Identifier('people', '2')])); + + final r1 = await client.fetchResource('books', '1'); + expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); + }); + + test('204 No Content', () async { + final r = await client.updateResource(Resource('books', '1')); + expect(r.isSuccessful, isTrue); + expect(r.statusCode, 204); + expect(r.data, isNull); + }); + + test('404 on the target resource', () async { + final r = await client.updateResource(Resource('books', '42'), id: '42'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Resource not found'); + expect(error.detail, "Resource '42' does not exist in 'books'"); + }); + + test('409 when the resource type does not match the collection', () async { + final r = await client.updateResource(Resource('books', '1'), + collection: 'people'); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 409); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '409'); + expect(error.title, 'Invalid resource type'); + expect(error.detail, "Type 'books' does not belong in 'people'"); + }); +} diff --git a/tmp/pagination_test.dart b/tmp/pagination_test.dart deleted file mode 100644 index b6bb8dff..00000000 --- a/tmp/pagination_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/client.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../../example/server/controller/paginating_controller.dart'; -import '../../../example/server/shelf_request_response_converter.dart'; - -/// Pagination -void main() async { - HttpServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 8082; - final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); - - setUp(() async { - client = JsonApiClient(); - final pagination = Pagination.fixedSize(5); - final handler = RequestHandler(ShelfRequestResponseConverter(), - PaginatingController(pagination), design, - pagination: pagination); - - server = await serve(handler, host, port); - }); - - tearDown(() async { - client.close(); - await server.close(); - }); - - group('Paginating', () { - test('a primary collection', () async { - final r0 = - await client.fetchCollection(base.replace(pathSegments: ['colors'])); - expect(r0.data.unwrap().length, 5); - expect(r0.data.unwrap().first.attributes['name'], 'black'); - expect(r0.data.unwrap().last.attributes['name'], 'maroon'); - - final r1 = await client.fetchCollection(r0.data.next.uri); - expect(r1.data.unwrap().length, 5); - expect(r1.data.unwrap().first.attributes['name'], 'red'); - expect(r1.data.unwrap().last.attributes['name'], 'lime'); - - final r2 = await client.fetchCollection(r0.data.last.uri); - expect(r2.data.unwrap().length, 1); - expect(r2.data.unwrap().first.attributes['name'], 'aqua'); - - final r3 = await client.fetchCollection(r2.data.prev.uri); - expect(r3.data.unwrap().length, 5); - expect(r3.data.unwrap().first.attributes['name'], 'olive'); - expect(r3.data.unwrap().last.attributes['name'], 'teal'); - }); - }, testOn: 'vm'); -} diff --git a/tmp/sorting_test.dart b/tmp/sorting_test.dart deleted file mode 100644 index 9886f7d4..00000000 --- a/tmp/sorting_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/client.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/uri_design.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:test/test.dart'; - -import '../../../example/server/controller/sorting_controller.dart'; -import '../../../example/server/shelf_request_response_converter.dart'; - -/// Sorting -void main() async { - HttpServer server; - UriAwareClient client; - final host = 'localhost'; - final port = 8083; - final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); - - setUp(() async { - client = UriAwareClient(design); - final handler = RequestHandler( - ShelfRequestResponseConverter(), SortingController(), design); - - server = await serve(handler, host, port); - }); - - tearDown(() async { - client.close(); - await server.close(); - }); - - group('Sorting a collection', () { - test('unsorted', () async { - final r = await client.fetchCollection('names'); - expect(r.data.unwrap().length, 16); - expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); - expect(r.data.unwrap().first.attributes['lastName'], 'Smith'); - expect(r.data.unwrap().last.attributes['firstName'], 'Noah'); - expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); - }); - - test('sort by firstName ASC', () async { - final r = await client.fetchCollection('names', - parameters: Sort([Asc('firstName')])); - expect(r.data.unwrap().length, 16); - expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); - expect(r.data.unwrap().first.attributes['lastName'], 'Smith'); - expect(r.data.unwrap().last.attributes['firstName'], 'Olivia'); - expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); - }); - - test('sort by lastName DESC', () async { - final r = await client.fetchCollection('names', - parameters: Sort([Desc('lastName')])); - expect(r.data.unwrap().length, 16); - expect(r.data.unwrap().first.attributes['firstName'], 'Emma'); - expect(r.data.unwrap().first.attributes['lastName'], 'Williams'); - expect(r.data.unwrap().last.attributes['firstName'], 'Noah'); - expect(r.data.unwrap().last.attributes['lastName'], 'Brown'); - }); - - test('sort by fistName DESC, lastName ASC', () async { - final r = await client.fetchCollection('names', - parameters: Sort([Desc('firstName'), Asc('lastName')])); - expect(r.data.unwrap().length, 16); - expect(r.data.unwrap()[0].attributes['firstName'], 'Olivia'); - expect(r.data.unwrap()[0].attributes['lastName'], 'Brown'); - expect(r.data.unwrap()[1].attributes['firstName'], 'Olivia'); - expect(r.data.unwrap()[1].attributes['lastName'], 'Johnson'); - expect(r.data.unwrap()[2].attributes['firstName'], 'Olivia'); - expect(r.data.unwrap()[2].attributes['lastName'], 'Smith'); - expect(r.data.unwrap()[3].attributes['firstName'], 'Olivia'); - expect(r.data.unwrap()[3].attributes['lastName'], 'Williams'); - - expect(r.data.unwrap().last.attributes['firstName'], 'Emma'); - expect(r.data.unwrap().last.attributes['lastName'], 'Williams'); - }); - }, testOn: 'vm'); -} From cd4cae33068a9d4e7ca980feb4c4da35b9575dea Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 2 Feb 2020 22:46:07 -0800 Subject: [PATCH 19/99] wip --- README.md | 54 ++++- example/client.dart | 9 +- example/server.dart | 5 +- lib/client.dart | 1 - lib/src/client/json_api_client.dart | 208 +++++++++++++++--- lib/src/client/simple_client.dart | 172 --------------- lib/src/server/in_memory_repository.dart | 12 +- lib/src/server/repository_controller.dart | 74 +++---- test/e2e/client_server_interaction_test.dart | 4 +- test/functional/compound_document_test.dart | 4 +- .../crud/creating_resources_test.dart | 17 +- .../crud/deleting_resources_test.dart | 4 +- .../crud/fetching_relationships_test.dart | 4 +- .../crud/fetching_resources_test.dart | 4 +- test/functional/crud/seed_resources.dart | 2 +- .../crud/updating_relationships_test.dart | 4 +- .../crud/updating_resources_test.dart | 10 +- test/unit/server/json_api_server_test.dart | 8 +- 18 files changed, 300 insertions(+), 296 deletions(-) delete mode 100644 lib/src/client/simple_client.dart diff --git a/README.md b/README.md index aae8c0ec..98ef3618 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,44 @@ -[JSON:API](http://jsonapi.org) is a specification for building APIs in JSON. +# JSON:API for Dart/Flutter + +[JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: -- The [Client] to make requests to JSON:API servers -- The [Server] which is still under development -- The [Document] model for resources, relationships, identifiers, etc -- The [Query] to build and parse the query parameters (pagination, sorting, etc) -- The [URL Design] to build and match URLs for resources, collections, and relationships - -[Client]: https://pub.dev/documentation/json_api/latest/client/client-library.html -[Server]: https://pub.dev/documentation/json_api/latest/server/server-library.html -[Document]: https://pub.dev/documentation/json_api/latest/document/document-library.html -[Query]: https://pub.dev/documentation/json_api/latest/query/query-library.html -[URL Design]: https://pub.dev/documentation/json_api/latest/url_design/url_design-library.html \ No newline at end of file +- The [Client library] to make requests to JSON:API servers +- The [Server library] which is still under development +- The [Document library] model for resources, relationships, identifiers, etc +- The [Query library] to build and parse the query parameters (pagination, sorting, etc) +- The [URI Design library] to build and match URIs for resources, collections, and relationships +- The [HTTP library] to interact with Dart's native HTTP client and server + + +## Document model +This part assumes that you have a basic understanding of the JSON:API standard. If not, please read the [JSON:API] spec. +The main concept of JSON:API model is the [Resource]. Resources are passed between the client and the server in the +form of a [Document]. A resource has its `type`, `id`, and a map of `attributes`. Resources refer to other resources +with the [Identifier] objects which contain a `type` and `id` of the resource being referred. +Relationship between resources may be either `toOne` (maps to a single identifier) +or `toMany` (maps to a list of identifiers). + +## Client +[JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: +- fetching resources and collections (both primary and related) +- creating resources +- deleting resources +- updating resource attributes and relationships +- direct modification of relationships (both to-one and to-many) +- [async processing](https://jsonapi.org/recommendations/#asynchronous-processing) + + + +[JSON:API]: http://jsonapi.org +[Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html +[Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html +[Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html +[Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html +[URI Design library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html +[HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html + +[Resource]: https://pub.dev/documentation/json_api/latest/document/Resource-class.html +[Identifier]: https://pub.dev/documentation/json_api/latest/document/Identifier-class.html +[Document]: https://pub.dev/documentation/json_api/latest/document/Document-class.html +[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html \ No newline at end of file diff --git a/example/client.dart b/example/client.dart index 2e41f8fc..afaf2ddc 100644 --- a/example/client.dart +++ b/example/client.dart @@ -18,14 +18,11 @@ void main() async { /// We'll use a logging handler to how the requests and responses final httpHandler = LoggingHttpHandler(DartHttp(httpClient), - onRequest: print, onResponse: print); + onRequest: (r) => print('${r.method} ${r.uri}'), + onResponse: (r) => print('${r.statusCode}')); /// The JSON:API client - final jsonApiClient = JsonApiClient(httpHandler); - - /// We will use a wrapper over the JSON:API client to reduce boilerplate code. - /// This wrapper makes use of the URI design to build query URIs. - final client = SimpleClient(uriDesign, jsonApiClient); + final client = JsonApiClient(httpHandler, uriFactory: uriDesign); /// Create the first resource await client.createResource( diff --git a/example/server.dart b/example/server.dart index 23eef1fe..9afa7631 100644 --- a/example/server.dart +++ b/example/server.dart @@ -30,8 +30,9 @@ void main() async { final jsonApiServer = JsonApiServer(uriDesign, controller); /// We will be logging the requests and responses to the console - final loggingJsonApiServer = - LoggingHttpHandler(jsonApiServer, onRequest: print, onResponse: print); + final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, + onRequest: (r) => print('${r.method} ${r.uri}'), + onResponse: (r) => print('${r.statusCode}')); /// The handler for the built-in HTTP server final serverHandler = DartServerHandler(loggingJsonApiServer); diff --git a/lib/client.dart b/lib/client.dart index 85a81858..545b8776 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -3,5 +3,4 @@ library client; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_response.dart'; export 'package:json_api/src/client/request_document_factory.dart'; -export 'package:json_api/src/client/simple_client.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 4091c5c6..11c831ba 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -7,102 +7,199 @@ import 'package:json_api/query.dart'; import 'package:json_api/src/client/json_api_response.dart'; import 'package:json_api/src/client/request_document_factory.dart'; import 'package:json_api/src/client/status_code.dart'; +import 'package:json_api/uri_design.dart'; /// The JSON:API Client. class JsonApiClient { - /// Fetches a resource collection by sending a GET query to the [uri]. + /// Fetches a resource collection at the [uri]. /// Use [headers] to pass extra HTTP headers. /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollection(Uri uri, + Future> fetchCollectionAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); - /// Fetches a single resource + /// Fetches a primary resource collection. Guesses the URI by [type]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchCollection(String type, + {Map headers, QueryParameters parameters}) => + fetchCollectionAt(_collection(type), + headers: headers, parameters: parameters); + + /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchRelatedCollection( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + fetchCollectionAt(_related(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a single resource at the [uri]. /// Use [headers] to pass extra HTTP headers. /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResource(Uri uri, + Future> fetchResourceAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceData.fromJson); + /// Fetches a primary resource. Guesses the URI by [type] and [id]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchResource(String type, String id, + {Map headers, QueryParameters parameters}) => + fetchResourceAt(_resource(type, id), + headers: headers, parameters: parameters); + + /// Fetches a related resource. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchRelatedResource( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + fetchResourceAt(_related(type, id, relationship), + headers: headers, parameters: parameters); + /// Fetches a to-one relationship /// Use [headers] to pass extra HTTP headers. /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOne(Uri uri, + Future> fetchToOneAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToOne.fromJson); + /// Same as [fetchToOneAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchToOne( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + fetchToOneAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + /// Fetches a to-many relationship /// Use [headers] to pass extra HTTP headers. /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToMany(Uri uri, + Future> fetchToManyAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToMany.fromJson); + /// Same as [fetchToManyAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchToMany( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + fetchToManyAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + /// 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. /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationship(Uri uri, + Future> fetchRelationshipAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), Relationship.fromJson); + /// Same as [fetchRelationshipAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> fetchRelationship( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + fetchRelationshipAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + /// Creates a new resource. The resource will be added to a collection /// according to its type. /// /// https://jsonapi.org/format/#crud-creating - Future> createResource( + Future> createResourceAt( Uri uri, Resource resource, {Map headers}) => - _call(_post(uri, headers, _factory.resourceDocument(resource)), + _call(_post(uri, headers, _doc.resourceDocument(resource)), ResourceData.fromJson); + /// Same as [createResourceAt]. Guesses the URI by [resource].type. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> createResource(Resource resource, + {Map headers}) => + createResourceAt(_collection(resource.type), resource, headers: headers); + /// Deletes the resource. /// /// https://jsonapi.org/format/#crud-deleting - Future deleteResource(Uri uri, + Future deleteResourceAt(Uri uri, {Map headers}) => _call(_delete(uri, headers), null); + /// Same as [deleteResourceAt]. Guesses the URI by [type] and [id]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future deleteResource(String type, String id, + {Map headers}) => + deleteResourceAt(_resource(type, id), headers: headers); + /// Updates the resource via PATCH query. /// /// https://jsonapi.org/format/#crud-updating - Future> updateResource( + Future> updateResourceAt( Uri uri, Resource resource, {Map headers}) => - _call(_patch(uri, headers, _factory.resourceDocument(resource)), + _call(_patch(uri, headers, _doc.resourceDocument(resource)), ResourceData.fromJson); + /// Same as [updateResourceAt]. Guesses the URI by [resource] type an id. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> updateResource(Resource resource, + {Map headers}) => + updateResourceAt(_resource(resource.type, resource.id), resource, + headers: headers); + /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOne(Uri uri, Identifier identifier, + Future> replaceToOneAt(Uri uri, Identifier identifier, {Map headers}) => - _call(_patch(uri, headers, _factory.toOneDocument(identifier)), - ToOne.fromJson); + _call( + _patch(uri, headers, _doc.toOneDocument(identifier)), ToOne.fromJson); - /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] + /// Same as [replaceToOneAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> replaceToOne( + String type, String id, String relationship, Identifier identifier, + {Map headers}) => + replaceToOneAt(_relationship(type, id, relationship), identifier, + headers: headers); + + /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt] /// with id = null. - Future> deleteToOne(Uri uri, + Future> deleteToOneAt(Uri uri, {Map headers}) => - replaceToOne(uri, null, headers: headers); + replaceToOneAt(uri, null, headers: headers); + + /// Same as [deleteToOneAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> deleteToOne( + String type, String id, String relationship, + {Map headers}) => + deleteToOneAt(_relationship(type, id, relationship), headers: headers); /// Removes the [identifiers] from the to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToMany( + Future> deleteFromToManyAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_deleteWithBody(uri, headers, _factory.toManyDocument(identifiers)), + _call(_deleteWithBody(uri, headers, _doc.toManyDocument(identifiers)), ToMany.fromJson); + /// Same as [deleteFromToManyAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> deleteFromToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + deleteFromToManyAt(_relationship(type, id, relationship), identifiers, + headers: headers); + /// Replaces a to-many relationship with the given set of [identifiers]. /// /// The server MUST either completely replace every member of the relationship, @@ -110,31 +207,51 @@ class JsonApiClient { /// or return a 403 Forbidden response if complete replacement is not allowed by the server. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToMany( + Future> replaceToManyAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_patch(uri, headers, _factory.toManyDocument(identifiers)), + _call(_patch(uri, headers, _doc.toManyDocument(identifiers)), ToMany.fromJson); + /// Same as [replaceToManyAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> replaceToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + replaceToManyAt(_relationship(type, id, relationship), identifiers, + headers: headers); + /// Adds the given set of [identifiers] to a to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationship( + Future> addToRelationshipAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_post(uri, headers, _factory.toManyDocument(identifiers)), + _call(_post(uri, headers, _doc.toManyDocument(identifiers)), ToMany.fromJson); + /// Same as [addToRelationshipAt]. Guesses the URI by [type], [id], [relationship]. + /// This method requires an instance of [UriFactory] to be specified when creating this class. + Future> addToRelationship(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + addToRelationshipAt(_relationship(type, id, relationship), identifiers, + headers: headers); + /// Creates an instance of JSON:API client. /// Pass an instance of DartHttpClient (comes with this package) or /// another instance of [HttpHandler]. + /// Provide the [uriFactory] to use URI guessing methods. /// Use a custom [documentFactory] if you want to build the outgoing /// documents in a special way. - JsonApiClient(this._http, {RequestDocumentFactory documentFactory}) - : _factory = documentFactory ?? RequestDocumentFactory(); + JsonApiClient(this._http, + {RequestDocumentFactory documentFactory, UriFactory uriFactory}) + : _doc = documentFactory ?? RequestDocumentFactory(), + _uri = uriFactory ?? const _NullUriFactory(); final HttpHandler _http; - final RequestDocumentFactory _factory; + final RequestDocumentFactory _doc; + final UriFactory _uri; HttpRequest _get(Uri uri, Map headers, QueryParameters queryParameters) => @@ -196,4 +313,41 @@ class JsonApiClient { ? null : Document.fromJson(document, decodePrimaryData)); } + + Uri _collection(String type) => _uri.collectionUri(type); + + Uri _relationship(String type, String id, String relationship) => + _uri.relationshipUri(type, id, relationship); + + Uri _resource(String type, String id) => _uri.resourceUri(type, id); + + Uri _related(String type, String id, String relationship) => + _uri.relatedUri(type, id, relationship); +} + +final _error = + StateError('Provide an instance of UriFactory to use URI guesing'); + +class _NullUriFactory implements UriFactory { + const _NullUriFactory(); + + @override + Uri collectionUri(String type) { + throw _error; + } + + @override + Uri relatedUri(String type, String id, String relationship) { + throw _error; + } + + @override + Uri relationshipUri(String type, String id, String relationship) { + throw _error; + } + + @override + Uri resourceUri(String type, String id) { + throw _error; + } } diff --git a/lib/src/client/simple_client.dart b/lib/src/client/simple_client.dart deleted file mode 100644 index cf29eb1c..00000000 --- a/lib/src/client/simple_client.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/uri_design.dart'; - -/// A wrapper over [JsonApiClient] making use of the given UrlFactory. -/// This wrapper reduces the boilerplate code but is not as flexible -/// as [JsonApiClient]. -class SimpleClient { - /// Creates a new resource. - /// - /// If [collection] is specified, the resource will be added to that collection, - /// otherwise its type will be used to reference the target collection. - /// - /// https://jsonapi.org/format/#crud-creating - Future> createResource(Resource resource, - {String collection, Map headers}) => - _client.createResource( - _uriFactory.collectionUri(collection ?? resource.type), resource, - headers: headers); - - /// Fetches a single resource - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResource(String type, String id, - {Map headers, QueryParameters parameters}) => - _client.fetchResource(_uriFactory.resourceUri(type, id), - headers: headers, parameters: parameters); - - /// Fetches a resource collection . - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollection(String type, - {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_uriFactory.collectionUri(type), - headers: headers, parameters: parameters); - - /// Fetches a related resource. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelatedResource( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchResource(_uriFactory.relatedUri(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a related resource collection. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchCollection(_uriFactory.relatedUri(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a to-one relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOne( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToOne(_uriFactory.relationshipUri(type, id, relationship), - headers: headers, parameters: parameters); - - /// 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. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchRelationship( - _uriFactory.relationshipUri(type, id, relationship), - headers: headers, - parameters: parameters); - - /// Fetches a to-many relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToMany( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToMany(_uriFactory.relationshipUri(type, id, relationship), - headers: headers, parameters: parameters); - - /// Deletes the resource referenced by [type] and [id]. - /// - /// https://jsonapi.org/format/#crud-deleting - Future deleteResource(String type, String id, - {Map headers}) => - _client.deleteResource(_uriFactory.resourceUri(type, id), - headers: headers); - - /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] - /// with id = null. - Future> deleteToOne( - String type, String id, String relationship, - {Map headers}) => - _client.deleteToOne(_uriFactory.relationshipUri(type, id, relationship), - headers: headers); - - /// Removes the [identifiers] from the to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.deleteFromToMany( - _uriFactory.relationshipUri(type, id, relationship), identifiers, - headers: headers); - - /// Updates the [resource]. If [collection] and/or [id] is specified, they - /// will be used to refer the existing resource to be replaced. - /// - /// https://jsonapi.org/format/#crud-updating - Future> updateResource(Resource resource, - {Map headers, String collection, String id}) => - _client.updateResource( - _uriFactory.resourceUri( - collection ?? resource.type, id ?? resource.id), - resource, - headers: headers); - - /// Adds the given set of [identifiers] to a to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.addToRelationship( - _uriFactory.relationshipUri(type, id, relationship), identifiers, - headers: headers); - - /// Replaces a to-many relationship with the given set of [identifiers]. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.replaceToMany( - _uriFactory.relationshipUri(type, id, relationship), identifiers, - headers: headers); - - /// Updates a to-one relationship via PATCH query - /// - /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOne( - String type, String id, String relationship, Identifier identifier, - {Map headers}) => - _client.replaceToOne( - _uriFactory.relationshipUri(type, id, relationship), identifier, - headers: headers); - - SimpleClient(this._uriFactory, this._client); - - final JsonApiClient _client; - final UriFactory _uriFactory; -} diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index d9e39102..436b2f81 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -6,19 +6,17 @@ import 'package:json_api/src/server/repository.dart'; typedef IdGenerator = String Function(); typedef TypeAttributionCriteria = bool Function(String collection, String type); -final _typeEqualsCollection = ((t, s) => t == s); - +/// An in-memory implementation of [Repository] class InMemoryRepository implements Repository { final Map> _collections; final IdGenerator _nextId; - final TypeAttributionCriteria _typeBelongs; @override FutureOr create(String collection, Resource resource) async { if (!_collections.containsKey(collection)) { throw CollectionNotFound("Collection '$collection' does not exist"); } - if (!_typeBelongs(collection, resource.type)) { + if (collection != resource.type) { throw _invalidType(resource, collection); } for (final relationship in resource.toOne.values @@ -99,8 +97,6 @@ class InMemoryRepository implements Repository { "Type '${resource.type}' does not belong in '$collection'"); } - InMemoryRepository(this._collections, - {TypeAttributionCriteria typeBelongs, IdGenerator nextId}) - : _typeBelongs = typeBelongs ?? _typeEqualsCollection, - _nextId = nextId; + InMemoryRepository(this._collections, {IdGenerator nextId}) + : _nextId = nextId; } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 4989fe76..5fc818c3 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -12,10 +12,6 @@ typedef UriReader = FutureOr Function(R request); /// An opinionated implementation of [JsonApiController] class RepositoryController implements JsonApiController { - final Repository _repo; - - RepositoryController(this._repo); - @override FutureOr addToRelationship(HttpRequest request, RelationshipTarget target, Iterable identifiers) => @@ -136,6 +132,43 @@ class RepositoryController implements JsonApiController { included: include.isEmpty ? null : resources); }); + @override + FutureOr replaceToMany(HttpRequest request, + RelationshipTarget target, Iterable identifiers) => + _do(() async { + await _repo.update( + target.type, + target.id, + Resource(target.type, target.id, + toMany: {target.relationship: identifiers})); + return JsonApiResponse.noContent(); + }); + + @override + FutureOr updateResource( + HttpRequest request, ResourceTarget target, Resource resource) => + _do(() async { + final modified = await _repo.update(target.type, target.id, resource); + if (modified == null) return JsonApiResponse.noContent(); + return JsonApiResponse.resource(modified); + }); + + @override + FutureOr replaceToOne(HttpRequest request, + RelationshipTarget target, Identifier identifier) => + _do(() async { + await _repo.update( + target.type, + target.id, + Resource(target.type, target.id, + toOne: {target.relationship: identifier})); + return JsonApiResponse.noContent(); + }); + + RepositoryController(this._repo); + + final Repository _repo; + FutureOr _getByIdentifier(Identifier identifier) => _repo.get(identifier.type, identifier.id); @@ -163,39 +196,6 @@ class RepositoryController implements JsonApiController { return resources; } - @override - FutureOr replaceToMany(HttpRequest request, - RelationshipTarget target, Iterable identifiers) => - _do(() async { - await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, - toMany: {target.relationship: identifiers})); - return JsonApiResponse.noContent(); - }); - - @override - FutureOr replaceToOne(HttpRequest request, - RelationshipTarget target, Identifier identifier) => - _do(() async { - await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, - toOne: {target.relationship: identifier})); - return JsonApiResponse.noContent(); - }); - - @override - FutureOr updateResource( - HttpRequest request, ResourceTarget target, Resource resource) => - _do(() async { - final modified = await _repo.update(target.type, target.id, resource); - if (modified == null) return JsonApiResponse.noContent(); - return JsonApiResponse.resource(modified); - }); - FutureOr _do( FutureOr Function() action) async { try { diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index cce25cac..0add6ce1 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -20,13 +20,13 @@ void main() { final jsonApiServer = JsonApiServer(design, RepositoryController(repo)); final serverHandler = DartServerHandler(jsonApiServer); Client httpClient; - SimpleClient client; + JsonApiClient client; HttpServer server; setUp(() async { server = await HttpServer.bind(host, port); httpClient = Client(); - client = SimpleClient(design, JsonApiClient(DartHttp(httpClient))); + client = JsonApiClient(DartHttp(httpClient), uriFactory: design); unawaited(server.forEach(serverHandler)); }); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index f9e1fc39..10dfbf58 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -10,7 +10,7 @@ import 'package:test/test.dart'; import '../helper/expect_resources_equal.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -48,7 +48,7 @@ void main() async { 'tags': {} }); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); }); group('Single Resouces', () { diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index c8dc494f..2ed0a38a 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -11,7 +11,7 @@ import 'package:uuid/uuid.dart'; import '../../helper/expect_resources_equal.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -24,7 +24,7 @@ void main() async { 'people': {}, }, nextId: Uuid().v4); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); final person = Resource.toCreate('people', attributes: {'name': 'Martin Fowler'}); @@ -36,17 +36,16 @@ void main() async { expect(created.type, person.type); expect(created.id, isNotNull); expect(created.attributes, equals(person.attributes)); - final r1 = await JsonApiClient(server).fetchResource(r.location); + final r1 = await JsonApiClient(server).fetchResourceAt(r.location); expect(r1.statusCode, 200); expectResourcesEqual(r1.data.unwrap(), created); }); test('403 when the id can not be generated', () async { final repository = InMemoryRepository({'people': {}}); - client = SimpleClient( - design, - JsonApiClient( - JsonApiServer(design, RepositoryController(repository)))); + client = JsonApiClient( + JsonApiServer(design, RepositoryController(repository)), + uriFactory: design); final r = await client.createResource(Resource('people', null)); expect(r.statusCode, 403); @@ -69,7 +68,7 @@ void main() async { 'apples': {} }); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); }); test('204 No Content', () async { final person = @@ -126,7 +125,7 @@ void main() async { }); test('409 when the resource type does not match collection', () async { - final r = await JsonApiClient(server).createResource( + final r = await JsonApiClient(server).createResourceAt( design.collectionUri('fruits'), Resource('cucumbers', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 82431131..44f6ae31 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -9,7 +9,7 @@ import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -20,7 +20,7 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); await seedResources(client); }); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 1fafdb96..a5ddd4e9 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -10,7 +10,7 @@ import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -21,7 +21,7 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); await seedResources(client); }); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 3b75021a..82e0ec13 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -9,7 +9,7 @@ import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -20,7 +20,7 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); await seedResources(client); }); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index 64549f3d..e65f6a45 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -Future seedResources(SimpleClient client) async { +Future seedResources(JsonApiClient client) async { await client.createResource( Resource('people', '1', attributes: {'name': 'Martin Fowler'})); await client.createResource( diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index ce0e8889..ca0870dd 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -10,7 +10,7 @@ import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -21,7 +21,7 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); await seedResources(client); }); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 0943b2d8..738a7cc7 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -11,7 +11,7 @@ import '../../helper/expect_resources_equal.dart'; import 'seed_resources.dart'; void main() async { - SimpleClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -22,7 +22,7 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(design, RepositoryController(repository)); - client = SimpleClient(design, JsonApiClient(server)); + client = JsonApiClient(server, uriFactory: design); await seedResources(client); }); @@ -61,7 +61,7 @@ void main() async { }); test('404 on the target resource', () async { - final r = await client.updateResource(Resource('books', '42'), id: '42'); + final r = await client.updateResource(Resource('books', '42')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -72,8 +72,8 @@ void main() async { }); test('409 when the resource type does not match the collection', () async { - final r = await client.updateResource(Resource('books', '1'), - collection: 'people'); + final r = await client.updateResourceAt( + design.resourceUri('people', '1'), Resource('books', '1')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 409); expect(r.data, isNull); diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index 075a6a90..ef26ffda 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -7,7 +7,7 @@ import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; void main() { - final url = UriDesign.standard(Uri.parse('http://exapmle.com')); + final url = UriDesign.standard(Uri.parse('http://example.com')); final server = JsonApiServer(url, RepositoryController(InMemoryRepository({}))); @@ -80,7 +80,7 @@ void main() { expect(error.detail, 'Allowed methods: GET, POST'); }); - test('returns `method not allowed` for resource ', () async { + test('returns `method not allowed` for resource', () async { final rq = HttpRequest('POST', url.resourceUri('books', '1')); final rs = await server(rq); expect(rs.statusCode, 405); @@ -91,7 +91,7 @@ void main() { expect(error.detail, 'Allowed methods: DELETE, GET, PATCH'); }); - test('returns `method not allowed` for related ', () async { + test('returns `method not allowed` for related', () async { final rq = HttpRequest('POST', url.relatedUri('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); @@ -102,7 +102,7 @@ void main() { expect(error.detail, 'Allowed methods: GET'); }); - test('returns `method not allowed` for relationship ', () async { + test('returns `method not allowed` for relationship', () async { final rq = HttpRequest('PUT', url.relationshipUri('books', '1', 'author')); final rs = await server(rq); From 9b5153190c30ed124076b7868a953b70b988dd61 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 3 Feb 2020 00:49:29 -0800 Subject: [PATCH 20/99] wip --- example/README.md | 30 ++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 example/README.md diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..9205e925 --- /dev/null +++ b/example/README.md @@ -0,0 +1,30 @@ +# Client-server interaction example +Run the server: +``` +$ dart example/server.dart +Listening on http://localhost:8080 + +``` +This will start a simple JSON:API server at localhost:8080. It supports 2 resource types: +- [writers](http://localhost:8080/writers) +- [books](http://localhost:8080/books) + +Try opening these links in your browser, you should see empty collections. + +While the server is running, try the client script: +``` +$ dart example/client.dart +POST http://localhost:8080/writers +204 +POST http://localhost:8080/books +204 +GET http://localhost:8080/books/2?include=authors +200 +Book: Resource(books:2 {title: Refactoring}) +Author: Resource(writers:1 {name: Martin Fowler}) +``` +This will create resources in those collections. Try the the following links: + +- [writer](http://localhost:8080/writers/1) +- [book](http://localhost:8080/books/2) +- [book and its author](http://localhost:8080/books/2?include=authors) \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 565ac13e..117726a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-dev.2 +version: 4.0.0-dev.4 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: From 422fc95513195e2b209adc186c537b4f5defc6a2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 8 Feb 2020 18:29:56 -0800 Subject: [PATCH 21/99] wip --- lib/src/client/json_api_client.dart | 16 ++-- lib/src/document/primary_data.dart | 4 - lib/src/document/resource.dart | 19 ----- lib/src/http/http_request.dart | 5 +- lib/src/http/http_response.dart | 5 +- lib/src/http/normalize.dart | 3 - lib/src/server/in_memory_repository.dart | 5 +- lib/src/server/json_api_response.dart | 6 +- lib/src/server/json_api_server.dart | 83 +++++++++++++++++++ lib/src/server/repository_controller.dart | 21 +++-- lib/src/server/response_document_factory.dart | 16 ++-- lib/uri_design.dart | 16 ++-- test/e2e/client_server_interaction_test.dart | 20 +++-- .../crud/creating_resources_test.dart | 2 +- .../crud/fetching_relationships_test.dart | 6 +- .../crud/fetching_resources_test.dart | 4 +- .../crud/updating_relationships_test.dart | 14 ++++ .../crud/updating_resources_test.dart | 2 +- test/unit/document/resource_test.dart | 10 --- test/unit/server/json_api_server_test.dart | 16 ++-- ya.dart | 7 ++ 21 files changed, 180 insertions(+), 100 deletions(-) delete mode 100644 lib/src/http/normalize.dart create mode 100644 ya.dart diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 11c831ba..0bf36e39 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -314,15 +314,15 @@ class JsonApiClient { : Document.fromJson(document, decodePrimaryData)); } - Uri _collection(String type) => _uri.collectionUri(type); + Uri _collection(String type) => _uri.collection(type); Uri _relationship(String type, String id, String relationship) => - _uri.relationshipUri(type, id, relationship); + _uri.relationship(type, id, relationship); - Uri _resource(String type, String id) => _uri.resourceUri(type, id); + Uri _resource(String type, String id) => _uri.resource(type, id); Uri _related(String type, String id, String relationship) => - _uri.relatedUri(type, id, relationship); + _uri.related(type, id, relationship); } final _error = @@ -332,22 +332,22 @@ class _NullUriFactory implements UriFactory { const _NullUriFactory(); @override - Uri collectionUri(String type) { + Uri collection(String type) { throw _error; } @override - Uri relatedUri(String type, String id, String relationship) { + Uri related(String type, String id, String relationship) { throw _error; } @override - Uri relationshipUri(String type, String id, String relationship) { + Uri relationship(String type, String id, String relationship) { throw _error; } @override - Uri resourceUri(String type, String id) { + Uri resource(String type, String id) { throw _error; } } diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 6ca4da90..4504a0da 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -22,10 +22,6 @@ abstract class PrimaryData { /// The `self` link. May be null. Link get self => (links ?? {})['self']; - /// Documents with included resources are called compound - /// Details: http://jsonapi.org/format/#document-compound-documents - bool get isCompound => included != null; - /// Top-level JSON object Map toJson() => { if (links != null) ...{'links': links}, diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 3d2deffa..8c86d490 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -27,28 +27,9 @@ class Resource { /// Resource type and id combined String get key => '$type:$id'; - Identifier toIdentifier() { - if (id == null) { - throw StateError('Can not create an Identifier with id==null'); - } - return Identifier(type, id); - } - @override String toString() => 'Resource($key $attributes)'; - /// Creates a new instance of the resource with replaced properties - Resource replace( - {String type, - String id, - Map attributes, - Map toOne, - Map> toMany}) => - Resource(type ?? this.type, id ?? this.id, - attributes: attributes ?? this.attributes, - toOne: toOne ?? this.toOne, - toMany: toMany ?? this.toMany); - /// Creates an instance of [Resource]. /// The [type] can not be null. /// The [id] may be null for the resources to be created on the server. diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 751d6daa..e180b1d7 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,5 +1,3 @@ -import 'package:json_api/src/http/normalize.dart'; - /// The request which is sent by the client and received by the server class HttpRequest { /// Requested URI @@ -16,7 +14,8 @@ class HttpRequest { HttpRequest(String method, this.uri, {String body, Map headers}) - : headers = normalize(headers), + : headers = Map.unmodifiable( + (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), method = method.toUpperCase(), body = body ?? ''; } diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index 2f94120e..b5d70721 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -1,5 +1,3 @@ -import 'normalize.dart'; - /// The response sent by the server and received by the client class HttpResponse { /// Response status code @@ -12,6 +10,7 @@ class HttpResponse { final Map headers; HttpResponse(this.statusCode, {String body, Map headers}) - : headers = normalize(headers), + : headers = Map.unmodifiable( + (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), body = body ?? ''; } diff --git a/lib/src/http/normalize.dart b/lib/src/http/normalize.dart deleted file mode 100644 index a6e85ccd..00000000 --- a/lib/src/http/normalize.dart +++ /dev/null @@ -1,3 +0,0 @@ -/// Makes the keys lowercase, wraps to unmodifiable map -Map normalize(Map headers) => Map.unmodifiable( - (headers ?? const {}).map((k, v) => MapEntry(k.toLowerCase(), v))); diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 436b2f81..89db25d9 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -28,7 +28,10 @@ class InMemoryRepository implements Repository { throw UnsupportedOperation('Id generation is not supported'); } final id = _nextId(); - final created = resource.replace(id: id); + final created = Resource(resource.type, id ?? resource.id, + attributes: resource.attributes, + toOne: resource.toOne, + toMany: resource.toMany); _collections[collection][created.id] = created; return created; } diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index f07eed56..07d41e43 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -108,7 +108,7 @@ class _Accepted extends JsonApiResponse { Map buildHeaders(UriDesign design) => { 'Content-Type': Document.contentType, 'Content-Location': - design.resourceUri(resource.type, resource.id).toString(), + design.resource(resource.type, resource.id).toString(), }; } @@ -173,7 +173,7 @@ class _ResourceCreated extends JsonApiResponse { @override Map buildHeaders(UriDesign design) => { 'Content-Type': Document.contentType, - 'Location': design.resourceUri(resource.type, resource.id).toString() + 'Location': design.resource(resource.type, resource.id).toString() }; } @@ -188,7 +188,7 @@ class _SeeOther extends JsonApiResponse { @override Map buildHeaders(UriDesign design) => - {'Location': design.resourceUri(type, id).toString()}; + {'Location': design.resource(type, id).toString()}; } class _ToMany extends JsonApiResponse { diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 7b560ee8..596e660a 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -118,3 +118,86 @@ class JsonApiServer implements HttpHandler { detail: 'Allowed methods: ${allow.join(', ')}') ], allow: allow); } + +class RequestDispatcher { + FutureOr dispatch(HttpRequest request) async { + final target = _uriDesign.matchTarget(request.uri); + if (target is CollectionTarget) { + switch (request.method) { + case 'GET': + return _controller.fetchCollection(request, target); + case 'POST': + return _controller.createResource(request, target, + ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['GET', 'POST']); + } + } else if (target is ResourceTarget) { + switch (request.method) { + case 'DELETE': + return _controller.deleteResource(request, target); + case 'GET': + return _controller.fetchResource(request, target); + case 'PATCH': + return _controller.updateResource(request, target, + ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['DELETE', 'GET', 'PATCH']); + } + } else if (target is RelatedTarget) { + switch (request.method) { + case 'GET': + return _controller.fetchRelated(request, target); + default: + return _allow(['GET']); + } + } else if (target is RelationshipTarget) { + switch (request.method) { + case 'DELETE': + return _controller.deleteFromRelationship(request, target, + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + case 'GET': + return _controller.fetchRelationship(request, target); + case 'PATCH': + final rel = Relationship.fromJson(jsonDecode(request.body)); + if (rel is ToOne) { + return _controller.replaceToOne(request, target, rel.unwrap()); + } + if (rel is ToMany) { + return _controller.replaceToMany(request, target, rel.unwrap()); + } + return JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Incomplete relationship object') + ]); + case 'POST': + return _controller.addToRelationship(request, target, + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _allow(['DELETE', 'GET', 'PATCH', 'POST']); + } + } + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server') + ]); + } + + final UriDesign _uriDesign; + + final JsonApiController _controller; + + RequestDispatcher(this._uriDesign, this._controller); + + JsonApiResponse _allow(Iterable allow) => + JsonApiResponse.methodNotAllowed([ + JsonApiError( + status: '405', + title: 'Method Not Allowed', + detail: 'Allowed methods: ${allow.join(', ')}') + ], allow: allow); +} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 5fc818c3..9ead9a5d 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -17,6 +17,15 @@ class RepositoryController implements JsonApiController { RelationshipTarget target, Iterable identifiers) => _do(() async { final original = await _repo.get(target.type, target.id); + if (!original.toMany.containsKey(target.relationship)) { + return JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Relationship not found', + detail: + "There is no to-many relationship '${target.relationship}' in this resource") + ]); + } final updated = await _repo.update( target.type, target.id, @@ -97,8 +106,7 @@ class RepositoryController implements JsonApiController { } return JsonApiResponse.collection(related); } - return _relationshipNotFound( - target.relationship, target.type, target.id); + return _relationshipNotFound(target.relationship); }); @override @@ -114,8 +122,7 @@ class RepositoryController implements JsonApiController { return JsonApiResponse.toMany(target.type, target.id, target.relationship, resource.toMany[target.relationship]); } - return _relationshipNotFound( - target.relationship, target.type, target.id); + return _relationshipNotFound(target.relationship); }); @override @@ -228,12 +235,14 @@ class RepositoryController implements JsonApiController { } JsonApiResponse _relationshipNotFound( - String relationship, String type, String id) { + String relationship, + ) { return JsonApiResponse.notFound([ JsonApiError( status: '404', title: 'Relationship not found', - detail: "Relationship '$relationship' does not exist in '$type:$id'") + detail: + "Relationship '$relationship' does not exist in this resource") ]); } } diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index 1c99cc59..f6ada460 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -40,7 +40,7 @@ class ResponseDocumentFactory { /// See https://jsonapi.org/format/#crud-creating-responses-201 Document makeCreatedResourceDocument(Resource resource) => makeResourceDocument( - _urlFactory.resourceUri(resource.type, resource.id), resource); + _urlFactory.resource(resource.type, resource.id), resource); /// A document containing a to-many relationship Document makeToManyDocument( @@ -54,7 +54,7 @@ class ResponseDocumentFactory { identifiers.map(IdentifierObject.fromIdentifier), links: { 'self': Link(self), - 'related': Link(_urlFactory.relatedUri(type, id, relationship)) + 'related': Link(_urlFactory.related(type, id, relationship)) }, ), api: _api); @@ -67,7 +67,7 @@ class ResponseDocumentFactory { nullable(IdentifierObject.fromIdentifier)(identifier), links: { 'self': Link(self), - 'related': Link(_urlFactory.relatedUri(type, id, relationship)) + 'related': Link(_urlFactory.related(type, id, relationship)) }, ), api: _api); @@ -91,8 +91,8 @@ class ResponseDocumentFactory { ToOne( nullable(IdentifierObject.fromIdentifier)(v), links: { - 'self': Link(_urlFactory.relationshipUri(r.type, r.id, k)), - 'related': Link(_urlFactory.relatedUri(r.type, r.id, k)) + 'self': Link(_urlFactory.relationship(r.type, r.id, k)), + 'related': Link(_urlFactory.related(r.type, r.id, k)) }, ))), ...r.toMany.map((k, v) => MapEntry( @@ -100,12 +100,12 @@ class ResponseDocumentFactory { ToMany( v.map(IdentifierObject.fromIdentifier), links: { - 'self': Link(_urlFactory.relationshipUri(r.type, r.id, k)), - 'related': Link(_urlFactory.relatedUri(r.type, r.id, k)) + 'self': Link(_urlFactory.relationship(r.type, r.id, k)), + 'related': Link(_urlFactory.related(r.type, r.id, k)) }, ))) }, links: { - 'self': Link(_urlFactory.resourceUri(r.type, r.id)) + 'self': Link(_urlFactory.resource(r.type, r.id)) }); Map _navigation(Uri uri, int total) { diff --git a/lib/uri_design.dart b/lib/uri_design.dart index 6cc6d4c5..1129317f 100644 --- a/lib/uri_design.dart +++ b/lib/uri_design.dart @@ -8,20 +8,20 @@ abstract class UriDesign implements TargetMatcher, UriFactory { /// Makes URIs for specific targets abstract class UriFactory { /// Returns a URL for the primary resource collection of type [type] - Uri collectionUri(String 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 relatedUri(String type, String id, String relationship); + 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 relationshipUri(String type, String id, String relationship); + Uri relationship(String type, String id, String relationship); /// Returns a URL for the primary resource of type [type] with id [id] - Uri resourceUri(String type, String id); + Uri resource(String type, String id); } /// Determines if a given URI matches a specific target @@ -82,25 +82,25 @@ class RelationshipTarget implements Target { class _Standard implements UriDesign { /// Returns a URL for the primary resource collection of type [type] @override - Uri collectionUri(String type) => _appendToBase([type]); + Uri collection(String type) => _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. @override - Uri relatedUri(String type, String id, String relationship) => + Uri related(String type, String id, String relationship) => _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. @override - Uri relationshipUri(String type, String id, String relationship) => + Uri relationship(String type, String id, String relationship) => _appendToBase([type, id, _relationships, relationship]); /// Returns a URL for the primary resource of type [type] with id [id] @override - Uri resourceUri(String type, String id) => _appendToBase([type, id]); + Uri resource(String type, String id) => _appendToBase([type, id]); @override Target matchTarget(Uri uri) { diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index 0add6ce1..b3ad230f 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -35,15 +35,17 @@ void main() { await server.close(); }); - test('can create and fetch resources', () async { - await client.createResource( - Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); - - await client.createResource(Resource('books', '2', attributes: { - 'title': 'Refactoring' - }, toMany: { - 'authors': [Identifier('writers', '1')] - })); + test('Happy Path', () async { + final writer = + Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); + final book = Resource('books', '2', attributes: {'title': 'Refactoring'}); + + await client.createResource(writer); + await client.createResource(book); + await client + .updateResource(Resource('books', '2', toMany: {'authors': []})); + await client.addToRelationship( + 'books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 2ed0a38a..948e9686 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -126,7 +126,7 @@ void main() async { test('409 when the resource type does not match collection', () async { final r = await JsonApiClient(server).createResourceAt( - design.collectionUri('fruits'), Resource('cucumbers', null)); + design.collection('fruits'), Resource('cucumbers', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 409); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index a5ddd4e9..fba24707 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -59,7 +59,7 @@ void main() async { expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, - "Relationship 'owner' does not exist in 'books:1'"); + "Relationship 'owner' does not exist in this resource"); }); }); @@ -97,7 +97,7 @@ void main() async { expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); + "Relationship 'readers' does not exist in this resource"); }); }); @@ -156,7 +156,7 @@ void main() async { expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); + "Relationship 'readers' does not exist in this resource"); }); }); } diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 82e0ec13..11c02075 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -106,7 +106,7 @@ void main() async { expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, - "Relationship 'owner' does not exist in 'books:1'"); + "Relationship 'owner' does not exist in this resource"); }); }); @@ -144,7 +144,7 @@ void main() async { expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, - "Relationship 'readers' does not exist in 'books:1'"); + "Relationship 'readers' does not exist in this resource"); }); }); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index ca0870dd..3dd945b0 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -61,6 +61,7 @@ void main() async { expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); + }); group('Deleting a to-one relationship', () { @@ -185,6 +186,19 @@ void main() async { expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); + + test('404 when relationship not found', () async { + final r = await client.addToRelationship( + 'books', '1', 'sellers', [Identifier('companies', '3')]); + expect(r.isSuccessful, isFalse); + expect(r.statusCode, 404); + expect(r.data, isNull); + final error = r.errors.first; + expect(error.status, '404'); + expect(error.title, 'Relationship not found'); + expect(error.detail, + "There is no to-many relationship 'sellers' in this resource"); + }); }); group('Deleting from a to-many relationship', () { diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 738a7cc7..f0012c8f 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -73,7 +73,7 @@ void main() async { test('409 when the resource type does not match the collection', () async { final r = await client.updateResourceAt( - design.resourceUri('people', '1'), Resource('books', '1')); + design.resource('people', '1'), Resource('books', '1')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 409); expect(r.data, isNull); diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 9b2d3178..0a4418eb 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -2,16 +2,6 @@ import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - test('Can not create Identifier when id==null', () { - expect(() => Resource('type', null).toIdentifier(), throwsStateError); - }); - - test('Can create Identifier', () { - final id = Resource('apples', '123').toIdentifier(); - expect(id.type, 'apples'); - expect(id.id, '123'); - }); - test('Removes duplicate identifiers in toMany relationships', () { final r = Resource('type', 'id', toMany: { 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index ef26ffda..8b13e914 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -14,7 +14,7 @@ void main() { group('JsonApiServer', () { test('returns `bad request` on incomplete relationship', () async { final rq = HttpRequest( - 'PATCH', url.relationshipUri('books', '1', 'author'), + 'PATCH', url.relationship('books', '1', 'author'), body: '{}'); final rs = await server(rq); expect(rs.statusCode, 400); @@ -26,7 +26,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON', () async { final rq = - HttpRequest('POST', url.collectionUri('books'), body: '"ololo"abc'); + HttpRequest('POST', url.collection('books'), body: '"ololo"abc'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -38,7 +38,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON:API object', () async { final rq = - HttpRequest('POST', url.collectionUri('books'), body: '"oops"'); + HttpRequest('POST', url.collection('books'), body: '"oops"'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -50,7 +50,7 @@ void main() { test('returns `bad request` when payload violates JSON:API', () async { final rq = - HttpRequest('POST', url.collectionUri('books'), body: '{"data": {}}'); + HttpRequest('POST', url.collection('books'), body: '{"data": {}}'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -70,7 +70,7 @@ void main() { }); test('returns `method not allowed` for resource collection', () async { - final rq = HttpRequest('DELETE', url.collectionUri('books')); + final rq = HttpRequest('DELETE', url.collection('books')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'GET, POST'); @@ -81,7 +81,7 @@ void main() { }); test('returns `method not allowed` for resource', () async { - final rq = HttpRequest('POST', url.resourceUri('books', '1')); + final rq = HttpRequest('POST', url.resource('books', '1')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'DELETE, GET, PATCH'); @@ -92,7 +92,7 @@ void main() { }); test('returns `method not allowed` for related', () async { - final rq = HttpRequest('POST', url.relatedUri('books', '1', 'author')); + final rq = HttpRequest('POST', url.related('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'GET'); @@ -104,7 +104,7 @@ void main() { test('returns `method not allowed` for relationship', () async { final rq = - HttpRequest('PUT', url.relationshipUri('books', '1', 'author')); + HttpRequest('PUT', url.relationship('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST'); diff --git a/ya.dart b/ya.dart new file mode 100644 index 00000000..ae423d1f --- /dev/null +++ b/ya.dart @@ -0,0 +1,7 @@ +import 'package:http/http.dart'; + +void main() async { + final c = Client(); + final r = await c.get('https://ya.ru'); + r.headers.forEach((k, v) => print('$k : $v\n')); +} \ No newline at end of file From d6135510d7d987a60b10a590119d1bf2b9eee8bd Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 17 Feb 2020 20:05:16 -0800 Subject: [PATCH 22/99] wip --- example/client.dart | 8 +- example/server.dart | 6 +- lib/client.dart | 3 +- lib/routing.dart | 52 +++++ lib/server.dart | 1 + lib/src/client/json_api_client.dart | 197 ++---------------- lib/src/client/json_api_response.dart | 1 - lib/src/client/request_document_factory.dart | 33 --- lib/src/client/routing_client.dart | 127 +++++++++++ lib/src/document/relationship.dart | 6 + lib/src/document/resource.dart | 15 +- lib/src/document/resource_data.dart | 3 + lib/src/document/resource_object.dart | 10 + lib/src/server/json_api_controller.dart | 5 +- lib/src/server/json_api_response.dart | 30 +-- lib/src/server/json_api_server.dart | 110 ++-------- lib/src/server/repository_controller.dart | 3 +- lib/src/server/response_document_factory.dart | 4 +- lib/src/server/target.dart | 49 +++++ lib/uri_design.dart | 130 ------------ test/e2e/client_server_interaction_test.dart | 12 +- test/functional/compound_document_test.dart | 10 +- .../crud/creating_resources_test.dart | 55 ++--- .../crud/deleting_resources_test.dart | 22 +- .../crud/fetching_relationships_test.dart | 42 ++-- .../crud/fetching_resources_test.dart | 40 ++-- test/functional/crud/seed_resources.dart | 2 +- .../crud/updating_relationships_test.dart | 64 +++--- .../crud/updating_resources_test.dart | 24 ++- .../client/request_document_factory_test.dart | 11 - test/unit/server/json_api_server_test.dart | 22 +- 31 files changed, 479 insertions(+), 618 deletions(-) create mode 100644 lib/routing.dart delete mode 100644 lib/src/client/request_document_factory.dart create mode 100644 lib/src/client/routing_client.dart create mode 100644 lib/src/server/target.dart delete mode 100644 lib/uri_design.dart delete mode 100644 test/unit/client/request_document_factory_test.dart diff --git a/example/client.dart b/example/client.dart index afaf2ddc..2e9f003c 100644 --- a/example/client.dart +++ b/example/client.dart @@ -3,14 +3,14 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/client/dart_http.dart'; -import 'package:json_api/uri_design.dart'; /// This example shows how to use the JSON:API client. /// Run the server first! void main() async { - /// Use the same URI design as the server - final uriDesign = UriDesign.standard(Uri.parse('http://localhost:8080')); + /// Use the standard routing + final routing = StandardRouting(Uri.parse('http://localhost:8080')); /// Create the HTTP client. We're using Dart's native client. /// Do not forget to call [Client.close] when you're done using it. @@ -22,7 +22,7 @@ void main() async { onResponse: (r) => print('${r.statusCode}')); /// The JSON:API client - final client = JsonApiClient(httpHandler, uriFactory: uriDesign); + final client = RoutingClient(JsonApiClient(httpHandler), routing); /// Create the first resource await client.createResource( diff --git a/example/server.dart b/example/server.dart index 9afa7631..564d4757 100644 --- a/example/server.dart +++ b/example/server.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; /// This example shows how to run a simple JSON:API server using the built-in /// HTTP server (dart:io). @@ -18,7 +18,7 @@ void main() async { final base = Uri(host: address, port: port, scheme: 'http'); /// Use the standard URI design - final uriDesign = UriDesign.standard(base); + final routing = StandardRouting(base); /// Resource repository supports two kind of entities: writers and books final repo = InMemoryRepository({'writers': {}, 'books': {}}); @@ -27,7 +27,7 @@ void main() async { final controller = RepositoryController(repo); /// The JSON:API server uses the given URI design to route requests to the controller - final jsonApiServer = JsonApiServer(uriDesign, controller); + final jsonApiServer = JsonApiServer(routing, controller); /// We will be logging the requests and responses to the console final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, diff --git a/lib/client.dart b/lib/client.dart index 545b8776..9d84d7f4 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -2,5 +2,4 @@ library client; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_response.dart'; -export 'package:json_api/src/client/request_document_factory.dart'; -export 'package:json_api/src/client/status_code.dart'; +export 'package:json_api/src/client/routing_client.dart'; diff --git a/lib/routing.dart b/lib/routing.dart new file mode 100644 index 00000000..2d7b7bf4 --- /dev/null +++ b/lib/routing.dart @@ -0,0 +1,52 @@ +/// Makes URIs for specific targets +abstract class Routing { + /// 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); +} + +class StandardRouting implements Routing { + /// Returns a URL for the primary resource collection of type [type] + @override + Uri collection(String type) => _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. + @override + Uri related(String type, String id, String relationship) => + _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. + @override + Uri relationship(String type, String id, String relationship) => + _appendToBase([type, id, _relationships, relationship]); + + /// Returns a URL for the primary resource of type [type] with id [id] + @override + Uri resource(String type, String id) => _appendToBase([type, id]); + + const StandardRouting(this._base); + + static const _relationships = 'relationships'; + + /// The base to be added the the generated URIs + final Uri _base; + + Uri _appendToBase(List segments) => + _base.replace(pathSegments: _base.pathSegments + segments); +} diff --git a/lib/server.dart b/lib/server.dart index ce77b41c..b9481e7c 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -12,3 +12,4 @@ export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; export 'package:json_api/src/server/response_document_factory.dart'; +export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 0bf36e39..971bf71a 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -5,9 +5,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/client/json_api_response.dart'; -import 'package:json_api/src/client/request_document_factory.dart'; import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/uri_design.dart'; /// The JSON:API Client. class JsonApiClient { @@ -20,21 +18,6 @@ class JsonApiClient { {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); - /// Fetches a primary resource collection. Guesses the URI by [type]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchCollection(String type, - {Map headers, QueryParameters parameters}) => - fetchCollectionAt(_collection(type), - headers: headers, parameters: parameters); - - /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - fetchCollectionAt(_related(type, id, relationship), - headers: headers, parameters: parameters); - /// Fetches a single resource at the [uri]. /// Use [headers] to pass extra HTTP headers. /// Use [parameters] to specify extra query parameters, such as: @@ -44,21 +27,6 @@ class JsonApiClient { {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceData.fromJson); - /// Fetches a primary resource. Guesses the URI by [type] and [id]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchResource(String type, String id, - {Map headers, QueryParameters parameters}) => - fetchResourceAt(_resource(type, id), - headers: headers, parameters: parameters); - - /// Fetches a related resource. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchRelatedResource( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - fetchResourceAt(_related(type, id, relationship), - headers: headers, parameters: parameters); - /// Fetches a to-one relationship /// Use [headers] to pass extra HTTP headers. /// Use [queryParameters] to specify extra request parameters, such as: @@ -68,14 +36,6 @@ class JsonApiClient { {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToOne.fromJson); - /// Same as [fetchToOneAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchToOne( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - fetchToOneAt(_relationship(type, id, relationship), - headers: headers, parameters: parameters); - /// Fetches a to-many relationship /// Use [headers] to pass extra HTTP headers. /// Use [queryParameters] to specify extra request parameters, such as: @@ -85,14 +45,6 @@ class JsonApiClient { {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToMany.fromJson); - /// Same as [fetchToManyAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchToMany( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - fetchToManyAt(_relationship(type, id, relationship), - headers: headers, parameters: parameters); - /// 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. @@ -103,28 +55,14 @@ class JsonApiClient { {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), Relationship.fromJson); - /// Same as [fetchRelationshipAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - fetchRelationshipAt(_relationship(type, id, relationship), - headers: headers, parameters: parameters); - /// Creates a new resource. The resource will be added to a collection /// according to its type. /// /// https://jsonapi.org/format/#crud-creating Future> createResourceAt( - Uri uri, Resource resource, {Map headers}) => - _call(_post(uri, headers, _doc.resourceDocument(resource)), - ResourceData.fromJson); - - /// Same as [createResourceAt]. Guesses the URI by [resource].type. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> createResource(Resource resource, + Uri uri, Resource resource, {Map headers}) => - createResourceAt(_collection(resource.type), resource, headers: headers); + _call(_post(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); /// Deletes the resource. /// @@ -133,42 +71,20 @@ class JsonApiClient { {Map headers}) => _call(_delete(uri, headers), null); - /// Same as [deleteResourceAt]. Guesses the URI by [type] and [id]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future deleteResource(String type, String id, - {Map headers}) => - deleteResourceAt(_resource(type, id), headers: headers); - /// Updates the resource via PATCH query. /// /// https://jsonapi.org/format/#crud-updating Future> updateResourceAt( Uri uri, Resource resource, {Map headers}) => - _call(_patch(uri, headers, _doc.resourceDocument(resource)), - ResourceData.fromJson); - - /// Same as [updateResourceAt]. Guesses the URI by [resource] type an id. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> updateResource(Resource resource, - {Map headers}) => - updateResourceAt(_resource(resource.type, resource.id), resource, - headers: headers); + _call( + _patch(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships Future> replaceToOneAt(Uri uri, Identifier identifier, {Map headers}) => - _call( - _patch(uri, headers, _doc.toOneDocument(identifier)), ToOne.fromJson); - - /// Same as [replaceToOneAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> replaceToOne( - String type, String id, String relationship, Identifier identifier, - {Map headers}) => - replaceToOneAt(_relationship(type, id, relationship), identifier, - headers: headers); + _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt] /// with id = null. @@ -176,30 +92,15 @@ class JsonApiClient { {Map headers}) => replaceToOneAt(uri, null, headers: headers); - /// Same as [deleteToOneAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> deleteToOne( - String type, String id, String relationship, - {Map headers}) => - deleteToOneAt(_relationship(type, id, relationship), headers: headers); - /// Removes the [identifiers] from the to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> deleteFromToManyAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_deleteWithBody(uri, headers, _doc.toManyDocument(identifiers)), + _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); - /// Same as [deleteFromToManyAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - deleteFromToManyAt(_relationship(type, id, relationship), identifiers, - headers: headers); - /// Replaces a to-many relationship with the given set of [identifiers]. /// /// The server MUST either completely replace every member of the relationship, @@ -210,16 +111,7 @@ class JsonApiClient { Future> replaceToManyAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_patch(uri, headers, _doc.toManyDocument(identifiers)), - ToMany.fromJson); - - /// Same as [replaceToManyAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - replaceToManyAt(_relationship(type, id, relationship), identifiers, - headers: headers); + _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); /// Adds the given set of [identifiers] to a to-many relationship. /// @@ -227,31 +119,23 @@ class JsonApiClient { Future> addToRelationshipAt( Uri uri, Iterable identifiers, {Map headers}) => - _call(_post(uri, headers, _doc.toManyDocument(identifiers)), - ToMany.fromJson); - - /// Same as [addToRelationshipAt]. Guesses the URI by [type], [id], [relationship]. - /// This method requires an instance of [UriFactory] to be specified when creating this class. - Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - addToRelationshipAt(_relationship(type, id, relationship), identifiers, - headers: headers); + _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); /// Creates an instance of JSON:API client. - /// Pass an instance of DartHttpClient (comes with this package) or - /// another instance of [HttpHandler]. - /// Provide the [uriFactory] to use URI guessing methods. - /// Use a custom [documentFactory] if you want to build the outgoing - /// documents in a special way. - JsonApiClient(this._http, - {RequestDocumentFactory documentFactory, UriFactory uriFactory}) - : _doc = documentFactory ?? RequestDocumentFactory(), - _uri = uriFactory ?? const _NullUriFactory(); + /// Provide instances of [HttpHandler] (e.g. [DartHttp]) + JsonApiClient(this._httpHandler); + + final HttpHandler _httpHandler; + static final _api = Api(version: '1.0'); + + Document _resourceDoc(Resource resource) => + Document(ResourceData.fromResource(resource), api: _api); - final HttpHandler _http; - final RequestDocumentFactory _doc; - final UriFactory _uri; + Document _toManyDoc(Iterable identifiers) => + Document(ToMany.fromIdentifiers(identifiers), api: _api); + + Document _toOneDoc(Identifier identifier) => + Document(ToOne.fromIdentifier(identifier), api: _api); HttpRequest _get(Uri uri, Map headers, QueryParameters queryParameters) => @@ -297,7 +181,7 @@ class JsonApiClient { Future> _call( HttpRequest request, D Function(Object _) decodePrimaryData) async { - final response = await _http(request); + final response = await _httpHandler(request); final document = response.body.isEmpty ? null : jsonDecode(response.body); if (document == null) { return JsonApiResponse(response.statusCode, response.headers); @@ -313,41 +197,4 @@ class JsonApiClient { ? null : Document.fromJson(document, decodePrimaryData)); } - - Uri _collection(String type) => _uri.collection(type); - - Uri _relationship(String type, String id, String relationship) => - _uri.relationship(type, id, relationship); - - Uri _resource(String type, String id) => _uri.resource(type, id); - - Uri _related(String type, String id, String relationship) => - _uri.related(type, id, relationship); -} - -final _error = - StateError('Provide an instance of UriFactory to use URI guesing'); - -class _NullUriFactory implements UriFactory { - const _NullUriFactory(); - - @override - Uri collection(String type) { - throw _error; - } - - @override - Uri related(String type, String id, String relationship) { - throw _error; - } - - @override - Uri relationship(String type, String id, String relationship) { - throw _error; - } - - @override - Uri resource(String type, String id) { - throw _error; - } } diff --git a/lib/src/client/json_api_response.dart b/lib/src/client/json_api_response.dart index e6a8f06d..ffc69be7 100644 --- a/lib/src/client/json_api_response.dart +++ b/lib/src/client/json_api_response.dart @@ -1,4 +1,3 @@ -import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/nullable.dart'; diff --git a/lib/src/client/request_document_factory.dart b/lib/src/client/request_document_factory.dart deleted file mode 100644 index bb29ff2e..00000000 --- a/lib/src/client/request_document_factory.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/nullable.dart'; - -/// The document factory used by the client. It is responsible -/// for building the JSON representation of the outgoing resources. -class RequestDocumentFactory { - /// Makes a document containing a single resource. - Document resourceDocument(Resource resource) => - Document(ResourceData(_resourceObject(resource)), api: _api); - - /// Makes a document containing a to-many relationship. - Document toManyDocument(Iterable ids) => - Document(ToMany(ids.map(IdentifierObject.fromIdentifier)), api: _api); - - /// Makes a document containing a to-one relationship. - Document toOneDocument(Identifier id) => - Document(ToOne(nullable(IdentifierObject.fromIdentifier)(id)), api: _api); - - /// Creates an instance of the factory. - RequestDocumentFactory({Api api}) : _api = api ?? Api(version: '1.0'); - - final Api _api; - - ResourceObject _resourceObject(Resource resource) => - ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: { - ...resource.toOne.map((k, v) => MapEntry( - k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), - ...resource.toMany.map((k, v) => - MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))) - }); -} diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart new file mode 100644 index 00000000..c66b382c --- /dev/null +++ b/lib/src/client/routing_client.dart @@ -0,0 +1,127 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; + +import 'json_api_response.dart'; + +/// This is a wrapper over [JsonApiClient] capable of building the +/// request URIs by itself. +class RoutingClient { + /// Fetches a primary resource collection by [type]. + Future> fetchCollection(String type, + {Map headers, QueryParameters parameters}) => + _client.fetchCollectionAt(_collection(type), + headers: headers, parameters: parameters); + + /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. + Future> fetchRelatedCollection( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchCollectionAt(_related(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a primary resource by [type] and [id]. + Future> fetchResource(String type, String id, + {Map headers, QueryParameters parameters}) => + _client.fetchResourceAt(_resource(type, id), + headers: headers, parameters: parameters); + + /// Fetches a related resource by [type], [id], [relationship]. + Future> fetchRelatedResource( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchResourceAt(_related(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a to-one relationship by [type], [id], [relationship]. + Future> fetchToOne( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchToOneAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a to-many relationship by [type], [id], [relationship]. + Future> fetchToMany( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchToManyAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// Fetches a [relationship] of [type] : [id]. + Future> fetchRelationship( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + _client.fetchRelationshipAt(_relationship(type, id, relationship), + headers: headers, parameters: parameters); + + /// Creates the [resource] on the server. + Future> createResource(Resource resource, + {Map headers}) => + _client.createResourceAt(_collection(resource.type), resource, + headers: headers); + + /// Deletes the resource by [type] and [id]. + Future deleteResource(String type, String id, + {Map headers}) => + _client.deleteResourceAt(_resource(type, id), headers: headers); + + /// Updates the [resource]. + Future> updateResource(Resource resource, + {Map headers}) => + _client.updateResourceAt(_resource(resource.type, resource.id), resource, + headers: headers); + + /// Replaces the to-one [relationship] of [type] : [id]. + Future> replaceToOne( + String type, String id, String relationship, Identifier identifier, + {Map headers}) => + _client.replaceToOneAt(_relationship(type, id, relationship), identifier, + headers: headers); + + /// Deletes the to-one [relationship] of [type] : [id]. + Future> deleteToOne( + String type, String id, String relationship, + {Map headers}) => + _client.deleteToOneAt(_relationship(type, id, relationship), + headers: headers); + + /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. + Future> deleteFromToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.deleteFromToManyAt( + _relationship(type, id, relationship), identifiers, + headers: headers); + + /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. + Future> replaceToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.replaceToManyAt( + _relationship(type, id, relationship), identifiers, + headers: headers); + + /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. + Future> addToRelationship(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + _client.addToRelationshipAt( + _relationship(type, id, relationship), identifiers, + headers: headers); + + RoutingClient(this._client, this._routing); + + final JsonApiClient _client; + final Routing _routing; + + Uri _collection(String type) => _routing.collection(type); + + Uri _relationship(String type, String id, String relationship) => + _routing.relationship(type, id, relationship); + + Uri _resource(String type, String id) => _routing.resource(type, id); + + Uri _related(String type, String id, String relationship) => + _routing.related(type, id, relationship); +} diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 73a3e146..879d28b3 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -75,6 +75,9 @@ class ToOne extends Relationship { : linkage = null, super(links: links); + static ToOne fromIdentifier(Identifier identifier) => + ToOne(nullable(IdentifierObject.fromIdentifier)(identifier)); + static ToOne fromJson(Object json) { if (json is Map && json.containsKey('data')) { final included = json['included']; @@ -116,6 +119,9 @@ class ToMany extends Relationship { : linkage = List.unmodifiable(linkage), super(included: included, links: links); + static ToMany fromIdentifiers(Iterable identifiers) => + ToMany(identifiers.map(IdentifierObject.fromIdentifier)); + static ToMany fromJson(Object json) { if (json is Map && json.containsKey('data')) { final data = json['data']; diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 8c86d490..cd50ad16 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -43,12 +43,13 @@ class Resource { (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { DocumentException.throwIfNull(type, "Resource 'type' must not be null"); } +} - /// Returns a resource to be created on the server (without the "id") - static Resource toCreate(String type, - {Map attributes, - Map toOne, - Map> toMany}) => - Resource(type, null, - attributes: attributes, toMany: toMany, toOne: toOne); +/// Resource to be created on the server. Does not have the id yet. +class NewResource extends Resource { + NewResource(String type, + {Map attributes, + Map toOne, + Map> toMany}) + : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index fb525516..28d391d1 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -17,6 +17,9 @@ class ResourceData extends PrimaryData { ? null : {...?resourceObject?.links, ...?links}); + static ResourceData fromResource(Resource resource) => + ResourceData(ResourceObject.fromResource(resource)); + static ResourceData fromJson(Object json) { if (json is Map) { Iterable resources; diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index a95c3f91..738df40f 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -37,6 +37,16 @@ class ResourceObject { Link get self => (links ?? {})['self']; + static ResourceObject fromResource(Resource resource) => + ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: { + ...resource.toOne.map((k, v) => MapEntry( + k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), + ...resource.toMany.map((k, v) => + MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))) + }); + /// Reconstructs the `data` member of a JSON:API Document. /// If [json] is null, returns null. static ResourceObject fromJson(Object json) { diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index 0f52b5a7..c8eee813 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -4,11 +4,10 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/src/server/target.dart'; /// The Controller consolidates all possible requests a JSON:API server -/// may handle. The controller is agnostic to the request, therefore it is -/// generalized with ``. Each of the methods is expected to return a +/// may handle. Each of the methods is expected to return a /// [JsonApiResponse] object or a [Future] of it. /// /// The response may either be a successful or an error. diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 07d41e43..709b9e0e 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,6 +1,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response_document_factory.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; abstract class JsonApiResponse { final int statusCode; @@ -9,7 +9,7 @@ abstract class JsonApiResponse { Document buildDocument(ResponseDocumentFactory factory, Uri self); - Map buildHeaders(UriDesign design); + Map buildHeaders(Routing routing); static JsonApiResponse noContent() => _NoContent(); @@ -73,7 +73,7 @@ class _NoContent extends JsonApiResponse { null; @override - Map buildHeaders(UriDesign design) => {}; + Map buildHeaders(Routing routing) => {}; } class _Collection extends JsonApiResponse { @@ -90,7 +90,7 @@ class _Collection extends JsonApiResponse { included: included, total: total); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -105,10 +105,10 @@ class _Accepted extends JsonApiResponse { factory.makeResourceDocument(self, resource); @override - Map buildHeaders(UriDesign design) => { + Map buildHeaders(Routing routing) => { 'Content-Type': Document.contentType, 'Content-Location': - design.resource(resource.type, resource.id).toString(), + routing.resource(resource.type, resource.id).toString(), }; } @@ -124,7 +124,7 @@ class _Error extends JsonApiResponse { builder.makeErrorDocument(errors); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {...headers, 'Content-Type': Document.contentType}; } @@ -138,7 +138,7 @@ class _Meta extends JsonApiResponse { builder.makeMetaDocument(meta); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -154,7 +154,7 @@ class _Resource extends JsonApiResponse { builder.makeResourceDocument(self, resource, included: included); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -171,9 +171,9 @@ class _ResourceCreated extends JsonApiResponse { builder.makeCreatedResourceDocument(resource); @override - Map buildHeaders(UriDesign design) => { + Map buildHeaders(Routing routing) => { 'Content-Type': Document.contentType, - 'Location': design.resource(resource.type, resource.id).toString() + 'Location': routing.resource(resource.type, resource.id).toString() }; } @@ -187,8 +187,8 @@ class _SeeOther extends JsonApiResponse { Document buildDocument(ResponseDocumentFactory builder, Uri self) => null; @override - Map buildHeaders(UriDesign design) => - {'Location': design.resource(type, id).toString()}; + Map buildHeaders(Routing routing) => + {'Location': routing.resource(type, id).toString()}; } class _ToMany extends JsonApiResponse { @@ -205,7 +205,7 @@ class _ToMany extends JsonApiResponse { builder.makeToManyDocument(self, collection, type, id, relationship); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } @@ -223,6 +223,6 @@ class _ToOne extends JsonApiResponse { builder.makeToOneDocument(self, identifier, type, id, relationship); @override - Map buildHeaders(UriDesign design) => + Map buildHeaders(Routing routing) => {'Content-Type': Document.contentType}; } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 596e660a..3726f5e3 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -3,8 +3,9 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/src/server/target.dart'; class JsonApiServer implements HttpHandler { @override @@ -13,20 +14,20 @@ class JsonApiServer implements HttpHandler { final document = response.buildDocument(_factory, request.uri); return HttpResponse(response.statusCode, body: document == null ? null : jsonEncode(document), - headers: response.buildHeaders(_uriDesign)); + headers: response.buildHeaders(_routing)); } - JsonApiServer(this._uriDesign, this._controller, + JsonApiServer(this._routing, this._controller, {ResponseDocumentFactory documentFactory}) - : _factory = documentFactory ?? ResponseDocumentFactory(_uriDesign); + : _factory = documentFactory ?? ResponseDocumentFactory(_routing); - final UriDesign _uriDesign; + final Routing _routing; final JsonApiController _controller; final ResponseDocumentFactory _factory; - + Future _do(HttpRequest request) async { try { - return await _dispatch(request); + return await RequestDispatcher(_controller).dispatch(request); } on JsonApiResponse catch (e) { return e; } on FormatException catch (e) { @@ -42,87 +43,13 @@ class JsonApiServer implements HttpHandler { ]); } } - - FutureOr _dispatch(HttpRequest request) async { - final target = _uriDesign.matchTarget(request.uri); - if (target is CollectionTarget) { - switch (request.method) { - case 'GET': - return _controller.fetchCollection(request, target); - case 'POST': - return _controller.createResource(request, target, - ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _allow(['GET', 'POST']); - } - } else if (target is ResourceTarget) { - switch (request.method) { - case 'DELETE': - return _controller.deleteResource(request, target); - case 'GET': - return _controller.fetchResource(request, target); - case 'PATCH': - return _controller.updateResource(request, target, - ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _allow(['DELETE', 'GET', 'PATCH']); - } - } else if (target is RelatedTarget) { - switch (request.method) { - case 'GET': - return _controller.fetchRelated(request, target); - default: - return _allow(['GET']); - } - } else if (target is RelationshipTarget) { - switch (request.method) { - case 'DELETE': - return _controller.deleteFromRelationship(request, target, - ToMany.fromJson(jsonDecode(request.body)).unwrap()); - case 'GET': - return _controller.fetchRelationship(request, target); - case 'PATCH': - final rel = Relationship.fromJson(jsonDecode(request.body)); - if (rel is ToOne) { - return _controller.replaceToOne(request, target, rel.unwrap()); - } - if (rel is ToMany) { - return _controller.replaceToMany(request, target, rel.unwrap()); - } - return JsonApiResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ]); - case 'POST': - return _controller.addToRelationship(request, target, - ToMany.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _allow(['DELETE', 'GET', 'PATCH', 'POST']); - } - } - return JsonApiResponse.notFound([ - JsonApiError( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ]); - } - - JsonApiResponse _allow(Iterable allow) => - JsonApiResponse.methodNotAllowed([ - JsonApiError( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${allow.join(', ')}') - ], allow: allow); } class RequestDispatcher { FutureOr dispatch(HttpRequest request) async { - final target = _uriDesign.matchTarget(request.uri); - if (target is CollectionTarget) { + final s = request.uri.pathSegments; + if (s.length == 1) { + final target = CollectionTarget(s[0]); switch (request.method) { case 'GET': return _controller.fetchCollection(request, target); @@ -132,7 +59,8 @@ class RequestDispatcher { default: return _allow(['GET', 'POST']); } - } else if (target is ResourceTarget) { + } else if (s.length == 2) { + final target = ResourceTarget(s[0], s[1]); switch (request.method) { case 'DELETE': return _controller.deleteResource(request, target); @@ -144,14 +72,16 @@ class RequestDispatcher { default: return _allow(['DELETE', 'GET', 'PATCH']); } - } else if (target is RelatedTarget) { + } else if (s.length == 3) { switch (request.method) { case 'GET': - return _controller.fetchRelated(request, target); + return _controller.fetchRelated( + request, RelatedTarget(s[0], s[1], s[2])); default: return _allow(['GET']); } - } else if (target is RelationshipTarget) { + } else if (s.length == 4 && s[2] == 'relationships') { + final target = RelationshipTarget(s[0], s[1], s[3]); switch (request.method) { case 'DELETE': return _controller.deleteFromRelationship(request, target, @@ -187,12 +117,10 @@ class RequestDispatcher { ]); } - final UriDesign _uriDesign; + RequestDispatcher(this._controller); final JsonApiController _controller; - RequestDispatcher(this._uriDesign, this._controller); - JsonApiResponse _allow(Iterable allow) => JsonApiResponse.methodNotAllowed([ JsonApiError( diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 9ead9a5d..836fd50e 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -6,7 +6,8 @@ import 'package:json_api/query.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/target.dart'; typedef UriReader = FutureOr Function(R request); diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index f6ada460..c1f40b02 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/query/page.dart'; import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; class ResponseDocumentFactory { /// A document containing a list of errors @@ -80,7 +80,7 @@ class ResponseDocumentFactory { : _api = api, _pagination = pagination ?? Pagination.none(); - final UriFactory _urlFactory; + final Routing _urlFactory; final Pagination _pagination; final Api _api; diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart new file mode 100644 index 00000000..98b37ca2 --- /dev/null +++ b/lib/src/server/target.dart @@ -0,0 +1,49 @@ + +abstract class Target {} + +/// The target of a URI referring a resource collection +class CollectionTarget implements Target { + /// Resource type + final String type; + + const CollectionTarget(this.type); +} + +/// The target of a URI referring to a single resource +class ResourceTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + const ResourceTarget(this.type, this.id); +} + +/// The target of a URI referring a related resource or collection +class RelatedTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelatedTarget(this.type, this.id, this.relationship); +} + +/// The target of a URI referring a relationship +class RelationshipTarget implements Target { + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + const RelationshipTarget(this.type, this.id, this.relationship); +} diff --git a/lib/uri_design.dart b/lib/uri_design.dart deleted file mode 100644 index 1129317f..00000000 --- a/lib/uri_design.dart +++ /dev/null @@ -1,130 +0,0 @@ -/// URI Design describes how the endpoints are organized. -abstract class UriDesign implements TargetMatcher, UriFactory { - /// Returns the URI design recommended by the JSON:API standard. - /// @see https://jsonapi.org/recommendations/#urls - static UriDesign standard(Uri base) => _Standard(base); -} - -/// Makes URIs for specific targets -abstract class UriFactory { - /// 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 { - /// Returns the target of the [uri] or null. - Target matchTarget(Uri uri); -} - -abstract class Target {} - -/// The target of a URI referring a resource collection -class CollectionTarget implements Target { - /// Resource type - final String type; - - const CollectionTarget(this.type); -} - -/// The target of a URI referring to a single resource -class ResourceTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - const ResourceTarget(this.type, this.id); -} - -/// The target of a URI referring a related resource or collection -class RelatedTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelatedTarget(this.type, this.id, this.relationship); -} - -/// The target of a URI referring a relationship -class RelationshipTarget implements Target { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelationshipTarget(this.type, this.id, this.relationship); -} - -class _Standard implements UriDesign { - /// Returns a URL for the primary resource collection of type [type] - @override - Uri collection(String type) => _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. - @override - Uri related(String type, String id, String relationship) => - _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. - @override - Uri relationship(String type, String id, String relationship) => - _appendToBase([type, id, _relationships, relationship]); - - /// Returns a URL for the primary resource of type [type] with id [id] - @override - Uri resource(String type, String id) => _appendToBase([type, id]); - - @override - Target matchTarget(Uri uri) { - if (!uri.toString().startsWith(_base.toString())) return null; - final s = uri.pathSegments.sublist(_base.pathSegments.length); - if (s.length == 1) { - return CollectionTarget(s[0]); - } else if (s.length == 2) { - return ResourceTarget(s[0], s[1]); - } else if (s.length == 3) { - return RelatedTarget(s[0], s[1], s[2]); - } else if (s.length == 4 && s[2] == _relationships) { - return RelationshipTarget(s[0], s[1], s[3]); - } - return null; - } - - const _Standard(this._base); - - static const _relationships = 'relationships'; - - /// The base to be added the the generated URIs - final Uri _base; - - Uri _appendToBase(List segments) => - _base.replace(pathSegments: _base.pathSegments + segments); -} diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index b3ad230f..dd9475cb 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -4,9 +4,9 @@ import 'package:http/http.dart'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/client/dart_http.dart'; -import 'package:json_api/uri_design.dart'; import 'package:pedantic/pedantic.dart'; import 'package:test/test.dart'; @@ -14,19 +14,19 @@ void main() { group('Client-Server interation over HTTP', () { final port = 8088; final host = 'localhost'; - final design = - UriDesign.standard(Uri(host: host, port: port, scheme: 'http')); + final routing = + StandardRouting(Uri(host: host, port: port, scheme: 'http')); final repo = InMemoryRepository({'writers': {}, 'books': {}}); - final jsonApiServer = JsonApiServer(design, RepositoryController(repo)); + final jsonApiServer = JsonApiServer(routing, RepositoryController(repo)); final serverHandler = DartServerHandler(jsonApiServer); Client httpClient; - JsonApiClient client; + RoutingClient client; HttpServer server; setUp(() async { server = await HttpServer.bind(host, port); httpClient = Client(); - client = JsonApiClient(DartHttp(httpClient), uriFactory: design); + client = RoutingClient(JsonApiClient(DartHttp(httpClient)), routing); unawaited(server.forEach(serverHandler)); }); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 10dfbf58..8c2f61e3 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -1,21 +1,21 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; import '../helper/expect_resources_equal.dart'; void main() async { - JsonApiClient client; + RoutingClient client; JsonApiServer server; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); final wonderland = Resource('countries', '1', attributes: {'name': 'Wonderland'}); final alice = Resource('people', '1', @@ -47,8 +47,8 @@ void main() async { 'countries': {'1': wonderland}, 'tags': {} }); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = RoutingClient(JsonApiClient(server), routing); }); group('Single Resouces', () { diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 948e9686..aa9e70ff 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -1,34 +1,30 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; import '../../helper/expect_resources_equal.dart'; void main() async { - JsonApiClient client; - JsonApiServer server; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); group('Server-genrated ID', () { test('201 Created', () async { final repository = InMemoryRepository({ 'people': {}, }, nextId: Uuid().v4); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + final server = JsonApiServer(routing, RepositoryController(repository)); + final client = JsonApiClient(server); + final routingClient = RoutingClient(client, routing); final person = - Resource.toCreate('people', attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); + NewResource('people', attributes: {'name': 'Martin Fowler'}); + final r = await routingClient.createResource(person); expect(r.statusCode, 201); expect(r.location, isNotNull); expect(r.location, r.data.links['self'].uri); @@ -36,18 +32,18 @@ void main() async { expect(created.type, person.type); expect(created.id, isNotNull); expect(created.attributes, equals(person.attributes)); - final r1 = await JsonApiClient(server).fetchResourceAt(r.location); + final r1 = await client.fetchResourceAt(r.location); expect(r1.statusCode, 200); expectResourcesEqual(r1.data.unwrap(), created); }); test('403 when the id can not be generated', () async { final repository = InMemoryRepository({'people': {}}); - client = JsonApiClient( - JsonApiServer(design, RepositoryController(repository)), - uriFactory: design); + final server = JsonApiServer(routing, RepositoryController(repository)); + final client = JsonApiClient(server); + final routingClient = RoutingClient(client, routing); - final r = await client.createResource(Resource('people', null)); + final r = await routingClient.createResource(Resource('people', null)); expect(r.statusCode, 403); expect(r.data, isNull); final error = r.errors.first; @@ -58,6 +54,8 @@ void main() async { }); group('Client-genrated ID', () { + JsonApiClient client; + RoutingClient routingClient; setUp(() async { final repository = InMemoryRepository({ 'books': {}, @@ -67,24 +65,27 @@ void main() async { 'fruits': {}, 'apples': {} }); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + final server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); }); + test('204 No Content', () async { final person = Resource('people', '123', attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); + final r = await routingClient.createResource(person); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.location, isNull); expect(r.data, isNull); - final r1 = await client.fetchResource(person.type, person.id); + final r1 = await routingClient.fetchResource(person.type, person.id); expect(r1.isSuccessful, isTrue); expect(r1.statusCode, 200); expectResourcesEqual(r1.data.unwrap(), person); }); + test('404 when the collection does not exist', () async { - final r = await client.createResource(Resource('unicorns', null)); + final r = await routingClient.createResource(Resource('unicorns', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); @@ -98,7 +99,7 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { final book = Resource('books', null, toOne: {'publisher': Identifier('companies', '123')}); - final r = await client.createResource(book); + final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); @@ -113,7 +114,7 @@ void main() async { final book = Resource('books', null, toMany: { 'authors': [Identifier('people', '123')] }); - final r = await client.createResource(book); + final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); @@ -125,8 +126,8 @@ void main() async { }); test('409 when the resource type does not match collection', () async { - final r = await JsonApiClient(server).createResourceAt( - design.collection('fruits'), Resource('cucumbers', null)); + final r = await client.createResourceAt( + routing.collection('fruits'), Resource('cucumbers', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 409); @@ -139,8 +140,8 @@ void main() async { test('409 when the resource with this id already exists', () async { final apple = Resource('apples', '123'); - await client.createResource(apple); - final r = await client.createResource(apple); + await routingClient.createResource(apple); + final r = await routingClient.createResource(apple); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 409); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 44f6ae31..4fe74d89 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -1,43 +1,45 @@ import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - JsonApiClient client; JsonApiServer server; + JsonApiClient client; + RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); - await seedResources(client); + await seedResources(routingClient); }); test('successful', () async { - final r = await client.deleteResource('books', '1'); + final r = await routingClient.deleteResource('books', '1'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.isSuccessful, isFalse); expect(r1.statusCode, 404); }); test('404 on collecton', () async { - final r = await client.deleteResource('unicorns', '42'); + final r = await routingClient.deleteResource('unicorns', '42'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); @@ -49,7 +51,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.deleteResource('books', '42'); + final r = await routingClient.deleteResource('books', '42'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index fba24707..4b08dc75 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -1,33 +1,35 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - JsonApiClient client; JsonApiServer server; + JsonApiClient client; + RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); - await seedResources(client); + await seedResources(routingClient); }); group('To-one', () { test('200 OK', () async { - final r = await client.fetchToOne('books', '1', 'publisher'); + final r = await routingClient.fetchToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().type, 'companies'); @@ -35,7 +37,7 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchToOne('unicorns', '1', 'publisher'); + final r = await routingClient.fetchToOne('unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -44,7 +46,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchToOne('books', '42', 'publisher'); + final r = await routingClient.fetchToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -53,7 +55,7 @@ void main() async { }); test('404 on relationship', () async { - final r = await client.fetchToOne('books', '1', 'owner'); + final r = await routingClient.fetchToOne('books', '1', 'owner'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -65,7 +67,7 @@ void main() async { group('To-many', () { test('200 OK', () async { - final r = await client.fetchToMany('books', '1', 'authors'); + final r = await routingClient.fetchToMany('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -73,7 +75,7 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchToMany('unicorns', '1', 'athors'); + final r = await routingClient.fetchToMany('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -82,7 +84,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchToMany('books', '42', 'authors'); + final r = await routingClient.fetchToMany('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -91,7 +93,7 @@ void main() async { }); test('404 on relationship', () async { - final r = await client.fetchToMany('books', '1', 'readers'); + final r = await routingClient.fetchToMany('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -103,7 +105,8 @@ void main() async { group('Generc', () { test('200 OK to-one', () async { - final r = await client.fetchRelationship('books', '1', 'publisher'); + final r = + await routingClient.fetchRelationship('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); final rel = r.data; @@ -116,7 +119,7 @@ void main() async { }); test('200 OK to-many', () async { - final r = await client.fetchRelationship('books', '1', 'authors'); + final r = await routingClient.fetchRelationship('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); final rel = r.data; @@ -132,7 +135,8 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelationship('unicorns', '1', 'athors'); + final r = + await routingClient.fetchRelationship('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -141,7 +145,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchRelationship('books', '42', 'authors'); + final r = await routingClient.fetchRelationship('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -150,7 +154,7 @@ void main() async { }); test('404 on relationship', () async { - final r = await client.fetchRelationship('books', '1', 'readers'); + final r = await routingClient.fetchRelationship('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 11c02075..099c3981 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -3,31 +3,33 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - JsonApiClient client; JsonApiServer server; + JsonApiClient client; + RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); - await seedResources(client); + await seedResources(routingClient); }); group('Primary Resource', () { test('200 OK', () async { - final r = await client.fetchResource('people', '1'); + final r = await routingClient.fetchResource('people', '1'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().id, '1'); @@ -35,7 +37,7 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchResource('unicorns', '1'); + final r = await routingClient.fetchResource('unicorns', '1'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -44,7 +46,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchResource('people', '42'); + final r = await routingClient.fetchResource('people', '42'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -55,7 +57,7 @@ void main() async { group('Primary collections', () { test('200 OK', () async { - final r = await client.fetchCollection('people'); + final r = await routingClient.fetchCollection('people'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 3); @@ -63,7 +65,7 @@ void main() async { }); test('404', () async { - final r = await client.fetchCollection('unicorns'); + final r = await routingClient.fetchCollection('unicorns'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -74,7 +76,7 @@ void main() async { group('Related Resource', () { test('200 OK', () async { - final r = await client.fetchRelatedResource('books', '1', 'publisher'); + final r = await routingClient.fetchRelatedResource('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().type, 'companies'); @@ -82,7 +84,7 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelatedResource('unicorns', '1', 'publisher'); + final r = await routingClient.fetchRelatedResource('unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -91,7 +93,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchRelatedResource('books', '42', 'publisher'); + final r = await routingClient.fetchRelatedResource('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -100,7 +102,7 @@ void main() async { }); test('404 on relationship', () async { - final r = await client.fetchRelatedResource('books', '1', 'owner'); + final r = await routingClient.fetchRelatedResource('books', '1', 'owner'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -112,7 +114,7 @@ void main() async { group('Related Collection', () { test('successful', () async { - final r = await client.fetchRelatedCollection('books', '1', 'authors'); + final r = await routingClient.fetchRelatedCollection('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -120,7 +122,7 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelatedCollection('unicorns', '1', 'athors'); + final r = await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -129,7 +131,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.fetchRelatedCollection('books', '42', 'authors'); + final r = await routingClient.fetchRelatedCollection('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -138,7 +140,7 @@ void main() async { }); test('404 on relationship', () async { - final r = await client.fetchRelatedCollection('books', '1', 'readers'); + final r = await routingClient.fetchRelatedCollection('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index e65f6a45..bf0cdc2a 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -Future seedResources(JsonApiClient client) async { +Future seedResources(RoutingClient client) async { await client.createResource( Resource('people', '1', attributes: {'name': 'Martin Fowler'})); await client.createResource( diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 3dd945b0..3c878193 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -4,42 +4,44 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; void main() async { - JsonApiClient client; JsonApiServer server; + JsonApiClient client; + RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); - await seedResources(client); + await seedResources(routingClient); }); group('Updatng a to-one relationship', () { test('204 No Content', () async { - final r = await client.replaceToOne( + final r = await routingClient.replaceToOne( 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toOne['publisher'].id, '2'); }); test('404 on collection', () async { - final r = await client.replaceToOne( + final r = await routingClient.replaceToOne( 'unicorns', '1', 'breed', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -51,7 +53,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.replaceToOne( + final r = await routingClient.replaceToOne( 'books', '42', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -66,17 +68,17 @@ void main() async { group('Deleting a to-one relationship', () { test('204 No Content', () async { - final r = await client.deleteToOne('books', '1', 'publisher'); + final r = await routingClient.deleteToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toOne['publisher'], isNull); }); test('404 on collection', () async { - final r = await client.deleteToOne('unicorns', '1', 'breed'); + final r = await routingClient.deleteToOne('unicorns', '1', 'breed'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -87,7 +89,7 @@ void main() async { }); test('404 on resource', () async { - final r = await client.deleteToOne('books', '42', 'publisher'); + final r = await routingClient.deleteToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -100,19 +102,19 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { - final r = await client + final r = await routingClient .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 1); expect(r1.data.unwrap().toMany['authors'].first.id, '1'); }); test('404 when collection not found', () async { - final r = await client.replaceToMany( + final r = await routingClient.replaceToMany( 'unicorns', '1', 'breed', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -124,7 +126,7 @@ void main() async { }); test('404 when resource not found', () async { - final r = await client.replaceToMany( + final r = await routingClient.replaceToMany( 'books', '42', 'publisher', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -138,7 +140,7 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { - final r = await client.addToRelationship( + final r = await routingClient.addToRelationship( 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); @@ -146,12 +148,12 @@ void main() async { expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '3'); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 3); }); test('successfully adding an existing identifier', () async { - final r = await client.addToRelationship( + final r = await routingClient.addToRelationship( 'books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); @@ -159,12 +161,12 @@ void main() async { expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '2'); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 2); }); test('404 when collection not found', () async { - final r = await client.addToRelationship( + final r = await routingClient.addToRelationship( 'unicorns', '1', 'breed', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -176,7 +178,7 @@ void main() async { }); test('404 when resource not found', () async { - final r = await client.addToRelationship( + final r = await routingClient.addToRelationship( 'books', '42', 'publisher', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -188,7 +190,7 @@ void main() async { }); test('404 when relationship not found', () async { - final r = await client.addToRelationship( + final r = await routingClient.addToRelationship( 'books', '1', 'sellers', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -203,19 +205,19 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { - final r = await client.deleteFromToMany( + final r = await routingClient.deleteFromToMany( 'books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 1); expect(r.data.unwrap().first.id, '2'); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 1); }); test('successfully deleting a non-present identifier', () async { - final r = await client.deleteFromToMany( + final r = await routingClient.deleteFromToMany( 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); @@ -223,12 +225,12 @@ void main() async { expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '2'); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 2); }); test('404 when collection not found', () async { - final r = await client.deleteFromToMany( + final r = await routingClient.deleteFromToMany( 'unicorns', '1', 'breed', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); @@ -240,7 +242,7 @@ void main() async { }); test('404 when resource not found', () async { - final r = await client.deleteFromToMany( + final r = await routingClient.deleteFromToMany( 'books', '42', 'publisher', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index f0012c8f..0fabe12a 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -4,31 +4,33 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import '../../helper/expect_resources_equal.dart'; import 'seed_resources.dart'; void main() async { - JsonApiClient client; JsonApiServer server; + JsonApiClient client; + RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final design = UriDesign.standard(base); + final routing = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(design, RepositoryController(repository)); - client = JsonApiClient(server, uriFactory: design); + server = JsonApiServer(routing, RepositoryController(repository)); + client = JsonApiClient(server); + routingClient = RoutingClient(client, routing); - await seedResources(client); + await seedResources(routingClient); }); test('200 OK', () async { - final r = await client.updateResource(Resource('books', '1', attributes: { + final r = await routingClient.updateResource(Resource('books', '1', attributes: { 'title': 'Refactoring. Improving the Design of Existing Code', 'pages': 448 }, toOne: { @@ -49,19 +51,19 @@ void main() async { expect(r.data.unwrap().toMany['reviewers'], equals([Identifier('people', '2')])); - final r1 = await client.fetchResource('books', '1'); + final r1 = await routingClient.fetchResource('books', '1'); expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); }); test('204 No Content', () async { - final r = await client.updateResource(Resource('books', '1')); + final r = await routingClient.updateResource(Resource('books', '1')); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); }); test('404 on the target resource', () async { - final r = await client.updateResource(Resource('books', '42')); + final r = await routingClient.updateResource(Resource('books', '42')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -73,7 +75,7 @@ void main() async { test('409 when the resource type does not match the collection', () async { final r = await client.updateResourceAt( - design.resource('people', '1'), Resource('books', '1')); + routing.resource('people', '1'), Resource('books', '1')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 409); expect(r.data, isNull); diff --git a/test/unit/client/request_document_factory_test.dart b/test/unit/client/request_document_factory_test.dart deleted file mode 100644 index 03b88b51..00000000 --- a/test/unit/client/request_document_factory_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('Generated documents contain no links object', () { - final doc = - RequestDocumentFactory().resourceDocument(Resource('apples', null)); - expect(doc.data.links, isNull); - }); -} diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index 8b13e914..dbd38024 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -3,18 +3,18 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/uri_design.dart'; +import 'package:json_api/routing.dart'; import 'package:test/test.dart'; void main() { - final url = UriDesign.standard(Uri.parse('http://example.com')); + final routing = StandardRouting(Uri.parse('http://example.com')); final server = - JsonApiServer(url, RepositoryController(InMemoryRepository({}))); + JsonApiServer(routing, RepositoryController(InMemoryRepository({}))); group('JsonApiServer', () { test('returns `bad request` on incomplete relationship', () async { final rq = HttpRequest( - 'PATCH', url.relationship('books', '1', 'author'), + 'PATCH', routing.relationship('books', '1', 'author'), body: '{}'); final rs = await server(rq); expect(rs.statusCode, 400); @@ -26,7 +26,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON', () async { final rq = - HttpRequest('POST', url.collection('books'), body: '"ololo"abc'); + HttpRequest('POST', routing.collection('books'), body: '"ololo"abc'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -38,7 +38,7 @@ void main() { test('returns `bad request` when payload is not a valid JSON:API object', () async { final rq = - HttpRequest('POST', url.collection('books'), body: '"oops"'); + HttpRequest('POST', routing.collection('books'), body: '"oops"'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -50,7 +50,7 @@ void main() { test('returns `bad request` when payload violates JSON:API', () async { final rq = - HttpRequest('POST', url.collection('books'), body: '{"data": {}}'); + HttpRequest('POST', routing.collection('books'), body: '{"data": {}}'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; @@ -70,7 +70,7 @@ void main() { }); test('returns `method not allowed` for resource collection', () async { - final rq = HttpRequest('DELETE', url.collection('books')); + final rq = HttpRequest('DELETE', routing.collection('books')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'GET, POST'); @@ -81,7 +81,7 @@ void main() { }); test('returns `method not allowed` for resource', () async { - final rq = HttpRequest('POST', url.resource('books', '1')); + final rq = HttpRequest('POST', routing.resource('books', '1')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'DELETE, GET, PATCH'); @@ -92,7 +92,7 @@ void main() { }); test('returns `method not allowed` for related', () async { - final rq = HttpRequest('POST', url.related('books', '1', 'author')); + final rq = HttpRequest('POST', routing.related('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'GET'); @@ -104,7 +104,7 @@ void main() { test('returns `method not allowed` for relationship', () async { final rq = - HttpRequest('PUT', url.relationship('books', '1', 'author')); + HttpRequest('PUT', routing.relationship('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST'); From 1b4139e00712b686052053db42919b41f1d3c3be Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 17 Feb 2020 21:31:04 -0800 Subject: [PATCH 23/99] wip --- lib/src/server/json_api_response.dart | 30 ++--- lib/src/server/json_api_server.dart | 16 +-- lib/src/server/repository_controller.dart | 5 +- lib/src/server/response_document_factory.dart | 105 ++++++++++-------- lib/src/server/target.dart | 11 +- 5 files changed, 83 insertions(+), 84 deletions(-) diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 709b9e0e..4a7ad5f6 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,13 +1,13 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response_document_factory.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/response_document_factory.dart'; abstract class JsonApiResponse { final int statusCode; const JsonApiResponse(this.statusCode); - Document buildDocument(ResponseDocumentFactory factory, Uri self); + void buildDocument(ResponseDocumentFactory factory, Uri self); Map buildHeaders(Routing routing); @@ -68,9 +68,7 @@ class _NoContent extends JsonApiResponse { const _NoContent() : super(204); @override - Document buildDocument( - ResponseDocumentFactory factory, Uri self) => - null; + Document buildDocument(ResponseDocumentFactory factory, Uri self) => null; @override Map buildHeaders(Routing routing) => {}; @@ -84,8 +82,7 @@ class _Collection extends JsonApiResponse { const _Collection(this.collection, {this.included, this.total}) : super(200); @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeCollectionDocument(self, collection, included: included, total: total); @@ -100,8 +97,7 @@ class _Accepted extends JsonApiResponse { _Accepted(this.resource) : super(202); @override - Document buildDocument( - ResponseDocumentFactory factory, Uri self) => + void buildDocument(ResponseDocumentFactory factory, Uri self) => factory.makeResourceDocument(self, resource); @override @@ -120,7 +116,7 @@ class _Error extends JsonApiResponse { : super(status); @override - Document buildDocument(ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeErrorDocument(errors); @override @@ -134,7 +130,7 @@ class _Meta extends JsonApiResponse { _Meta(this.meta) : super(200); @override - Document buildDocument(ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeMetaDocument(meta); @override @@ -149,8 +145,7 @@ class _Resource extends JsonApiResponse { const _Resource(this.resource, {this.included}) : super(200); @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeResourceDocument(self, resource, included: included); @override @@ -166,8 +161,7 @@ class _ResourceCreated extends JsonApiResponse { } @override - Document buildDocument( - ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeCreatedResourceDocument(resource); @override @@ -184,7 +178,7 @@ class _SeeOther extends JsonApiResponse { _SeeOther(this.type, this.id) : super(303); @override - Document buildDocument(ResponseDocumentFactory builder, Uri self) => null; + void buildDocument(ResponseDocumentFactory builder, Uri self) => null; @override Map buildHeaders(Routing routing) => @@ -201,7 +195,7 @@ class _ToMany extends JsonApiResponse { : super(200); @override - Document buildDocument(ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeToManyDocument(self, collection, type, id, relationship); @override @@ -219,7 +213,7 @@ class _ToOne extends JsonApiResponse { : super(200); @override - Document buildDocument(ResponseDocumentFactory builder, Uri self) => + void buildDocument(ResponseDocumentFactory builder, Uri self) => builder.makeToOneDocument(self, identifier, type, id, relationship); @override diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 3726f5e3..07cd0399 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -11,7 +11,9 @@ class JsonApiServer implements HttpHandler { @override Future call(HttpRequest request) async { final response = await _do(request); - final document = response.buildDocument(_factory, request.uri); + response.buildDocument(_factory, request.uri); + final document = _factory.build(); + return HttpResponse(response.statusCode, body: document == null ? null : jsonEncode(document), headers: response.buildHeaders(_routing)); @@ -24,7 +26,7 @@ class JsonApiServer implements HttpHandler { final Routing _routing; final JsonApiController _controller; final ResponseDocumentFactory _factory; - + Future _do(HttpRequest request) async { try { return await RequestDispatcher(_controller).dispatch(request); @@ -57,7 +59,7 @@ class RequestDispatcher { return _controller.createResource(request, target, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); default: - return _allow(['GET', 'POST']); + return _methodNotAllowed(['GET', 'POST']); } } else if (s.length == 2) { final target = ResourceTarget(s[0], s[1]); @@ -70,7 +72,7 @@ class RequestDispatcher { return _controller.updateResource(request, target, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); default: - return _allow(['DELETE', 'GET', 'PATCH']); + return _methodNotAllowed(['DELETE', 'GET', 'PATCH']); } } else if (s.length == 3) { switch (request.method) { @@ -78,7 +80,7 @@ class RequestDispatcher { return _controller.fetchRelated( request, RelatedTarget(s[0], s[1], s[2])); default: - return _allow(['GET']); + return _methodNotAllowed(['GET']); } } else if (s.length == 4 && s[2] == 'relationships') { final target = RelationshipTarget(s[0], s[1], s[3]); @@ -106,7 +108,7 @@ class RequestDispatcher { return _controller.addToRelationship(request, target, ToMany.fromJson(jsonDecode(request.body)).unwrap()); default: - return _allow(['DELETE', 'GET', 'PATCH', 'POST']); + return _methodNotAllowed(['DELETE', 'GET', 'PATCH', 'POST']); } } return JsonApiResponse.notFound([ @@ -121,7 +123,7 @@ class RequestDispatcher { final JsonApiController _controller; - JsonApiResponse _allow(Iterable allow) => + JsonApiResponse _methodNotAllowed(Iterable allow) => JsonApiResponse.methodNotAllowed([ JsonApiError( status: '405', diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 836fd50e..b1478220 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -6,7 +6,6 @@ import 'package:json_api/query.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/src/server/target.dart'; typedef UriReader = FutureOr Function(R request); @@ -97,8 +96,8 @@ class RepositoryController implements JsonApiController { _do(() async { final resource = await _repo.get(target.type, target.id); if (resource.toOne.containsKey(target.relationship)) { - final identifier = resource.toOne[target.relationship]; - return JsonApiResponse.resource(await _getByIdentifier(identifier)); + return JsonApiResponse.resource( + await _getByIdentifier(resource.toOne[target.relationship])); } if (resource.toMany.containsKey(target.relationship)) { final related = []; diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index c1f40b02..bf801516 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -1,32 +1,34 @@ import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/query/page.dart'; import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/routing.dart'; class ResponseDocumentFactory { /// A document containing a list of errors - Document makeErrorDocument(Iterable errors) => - Document.error(errors, api: _api); + Document makeErrorDocument(Iterable errors) { + return _document = Document.error(errors, api: _api); + } /// A document containing a collection of resources - Document makeCollectionDocument( - Uri self, Iterable collection, - {int total, Iterable included}) => - Document( - ResourceCollectionData(collection.map(_resourceObject), - links: {'self': Link(self), ..._navigation(self, total)}, - included: included?.map(_resourceObject)), - api: _api); + Document makeCollectionDocument(Uri self, Iterable collection, + {int total, Iterable included}) { + return _document = Document( + ResourceCollectionData(collection.map(_resourceObject), + links: {'self': Link(self), ..._navigation(self, total)}, + included: included?.map(_resourceObject)), + api: _api); + } /// A document containing a single resource - Document makeResourceDocument(Uri self, Resource resource, - {Iterable included}) => - Document( - ResourceData(_resourceObject(resource), - links: {'self': Link(self)}, - included: included?.map(_resourceObject)), - api: _api); + Document makeResourceDocument(Uri self, Resource resource, + {Iterable included}) { + return _document = Document( + ResourceData(_resourceObject(resource), + links: {'self': Link(self)}, + included: included?.map(_resourceObject)), + api: _api); + } /// A document containing a single (primary) resource which has been created /// on the server. The difference with [makeResourceDocument] is that this @@ -38,43 +40,47 @@ class ResponseDocumentFactory { /// > the self member MUST match the value of the Location header. /// /// See https://jsonapi.org/format/#crud-creating-responses-201 - Document makeCreatedResourceDocument(Resource resource) => - makeResourceDocument( - _urlFactory.resource(resource.type, resource.id), resource); + Document makeCreatedResourceDocument(Resource resource) { + return _document = makeResourceDocument( + _urlFactory.resource(resource.type, resource.id), resource); + } /// 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), - links: { - 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) - }, - ), - api: _api); + Document makeToManyDocument(Uri self, Iterable identifiers, + String type, String id, String relationship) { + return _document = Document( + ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(self), + 'related': Link(_urlFactory.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), - links: { - 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) - }, - ), - api: _api); + Document makeToOneDocument(Uri self, Identifier identifier, String type, + String id, String relationship) { + return _document = Document( + ToOne( + nullable(IdentifierObject.fromIdentifier)(identifier), + links: { + 'self': Link(self), + 'related': Link(_urlFactory.related(type, id, relationship)) + }, + ), + api: _api); + } /// A document containing just a meta member - Document makeMetaDocument(Map meta) => - Document.empty(meta, api: _api); + Document makeMetaDocument(Map meta) { + return _document = Document.empty(meta, api: _api); + } + + Document build() { + return _document; + } ResponseDocumentFactory(this._urlFactory, {Api api, Pagination pagination}) : _api = api, @@ -83,6 +89,7 @@ class ResponseDocumentFactory { final Routing _urlFactory; final Pagination _pagination; final Api _api; + Document _document; ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index 98b37ca2..3ba19413 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -1,8 +1,5 @@ - -abstract class Target {} - /// The target of a URI referring a resource collection -class CollectionTarget implements Target { +class CollectionTarget { /// Resource type final String type; @@ -10,7 +7,7 @@ class CollectionTarget implements Target { } /// The target of a URI referring to a single resource -class ResourceTarget implements Target { +class ResourceTarget { /// Resource type final String type; @@ -21,7 +18,7 @@ class ResourceTarget implements Target { } /// The target of a URI referring a related resource or collection -class RelatedTarget implements Target { +class RelatedTarget { /// Resource type final String type; @@ -35,7 +32,7 @@ class RelatedTarget implements Target { } /// The target of a URI referring a relationship -class RelationshipTarget implements Target { +class RelationshipTarget { /// Resource type final String type; From 939e7003304355c471baacc492745c6a4e3c6d8d Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 18 Feb 2020 00:14:00 -0800 Subject: [PATCH 24/99] wip --- example/client.dart | 2 +- lib/server.dart | 2 - lib/src/client/json_api_client.dart | 12 +- lib/src/client/routing_client.dart | 8 +- lib/src/document/identifier.dart | 12 +- lib/src/document/identifier_object.dart | 6 +- lib/src/document/relationship.dart | 12 +- lib/src/document/resource.dart | 14 +- lib/src/document/resource_object.dart | 4 +- lib/src/query/include.dart | 3 + lib/src/server/json_api_controller.dart | 100 ------- lib/src/server/json_api_request.dart | 252 ++++++++++++++++++ lib/src/server/json_api_response.dart | 10 +- lib/src/server/json_api_server.dart | 115 +------- lib/src/server/repository_controller.dart | 145 +++++----- lib/src/server/request_factory.dart | 101 +++++++ lib/src/server/response_document_factory.dart | 4 +- lib/src/server/target.dart | 46 ---- test/e2e/client_server_interaction_test.dart | 2 +- test/functional/compound_document_test.dart | 12 +- .../crud/creating_resources_test.dart | 4 +- test/functional/crud/seed_resources.dart | 4 +- .../crud/updating_relationships_test.dart | 30 +-- .../crud/updating_resources_test.dart | 8 +- test/unit/document/identifier_test.dart | 2 +- test/unit/document/resource_test.dart | 2 +- 26 files changed, 503 insertions(+), 409 deletions(-) delete mode 100644 lib/src/server/json_api_controller.dart create mode 100644 lib/src/server/json_api_request.dart create mode 100644 lib/src/server/request_factory.dart delete mode 100644 lib/src/server/target.dart diff --git a/example/client.dart b/example/client.dart index 2e9f003c..06b8437d 100644 --- a/example/client.dart +++ b/example/client.dart @@ -32,7 +32,7 @@ void main() async { await client.createResource(Resource('books', '2', attributes: { 'title': 'Refactoring' }, toMany: { - 'authors': [Identifier('writers', '1')] + 'authors': [Identifiers('writers', '1')] })); /// Fetch the book, including its authors diff --git a/lib/server.dart b/lib/server.dart index b9481e7c..f769b01f 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,11 +5,9 @@ library server; export 'package:json_api/src/server/dart_server_handler.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; -export 'package:json_api/src/server/json_api_controller.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; export 'package:json_api/src/server/response_document_factory.dart'; -export 'package:json_api/src/server/target.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 971bf71a..24e4ca02 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -82,7 +82,7 @@ class JsonApiClient { /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOneAt(Uri uri, Identifier identifier, + Future> replaceToOneAt(Uri uri, Identifiers identifier, {Map headers}) => _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); @@ -96,7 +96,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> deleteFromToManyAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -109,7 +109,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> replaceToManyAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -117,7 +117,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> addToRelationshipAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -131,10 +131,10 @@ class JsonApiClient { Document _resourceDoc(Resource resource) => Document(ResourceData.fromResource(resource), api: _api); - Document _toManyDoc(Iterable identifiers) => + Document _toManyDoc(Iterable identifiers) => Document(ToMany.fromIdentifiers(identifiers), api: _api); - Document _toOneDoc(Identifier identifier) => + Document _toOneDoc(Identifiers identifier) => Document(ToOne.fromIdentifier(identifier), api: _api); HttpRequest _get(Uri uri, Map headers, diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index c66b382c..cb033492 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -74,7 +74,7 @@ class RoutingClient { /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceToOne( - String type, String id, String relationship, Identifier identifier, + String type, String id, String relationship, Identifiers identifier, {Map headers}) => _client.replaceToOneAt(_relationship(type, id, relationship), identifier, headers: headers); @@ -88,7 +88,7 @@ class RoutingClient { /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToManyAt( _relationship(type, id, relationship), identifiers, @@ -96,7 +96,7 @@ class RoutingClient { /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.replaceToManyAt( _relationship(type, id, relationship), identifiers, @@ -104,7 +104,7 @@ class RoutingClient { /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationshipAt( _relationship(type, id, relationship), identifiers, diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 82b8b848..8e0224cd 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -6,7 +6,7 @@ import 'package:json_api/src/document/document_exception.dart'; /// Together with [Resource] forms the core of the Document model. /// Identifiers are passed between the server and the client in the form /// of [IdentifierObject]s. -class Identifier { +class Identifiers { /// Resource type final String type; @@ -14,18 +14,18 @@ class Identifier { final String id; /// Neither [type] nor [id] can be null or empty. - Identifier(this.type, this.id) { + Identifiers(this.type, this.id) { DocumentException.throwIfNull(id, "Identifier 'id' must not be null"); DocumentException.throwIfNull(type, "Identifier 'type' must not be null"); } - static Identifier of(Resource resource) => - Identifier(resource.type, resource.id); + static Identifiers of(Resource resource) => + Identifiers(resource.type, resource.id); /// Returns true if the two identifiers have the same [type] and [id] - bool equals(Identifier other) => + bool equals(Identifiers other) => other != null && - other.runtimeType == Identifier && + other.runtimeType == Identifiers && other.type == type && other.id == id; diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index a48f1195..f2a84340 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,7 +1,7 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; -/// [IdentifierObject] is a JSON representation of the [Identifier]. +/// [IdentifierObject] is a JSON representation of the [Identifiers]. /// It carries all JSON-related logic and the Meta-data. class IdentifierObject { /// Resource type @@ -21,7 +21,7 @@ class IdentifierObject { ArgumentError.checkNotNull(id); } - static IdentifierObject fromIdentifier(Identifier identifier, + static IdentifierObject fromIdentifier(Identifiers identifier, {Map meta}) => IdentifierObject(identifier.type, identifier.id, meta: meta); @@ -32,7 +32,7 @@ class IdentifierObject { throw DocumentException('A JSON:API identifier must be a JSON object'); } - Identifier unwrap() => Identifier(type, id); + Identifiers unwrap() => Identifiers(type, id); Map toJson() => { 'type': type, diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 879d28b3..7f0dbf93 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -75,7 +75,7 @@ class ToOne extends Relationship { : linkage = null, super(links: links); - static ToOne fromIdentifier(Identifier identifier) => + static ToOne fromIdentifier(Identifiers identifier) => ToOne(nullable(IdentifierObject.fromIdentifier)(identifier)); static ToOne fromJson(Object json) { @@ -97,12 +97,12 @@ class ToOne extends Relationship { ...{'data': linkage} }; - /// Converts to [Identifier]. + /// Converts to [Identifiers]. /// For empty relationships returns null. - Identifier unwrap() => linkage?.unwrap(); + Identifiers unwrap() => linkage?.unwrap(); /// Same as [unwrap()] - Identifier get identifier => unwrap(); + Identifiers get identifier => unwrap(); } /// Relationship to-many @@ -119,7 +119,7 @@ class ToMany extends Relationship { : linkage = List.unmodifiable(linkage), super(included: included, links: links); - static ToMany fromIdentifiers(Iterable identifiers) => + static ToMany fromIdentifiers(Iterable identifiers) => ToMany(identifiers.map(IdentifierObject.fromIdentifier)); static ToMany fromJson(Object json) { @@ -145,5 +145,5 @@ class ToMany extends Relationship { /// Converts to Iterable. /// For empty relationships returns an empty List. - Iterable unwrap() => linkage.map((_) => _.unwrap()); + Iterable unwrap() => linkage.map((_) => _.unwrap()); } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index cd50ad16..38f3b422 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -3,7 +3,7 @@ import 'package:json_api/src/document/identifier.dart'; /// Resource /// -/// Together with [Identifier] forms the core of the Document model. +/// Together with [Identifiers] forms the core of the Document model. /// Resources are passed between the server and the client in the form /// of [ResourceObject]s. class Resource { @@ -19,10 +19,10 @@ class Resource { final Map attributes; /// Unmodifiable map of to-one relationships - final Map toOne; + final Map toOne; /// Unmodifiable map of to-many relationships - final Map> toMany; + final Map> toMany; /// Resource type and id combined String get key => '$type:$id'; @@ -35,8 +35,8 @@ class Resource { /// The [id] may be null for the resources to be created on the server. Resource(this.type, this.id, {Map attributes, - Map toOne, - Map> toMany}) + Map toOne, + Map> toMany}) : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable( @@ -49,7 +49,7 @@ class Resource { class NewResource extends Resource { NewResource(String type, {Map attributes, - Map toOne, - Map> toMany}) + Map toOne, + Map> toMany}) : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); } diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 738df40f..3f83d911 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -83,8 +83,8 @@ class ResourceObject { /// without `data` member. In this case the original [Resource] can not be /// recovered and this method will throw a [StateError]. Resource unwrap() { - final toOne = {}; - final toMany = >{}; + final toOne = {}; + final toMany = >{}; final incomplete = {}; (relationships ?? {}).forEach((name, rel) { if (rel is ToOne) { diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index 12ea56d4..a8b749f6 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -20,6 +20,9 @@ class Include extends QueryParameters with IterableMixin { static Include fromUri(Uri uri) => Include( (uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? [])); + static Include fromQueryParameters(Map> parameters) => + Include((parameters['include']?.expand((_) => _.split(',')) ?? [])); + @override Iterator get iterator => _resources.iterator; diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart deleted file mode 100644 index c8eee813..00000000 --- a/lib/src/server/json_api_controller.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/http.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/target.dart'; - -/// The Controller consolidates all possible requests a JSON:API server -/// may handle. Each of the methods is expected to return a -/// [JsonApiResponse] object or a [Future] of it. -/// -/// The response may either be a successful or an error. -abstract class JsonApiController { - /// Finds an returns a primary resource collection. - /// Use [JsonApiResponse.collection] to return a successful response. - /// Use [JsonApiResponse.notFound] if the collection does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchCollection( - HttpRequest request, CollectionTarget target); - - /// Finds an returns a primary resource. - /// Use [JsonApiResponse.resource] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchResource( - HttpRequest request, ResourceTarget target); - - /// Finds an returns a related resource or a collection of related resources. - /// Use [JsonApiResponse.relatedResource] or [JsonApiResponse.relatedCollection] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchRelated( - HttpRequest request, RelatedTarget target); - - /// Finds an returns a relationship of a primary resource. - /// Use [JsonApiResponse.toOne] or [JsonApiResponse.toMany] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. - /// - /// See https://jsonapi.org/format/#fetching-relationships - FutureOr fetchRelationship( - HttpRequest request, RelationshipTarget target); - - /// Deletes the resource. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. - /// Use [JsonApiResponse.notFound] if the resource does not exist. - /// - /// See https://jsonapi.org/format/#crud-deleting - FutureOr deleteResource( - HttpRequest request, ResourceTarget target); - - /// Creates a new resource in the collection. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. - /// Use [JsonApiResponse.notFound] if the collection does not exist. - /// Use [JsonApiResponse.forbidden] if the server does not support this operation. - /// Use [JsonApiResponse.conflict] if the resource already exists or the collection - /// does not match the [resource] type.. - /// - /// See https://jsonapi.org/format/#crud-creating - FutureOr createResource( - HttpRequest request, CollectionTarget target, Resource resource); - - /// Updates the resource. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. - /// - /// See https://jsonapi.org/format/#crud-updating - FutureOr updateResource( - HttpRequest request, ResourceTarget target, Resource resource); - - /// Replaces the to-one relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toOne]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - FutureOr replaceToOne( - HttpRequest request, RelationshipTarget target, Identifier identifier); - - /// Replaces the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr replaceToMany(HttpRequest request, - RelationshipTarget target, Iterable identifiers); - - /// Removes the given identifiers from the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr deleteFromRelationship(HttpRequest request, - RelationshipTarget target, Iterable identifiers); - - /// Adds the given identifiers to the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr addToRelationship(HttpRequest request, - RelationshipTarget target, Iterable identifiers); -} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart new file mode 100644 index 00000000..0fcd4ee3 --- /dev/null +++ b/lib/src/server/json_api_request.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +/// The Controller consolidates all possible requests a JSON:API server +/// may handle. Each of the methods is expected to return a +/// [JsonApiResponse] object or a [Future] of it. +/// +/// The response may either be a successful or an error. +abstract class JsonApiController { + /// Finds an returns a primary resource collection. + /// Use [JsonApiResponse.collection] to return a successful response. + /// Use [JsonApiResponse.notFound] if the collection does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchCollection(FetchCollection request); + + /// Finds an returns a primary resource. + /// Use [JsonApiResponse.resource] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchResource(FetchResource request); + + /// Finds an returns a related resource or a collection of related resources. + /// Use [JsonApiResponse.relatedResource] or [JsonApiResponse.relatedCollection] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. + /// + /// See https://jsonapi.org/format/#fetching-resources + FutureOr fetchRelated(FetchRelated request); + + /// Finds an returns a relationship of a primary resource. + /// Use [JsonApiResponse.toOne] or [JsonApiResponse.toMany] to return a successful response. + /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. + /// + /// See https://jsonapi.org/format/#fetching-relationships + FutureOr fetchRelationship(FetchRelationship request); + + /// Deletes the resource. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// Use [JsonApiResponse.notFound] if the resource does not exist. + /// + /// See https://jsonapi.org/format/#crud-deleting + FutureOr deleteResource(DeleteResource request); + + /// Creates a new resource in the collection. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// Use [JsonApiResponse.notFound] if the collection does not exist. + /// Use [JsonApiResponse.forbidden] if the server does not support this operation. + /// Use [JsonApiResponse.conflict] if the resource already exists or the collection + /// does not match the [resource] type.. + /// + /// See https://jsonapi.org/format/#crud-creating + FutureOr createResource(CreateResource request); + + /// Updates the resource. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// + /// See https://jsonapi.org/format/#crud-updating + FutureOr updateResource(UpdateResourceRequest request); + + /// Replaces the to-one relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toOne]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-one-relationships + FutureOr replaceToOne(ReplaceToOne request); + + /// Replaces the to-many relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr replaceToMany(ReplaceToMany request); + + /// Removes the given identifiers from the to-many relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr deleteFromRelationship( + DeleteFromRelationship request); + + /// Adds the given identifiers to the to-many relationship. + /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + FutureOr addToRelationship(AddToRelationship request); +} + + + +abstract class JsonApiRequest { + FutureOr call(JsonApiController c); +} + +abstract class QueryParameters { + Map> get queryParameters; + + Include get include => Include.fromQueryParameters(queryParameters); +} + +class PredefinedResponse implements JsonApiRequest { + final JsonApiResponse response; + + PredefinedResponse(this.response); + + @override + FutureOr call(JsonApiController c) => response; +} + +class FetchCollection with QueryParameters implements JsonApiRequest { + final String type; + + @override + final Map> queryParameters; + + FetchCollection(this.queryParameters, this.type); + + @override + FutureOr call(JsonApiController c) => + c.fetchCollection(this); +} + +class CreateResource implements JsonApiRequest { + final String type; + + final Resource resource; + + CreateResource(this.type, this.resource); + + @override + FutureOr call(JsonApiController c) => c.createResource(this); +} + +class UpdateResourceRequest implements JsonApiRequest { + final String type; + final String id; + + final Resource resource; + + UpdateResourceRequest(this.type, this.id, this.resource); + + @override + FutureOr call(JsonApiController c) => c.updateResource(this); +} + +class DeleteResource implements JsonApiRequest { + final String type; + + final String id; + + DeleteResource(this.type, this.id); + + @override + FutureOr call(JsonApiController c) => c.deleteResource(this); +} + +class FetchResource with QueryParameters implements JsonApiRequest { + final String type; + final String id; + + @override + final Map> queryParameters; + + FetchResource(this.type, this.id, this.queryParameters); + + @override + FutureOr call(JsonApiController c) => c.fetchResource(this); +} + +class FetchRelated with QueryParameters implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + @override + final Map> queryParameters; + + FetchRelated(this.type, this.id, this.relationship, this.queryParameters); + + @override + FutureOr call(JsonApiController c) => c.fetchRelated(this); +} + +class FetchRelationship with QueryParameters implements JsonApiRequest { + final String type; + final String id; + final String relationship; + + @override + final Map> queryParameters; + + FetchRelationship( + this.type, this.id, this.relationship, this.queryParameters); + + @override + FutureOr call(JsonApiController c) => + c.fetchRelationship(this); +} + +class DeleteFromRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + DeleteFromRelationship( + this.type, this.id, this.relationship, this.identifiers); + + @override + FutureOr call(JsonApiController c) => + c.deleteFromRelationship(this); +} + +class ReplaceToOne implements JsonApiRequest { + final String type; + final String id; + final String relationship; + final Identifiers identifier; + + ReplaceToOne(this.type, this.id, this.relationship, this.identifier); + + @override + FutureOr call(JsonApiController c) => c.replaceToOne(this); +} + +class ReplaceToMany implements JsonApiRequest { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); + + @override + FutureOr call(JsonApiController c) => c.replaceToMany(this); +} + +class AddToRelationship implements JsonApiRequest { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + AddToRelationship(this.type, this.id, this.relationship, this.identifiers); + + @override + FutureOr call(JsonApiController c) => + c.addToRelationship(this); +} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 4a7ad5f6..e4cefbe9 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -32,11 +32,11 @@ abstract class JsonApiResponse { _SeeOther(type, id); static JsonApiResponse toMany(String type, String id, String relationship, - Iterable identifiers) => + Iterable identifiers) => _ToMany(type, id, relationship, identifiers); - static JsonApiResponse toOne( - String type, String id, String relationship, Identifier identifier) => + static JsonApiResponse toOne(String type, String id, String relationship, + Identifiers identifier) => _ToOne(type, id, relationship, identifier); /// Generic error response @@ -186,7 +186,7 @@ class _SeeOther extends JsonApiResponse { } class _ToMany extends JsonApiResponse { - final Iterable collection; + final Iterable collection; final String type; final String id; final String relationship; @@ -207,7 +207,7 @@ class _ToOne extends JsonApiResponse { final String type; final String id; final String relationship; - final Identifier identifier; + final Identifiers identifier; const _ToOne(this.type, this.id, this.relationship, this.identifier) : super(200); diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 07cd0399..17989a0b 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,19 +1,24 @@ import 'dart:async'; import 'dart:convert'; -import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/target.dart'; +import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/request_factory.dart'; class JsonApiServer implements HttpHandler { @override Future call(HttpRequest request) async { - final response = await _do(request); + final rq = JsonApiRequestFactory().getJsonApiRequest(request); + // Implementation-specific logic (e.g. auth) goes here + final response = await rq.call(_controller); + + // Build response Document response.buildDocument(_factory, request.uri); final document = _factory.build(); + // Any response post-processing goes here return HttpResponse(response.statusCode, body: document == null ? null : jsonEncode(document), headers: response.buildHeaders(_routing)); @@ -26,108 +31,4 @@ class JsonApiServer implements HttpHandler { final Routing _routing; final JsonApiController _controller; final ResponseDocumentFactory _factory; - - Future _do(HttpRequest request) async { - try { - return await RequestDispatcher(_controller).dispatch(request); - } on JsonApiResponse catch (e) { - return e; - } on FormatException catch (e) { - return JsonApiResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Invalid JSON. ${e.message}') - ]); - } on DocumentException catch (e) { - return JsonApiResponse.badRequest([ - JsonApiError(status: '400', title: 'Bad request', detail: e.message) - ]); - } - } -} - -class RequestDispatcher { - FutureOr dispatch(HttpRequest request) async { - final s = request.uri.pathSegments; - if (s.length == 1) { - final target = CollectionTarget(s[0]); - switch (request.method) { - case 'GET': - return _controller.fetchCollection(request, target); - case 'POST': - return _controller.createResource(request, target, - ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _methodNotAllowed(['GET', 'POST']); - } - } else if (s.length == 2) { - final target = ResourceTarget(s[0], s[1]); - switch (request.method) { - case 'DELETE': - return _controller.deleteResource(request, target); - case 'GET': - return _controller.fetchResource(request, target); - case 'PATCH': - return _controller.updateResource(request, target, - ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _methodNotAllowed(['DELETE', 'GET', 'PATCH']); - } - } else if (s.length == 3) { - switch (request.method) { - case 'GET': - return _controller.fetchRelated( - request, RelatedTarget(s[0], s[1], s[2])); - default: - return _methodNotAllowed(['GET']); - } - } else if (s.length == 4 && s[2] == 'relationships') { - final target = RelationshipTarget(s[0], s[1], s[3]); - switch (request.method) { - case 'DELETE': - return _controller.deleteFromRelationship(request, target, - ToMany.fromJson(jsonDecode(request.body)).unwrap()); - case 'GET': - return _controller.fetchRelationship(request, target); - case 'PATCH': - final rel = Relationship.fromJson(jsonDecode(request.body)); - if (rel is ToOne) { - return _controller.replaceToOne(request, target, rel.unwrap()); - } - if (rel is ToMany) { - return _controller.replaceToMany(request, target, rel.unwrap()); - } - return JsonApiResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ]); - case 'POST': - return _controller.addToRelationship(request, target, - ToMany.fromJson(jsonDecode(request.body)).unwrap()); - default: - return _methodNotAllowed(['DELETE', 'GET', 'PATCH', 'POST']); - } - } - return JsonApiResponse.notFound([ - JsonApiError( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ]); - } - - RequestDispatcher(this._controller); - - final JsonApiController _controller; - - JsonApiResponse _methodNotAllowed(Iterable allow) => - JsonApiResponse.methodNotAllowed([ - JsonApiError( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${allow.join(', ')}') - ], allow: allow); } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index b1478220..d536fb04 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,83 +1,75 @@ import 'dart:async'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/json_api_request.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/target.dart'; - -typedef UriReader = FutureOr Function(R request); /// An opinionated implementation of [JsonApiController] class RepositoryController implements JsonApiController { @override - FutureOr addToRelationship(HttpRequest request, - RelationshipTarget target, Iterable identifiers) => + FutureOr addToRelationship(AddToRelationship request) => _do(() async { - final original = await _repo.get(target.type, target.id); - if (!original.toMany.containsKey(target.relationship)) { + final original = await _repo.get(request.type, request.id); + if (!original.toMany.containsKey(request.relationship)) { return JsonApiResponse.notFound([ JsonApiError( status: '404', title: 'Relationship not found', detail: - "There is no to-many relationship '${target.relationship}' in this resource") + "There is no to-many relationship '${request.relationship}' in this resource") ]); } final updated = await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, toMany: { - target.relationship: { - ...original.toMany[target.relationship], - ...identifiers + request.type, + request.id, + Resource(request.type, request.id, toMany: { + request.relationship: { + ...original.toMany[request.relationship], + ...request.identifiers } })); - return JsonApiResponse.toMany(target.type, target.id, - target.relationship, updated.toMany[target.relationship]); + return JsonApiResponse.toMany(request.type, request.id, + request.relationship, updated.toMany[request.relationship]); }); @override - FutureOr createResource( - HttpRequest request, CollectionTarget target, Resource resource) => + FutureOr createResource(CreateResource request) => _do(() async { - final modified = await _repo.create(target.type, resource); + final modified = await _repo.create(request.type, request.resource); if (modified == null) return JsonApiResponse.noContent(); return JsonApiResponse.resourceCreated(modified); }); @override - FutureOr deleteFromRelationship(HttpRequest request, - RelationshipTarget target, Iterable identifiers) => + FutureOr deleteFromRelationship( + DeleteFromRelationship request) => _do(() async { - final original = await _repo.get(target.type, target.id); + final original = await _repo.get(request.type, request.id); final updated = await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, toMany: { - target.relationship: {...original.toMany[target.relationship]} - ..removeAll(identifiers) + request.type, + request.id, + Resource(request.type, request.id, toMany: { + request.relationship: {...original.toMany[request.relationship]} + ..removeAll(request.identifiers) })); - return JsonApiResponse.toMany(target.type, target.id, - target.relationship, updated.toMany[target.relationship]); + return JsonApiResponse.toMany(request.type, request.id, + request.relationship, updated.toMany[request.relationship]); }); @override - FutureOr deleteResource( - HttpRequest request, ResourceTarget target) => + FutureOr deleteResource(DeleteResource request) => _do(() async { - await _repo.delete(target.type, target.id); + await _repo.delete(request.type, request.id); return JsonApiResponse.noContent(); }); @override - FutureOr fetchCollection( - HttpRequest request, CollectionTarget target) => + FutureOr fetchCollection(FetchCollection request) => _do(() async { - final c = await _repo.getCollection(target.type); - final include = Include.fromUri(request.uri); + final c = await _repo.getCollection(request.type); + final include = request.include; final resources = []; for (final resource in c.elements) { @@ -91,46 +83,42 @@ class RepositoryController implements JsonApiController { }); @override - FutureOr fetchRelated( - HttpRequest request, RelatedTarget target) => - _do(() async { - final resource = await _repo.get(target.type, target.id); - if (resource.toOne.containsKey(target.relationship)) { + FutureOr fetchRelated(FetchRelated request) => _do(() async { + final resource = await _repo.get(request.type, request.id); + if (resource.toOne.containsKey(request.relationship)) { return JsonApiResponse.resource( - await _getByIdentifier(resource.toOne[target.relationship])); + await _getByIdentifier(resource.toOne[request.relationship])); } - if (resource.toMany.containsKey(target.relationship)) { + if (resource.toMany.containsKey(request.relationship)) { final related = []; - for (final identifier in resource.toMany[target.relationship]) { + for (final identifier in resource.toMany[request.relationship]) { related.add(await _getByIdentifier(identifier)); } return JsonApiResponse.collection(related); } - return _relationshipNotFound(target.relationship); + return _relationshipNotFound(request.relationship); }); @override - FutureOr fetchRelationship( - HttpRequest request, RelationshipTarget target) => + FutureOr fetchRelationship(FetchRelationship request) => _do(() async { - final resource = await _repo.get(target.type, target.id); - if (resource.toOne.containsKey(target.relationship)) { - return JsonApiResponse.toOne(target.type, target.id, - target.relationship, resource.toOne[target.relationship]); + final resource = await _repo.get(request.type, request.id); + if (resource.toOne.containsKey(request.relationship)) { + return JsonApiResponse.toOne(request.type, request.id, + request.relationship, resource.toOne[request.relationship]); } - if (resource.toMany.containsKey(target.relationship)) { - return JsonApiResponse.toMany(target.type, target.id, - target.relationship, resource.toMany[target.relationship]); + if (resource.toMany.containsKey(request.relationship)) { + return JsonApiResponse.toMany(request.type, request.id, + request.relationship, resource.toMany[request.relationship]); } - return _relationshipNotFound(target.relationship); + return _relationshipNotFound(request.relationship); }); @override - FutureOr fetchResource( - HttpRequest request, ResourceTarget target) => + FutureOr fetchResource(FetchResource request) => _do(() async { - final include = Include.fromUri(request.uri); - final resource = await _repo.get(target.type, target.id); + final include = request.include; + final resource = await _repo.get(request.type, request.id); final resources = []; for (final path in include) { resources.addAll(await _getRelated(resource, path.split('.'))); @@ -140,35 +128,32 @@ class RepositoryController implements JsonApiController { }); @override - FutureOr replaceToMany(HttpRequest request, - RelationshipTarget target, Iterable identifiers) => + FutureOr replaceToMany(ReplaceToMany request) => _do(() async { await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, - toMany: {target.relationship: identifiers})); + request.type, + request.id, + Resource(request.type, request.id, + toMany: {request.relationship: request.identifiers})); return JsonApiResponse.noContent(); }); @override - FutureOr updateResource( - HttpRequest request, ResourceTarget target, Resource resource) => + FutureOr updateResource(UpdateResourceRequest request) => _do(() async { - final modified = await _repo.update(target.type, target.id, resource); + final modified = + await _repo.update(request.type, request.id, request.resource); if (modified == null) return JsonApiResponse.noContent(); return JsonApiResponse.resource(modified); }); @override - FutureOr replaceToOne(HttpRequest request, - RelationshipTarget target, Identifier identifier) => - _do(() async { + FutureOr replaceToOne(ReplaceToOne request) => _do(() async { await _repo.update( - target.type, - target.id, - Resource(target.type, target.id, - toOne: {target.relationship: identifier})); + request.type, + request.id, + Resource(request.type, request.id, + toOne: {request.relationship: request.identifier})); return JsonApiResponse.noContent(); }); @@ -176,7 +161,7 @@ class RepositoryController implements JsonApiController { final Repository _repo; - FutureOr _getByIdentifier(Identifier identifier) => + FutureOr _getByIdentifier(Identifiers identifier) => _repo.get(identifier.type, identifier.id); Future> _getRelated( @@ -185,7 +170,7 @@ class RepositoryController implements JsonApiController { ) async { if (path.isEmpty) return []; final resources = []; - final ids = []; + final ids = []; if (resource.toOne.containsKey(path.first)) { ids.add(resource.toOne[path.first]); diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart new file mode 100644 index 00000000..e9eb7fc4 --- /dev/null +++ b/lib/src/server/request_factory.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +class JsonApiRequestFactory { + JsonApiRequest getJsonApiRequest(HttpRequest request) { + try { + return _convert(request); + } on FormatException catch (e) { + return PredefinedResponse(JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Invalid JSON. ${e.message}') + ])); + } on DocumentException catch (e) { + return PredefinedResponse(JsonApiResponse.badRequest([ + JsonApiError(status: '400', title: 'Bad request', detail: e.message) + ])); + } + } + + JsonApiRequest _convert(HttpRequest request) { + final s = request.uri.pathSegments; + if (s.length == 1) { + switch (request.method) { + case 'GET': + return FetchCollection(request.uri.queryParametersAll, s[0]); + case 'POST': + return CreateResource( + s[0], ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _methodNotAllowed(['GET', 'POST']); + } + } else if (s.length == 2) { + switch (request.method) { + case 'DELETE': + return DeleteResource(s[0], s[1]); + case 'GET': + return FetchResource(s[0], s[1], request.uri.queryParametersAll); + case 'PATCH': + return UpdateResourceRequest(s[0], s[1], + ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _methodNotAllowed(['DELETE', 'GET', 'PATCH']); + } + } else if (s.length == 3) { + switch (request.method) { + case 'GET': + return FetchRelated(s[0], s[1], s[2], request.uri.queryParametersAll); + default: + return _methodNotAllowed(['GET']); + } + } else if (s.length == 4 && s[2] == 'relationships') { + switch (request.method) { + case 'DELETE': + return DeleteFromRelationship(s[0], s[1], s[3], + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + case 'GET': + return FetchRelationship( + s[0], s[1], s[3], request.uri.queryParametersAll); + case 'PATCH': + final rel = Relationship.fromJson(jsonDecode(request.body)); + if (rel is ToOne) { + return ReplaceToOne(s[0], s[1], s[3], rel.unwrap()); + } + if (rel is ToMany) { + return ReplaceToMany(s[0], s[1], s[3], rel.unwrap()); + } + return PredefinedResponse(JsonApiResponse.badRequest([ + JsonApiError( + status: '400', + title: 'Bad request', + detail: 'Incomplete relationship object') + ])); + case 'POST': + return AddToRelationship(s[0], s[1], s[3], + ToMany.fromJson(jsonDecode(request.body)).unwrap()); + default: + return _methodNotAllowed(['DELETE', 'GET', 'PATCH', 'POST']); + } + } + return PredefinedResponse(JsonApiResponse.notFound([ + JsonApiError( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server') + ])); + } + + JsonApiRequest _methodNotAllowed(Iterable allow) => + PredefinedResponse(JsonApiResponse.methodNotAllowed([ + JsonApiError( + status: '405', + title: 'Method Not Allowed', + detail: 'Allowed methods: ${allow.join(', ')}') + ], allow: allow)); +} diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart index bf801516..d29084d3 100644 --- a/lib/src/server/response_document_factory.dart +++ b/lib/src/server/response_document_factory.dart @@ -46,7 +46,7 @@ class ResponseDocumentFactory { } /// A document containing a to-many relationship - Document makeToManyDocument(Uri self, Iterable identifiers, + Document makeToManyDocument(Uri self, Iterable identifiers, String type, String id, String relationship) { return _document = Document( ToMany( @@ -60,7 +60,7 @@ class ResponseDocumentFactory { } /// A document containing a to-one relationship - Document makeToOneDocument(Uri self, Identifier identifier, String type, + Document makeToOneDocument(Uri self, Identifiers identifier, String type, String id, String relationship) { return _document = Document( ToOne( diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart deleted file mode 100644 index 3ba19413..00000000 --- a/lib/src/server/target.dart +++ /dev/null @@ -1,46 +0,0 @@ -/// The target of a URI referring a resource collection -class CollectionTarget { - /// Resource type - final String type; - - const CollectionTarget(this.type); -} - -/// The target of a URI referring to a single resource -class ResourceTarget { - /// Resource type - final String type; - - /// Resource id - final String id; - - const ResourceTarget(this.type, this.id); -} - -/// The target of a URI referring a related resource or collection -class RelatedTarget { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelatedTarget(this.type, this.id, this.relationship); -} - -/// The target of a URI referring a relationship -class RelationshipTarget { - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; - - const RelationshipTarget(this.type, this.id, this.relationship); -} diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index dd9475cb..b62560bb 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -45,7 +45,7 @@ void main() { await client .updateResource(Resource('books', '2', toMany: {'authors': []})); await client.addToRelationship( - 'books', '2', 'authors', [Identifier('writers', '1')]); + 'books', '2', 'authors', [Identifiers('writers', '1')]); final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 8c2f61e3..26b6b27f 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -20,22 +20,22 @@ void main() async { Resource('countries', '1', attributes: {'name': 'Wonderland'}); final alice = Resource('people', '1', attributes: {'name': 'Alice'}, - toOne: {'birthplace': Identifier.of(wonderland)}); + toOne: {'birthplace': Identifiers.of(wonderland)}); final bob = Resource('people', '2', attributes: {'name': 'Bob'}, - toOne: {'birthplace': Identifier.of(wonderland)}); + toOne: {'birthplace': Identifiers.of(wonderland)}); final comment1 = Resource('comments', '1', attributes: {'text': 'First comment!'}, - toOne: {'author': Identifier.of(bob)}); + toOne: {'author': Identifiers.of(bob)}); final comment2 = Resource('comments', '2', attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': Identifier.of(alice)}); + toOne: {'author': Identifiers.of(alice)}); final post = Resource('posts', '1', attributes: { 'title': 'Hello World' }, toOne: { - 'author': Identifier.of(alice) + 'author': Identifiers.of(alice) }, toMany: { - 'comments': [Identifier.of(comment1), Identifier.of(comment2)], + 'comments': [Identifiers.of(comment1), Identifiers.of(comment2)], 'tags': [] }); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index aa9e70ff..80710f5c 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -98,7 +98,7 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { final book = Resource('books', null, - toOne: {'publisher': Identifier('companies', '123')}); + toOne: {'publisher': Identifiers('companies', '123')}); final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); @@ -112,7 +112,7 @@ void main() async { test('404 when the related resource does not exist (to-many)', () async { final book = Resource('books', null, toMany: { - 'authors': [Identifier('people', '123')] + 'authors': [Identifiers('people', '123')] }); final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index bf0cdc2a..a40f0f72 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -16,8 +16,8 @@ Future seedResources(RoutingClient client) async { 'title': 'Refactoring', 'ISBN-10': '0134757599' }, toOne: { - 'publisher': Identifier('companies', '1') + 'publisher': Identifiers('companies', '1') }, toMany: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] + 'authors': [Identifiers('people', '1'), Identifiers('people', '2')] })); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 3c878193..e9d5801d 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -31,7 +31,7 @@ void main() async { group('Updatng a to-one relationship', () { test('204 No Content', () async { final r = await routingClient.replaceToOne( - 'books', '1', 'publisher', Identifier('companies', '2')); + 'books', '1', 'publisher', Identifiers('companies', '2')); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); @@ -42,7 +42,7 @@ void main() async { test('404 on collection', () async { final r = await routingClient.replaceToOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); + 'unicorns', '1', 'breed', Identifiers('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -54,7 +54,7 @@ void main() async { test('404 on resource', () async { final r = await routingClient.replaceToOne( - 'books', '42', 'publisher', Identifier('companies', '2')); + 'books', '42', 'publisher', Identifiers('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -103,7 +103,7 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { final r = await routingClient - .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); + .replaceToMany('books', '1', 'authors', [Identifiers('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); @@ -115,7 +115,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.replaceToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '2')]); + 'unicorns', '1', 'breed', [Identifiers('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -127,7 +127,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.replaceToMany( - 'books', '42', 'publisher', [Identifier('companies', '2')]); + 'books', '42', 'publisher', [Identifiers('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -141,7 +141,7 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '3')]); + 'books', '1', 'authors', [Identifiers('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 3); @@ -154,7 +154,7 @@ void main() async { test('successfully adding an existing identifier', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '2')]); + 'books', '1', 'authors', [Identifiers('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -167,7 +167,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.addToRelationship( - 'unicorns', '1', 'breed', [Identifier('companies', '3')]); + 'unicorns', '1', 'breed', [Identifiers('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -179,7 +179,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.addToRelationship( - 'books', '42', 'publisher', [Identifier('companies', '3')]); + 'books', '42', 'publisher', [Identifiers('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -191,7 +191,7 @@ void main() async { test('404 when relationship not found', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'sellers', [Identifier('companies', '3')]); + 'books', '1', 'sellers', [Identifiers('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -206,7 +206,7 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '1')]); + 'books', '1', 'authors', [Identifiers('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 1); @@ -218,7 +218,7 @@ void main() async { test('successfully deleting a non-present identifier', () async { final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '3')]); + 'books', '1', 'authors', [Identifiers('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -231,7 +231,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.deleteFromToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '1')]); + 'unicorns', '1', 'breed', [Identifiers('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -243,7 +243,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.deleteFromToMany( - 'books', '42', 'publisher', [Identifier('companies', '1')]); + 'books', '42', 'publisher', [Identifiers('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 0fabe12a..ac704dd4 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -36,8 +36,8 @@ void main() async { }, toOne: { 'publisher': null }, toMany: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] + 'authors': [Identifiers('people', '1')], + 'reviewers': [Identifiers('people', '2')] })); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); @@ -47,9 +47,9 @@ void main() async { expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); expect(r.data.unwrap().toOne['publisher'], isNull); expect( - r.data.unwrap().toMany['authors'], equals([Identifier('people', '1')])); + r.data.unwrap().toMany['authors'], equals([Identifiers('people', '1')])); expect(r.data.unwrap().toMany['reviewers'], - equals([Identifier('people', '2')])); + equals([Identifiers('people', '2')])); final r1 = await routingClient.fetchResource('books', '1'); expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); diff --git a/test/unit/document/identifier_test.dart b/test/unit/document/identifier_test.dart index 05d485c4..3e979493 100644 --- a/test/unit/document/identifier_test.dart +++ b/test/unit/document/identifier_test.dart @@ -3,6 +3,6 @@ import 'package:test/test.dart'; void main() { test('equal identifiers are detected by Set', () { - expect({Identifier('foo', '1'), Identifier('foo', '1')}.length, 1); + expect({Identifiers('foo', '1'), Identifiers('foo', '1')}.length, 1); }); } diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 0a4418eb..bb69a88f 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Removes duplicate identifiers in toMany relationships', () { final r = Resource('type', 'id', toMany: { - 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] + 'rel': [Identifiers('foo', '1'), Identifiers('foo', '1')] }); expect(r.toMany['rel'].length, 1); }); From 48142839cb47325b65db40c69ed303f6a3ecc0f2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 18 Feb 2020 21:31:31 -0800 Subject: [PATCH 25/99] wip --- lib/server.dart | 2 +- lib/src/server/http_response_builder.dart | 162 ++++++++++++ lib/src/server/json_api_request.dart | 30 +-- lib/src/server/json_api_response.dart | 230 +++++++----------- lib/src/server/json_api_server.dart | 22 +- lib/src/server/repository_controller.dart | 54 ++-- lib/src/server/request_factory.dart | 13 +- lib/src/server/response_document_factory.dart | 129 ---------- 8 files changed, 300 insertions(+), 342 deletions(-) create mode 100644 lib/src/server/http_response_builder.dart delete mode 100644 lib/src/server/response_document_factory.dart diff --git a/lib/server.dart b/lib/server.dart index f769b01f..12e10a14 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -4,10 +4,10 @@ library server; export 'package:json_api/src/server/dart_server_handler.dart'; +export 'package:json_api/src/server/http_response_builder.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; -export 'package:json_api/src/server/response_document_factory.dart'; diff --git a/lib/src/server/http_response_builder.dart b/lib/src/server/http_response_builder.dart new file mode 100644 index 00000000..cf86515e --- /dev/null +++ b/lib/src/server/http_response_builder.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/query/page.dart'; +import 'package:json_api/src/server/pagination.dart'; + +class HttpResponseBuilder { + /// A document containing a list of errors + void errorDocument(Iterable errors) { + _headers['Content-Type'] = Document.contentType; + _document = Document.error(errors, api: _api); + } + + /// A document containing a collection of resources + void collectionDocument(Iterable collection, + {int total, Iterable included}) { + _headers['Content-Type'] = Document.contentType; + + _document = Document( + ResourceCollectionData(collection.map(_resourceObject), + links: {'self': Link(_self), ..._navigation(_self, total)}, + included: included?.map(_resourceObject)), + api: _api); + } + + /// A document containing a single resource + void resourceDocument(Resource resource, {Iterable included}) { + _headers['Content-Type'] = Document.contentType; + + _document = Document( + ResourceData(_resourceObject(resource), + links: {'self': Link(_self)}, + included: included?.map(_resourceObject)), + api: _api); + } + + /// A document containing a single (primary) resource which has been created + /// on the server. The difference with [resourceDocument] is that this + /// method generates the `self` link to match the `location` header. + /// + /// This is the quote from the documentation: + /// > If the resource object returned by the response contains a self key + /// > in its links member and a Location header is provided, the value of + /// > the self member MUST match the value of the Location header. + /// + /// See https://jsonapi.org/format/#crud-creating-responses-201 + void createdResourceDocument(Resource resource) { + _headers['Content-Type'] = Document.contentType; + + _document = Document( + ResourceData(_resourceObject(resource), links: { + 'self': Link(_routing.resource(resource.type, resource.id)) + }), + api: _api); + } + + /// A document containing a to-many relationship + void toManyDocument(Iterable identifiers, String type, String id, + String relationship) { + _headers['Content-Type'] = Document.contentType; + + _document = Document( + ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(_self), + 'related': Link(_routing.related(type, id, relationship)) + }, + ), + api: _api); + } + + /// A document containing a to-one relationship + void toOneDocument( + Identifiers identifier, String type, String id, String relationship) { + _headers['Content-Type'] = Document.contentType; + + _document = Document( + ToOne( + nullable(IdentifierObject.fromIdentifier)(identifier), + links: { + 'self': Link(_self), + 'related': Link(_routing.related(type, id, relationship)) + }, + ), + api: _api); + } + + /// A document containing just a meta member + void metaDocument(Map meta) { + _headers['Content-Type'] = Document.contentType; + + _document = Document.empty(meta, api: _api); + } + + void addContentLocation(String type, String id) { + _headers['Content-Location'] = _routing.resource(type, id).toString(); + } + + void addLocation(String type, String id) { + _headers['Location'] = _routing.resource(type, id).toString(); + } + + HttpResponse buildHttpResponse() { + return HttpResponse(statusCode, + body: _document == null ? null : jsonEncode(_document), + headers: _headers); + } + + void addHeaders(Map headers) { + _headers.addAll(headers); + } + + HttpResponseBuilder(this._routing, this._self); + + final Uri _self; + final Routing _routing; + final Pagination _pagination = Pagination.none(); + final Api _api = Api(version: '1.0'); + Document _document; + int statusCode = 200; + final _headers = {}; + + ResourceObject _resourceObject(Resource r) => + ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { + ...r.toOne.map((k, v) => MapEntry( + k, + ToOne( + nullable(IdentifierObject.fromIdentifier)(v), + links: { + 'self': Link(_routing.relationship(r.type, r.id, k)), + 'related': Link(_routing.related(r.type, r.id, k)) + }, + ))), + ...r.toMany.map((k, v) => MapEntry( + k, + ToMany( + v.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(_routing.relationship(r.type, r.id, k)), + 'related': Link(_routing.related(r.type, r.id, k)) + }, + ))) + }, links: { + 'self': Link(_routing.resource(r.type, r.id)) + }); + + Map _navigation(Uri uri, int total) { + final page = Page.fromUri(uri); + + return ({ + 'first': _pagination.first(), + 'last': _pagination.last(total), + 'prev': _pagination.prev(page), + 'next': _pagination.next(page, total) + }..removeWhere((k, v) => v == null)) + .map((k, v) => MapEntry(k, Link(v.addToUri(uri)))); + } +} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index 0fcd4ee3..ff6f80ed 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -1,8 +1,6 @@ import 'dart:async'; -import 'dart:convert'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/server/json_api_response.dart'; @@ -13,14 +11,14 @@ import 'package:json_api/src/server/json_api_response.dart'; /// The response may either be a successful or an error. abstract class JsonApiController { /// Finds an returns a primary resource collection. - /// Use [JsonApiResponse.collection] to return a successful response. + /// Use [CollectionResponse] to return a successful response. /// Use [JsonApiResponse.notFound] if the collection does not exist. /// /// See https://jsonapi.org/format/#fetching-resources FutureOr fetchCollection(FetchCollection request); /// Finds an returns a primary resource. - /// Use [JsonApiResponse.resource] to return a successful response. + /// Use [ResourceResponse] to return a successful response. /// Use [JsonApiResponse.notFound] if the resource does not exist. /// /// See https://jsonapi.org/format/#fetching-resources @@ -34,21 +32,21 @@ abstract class JsonApiController { FutureOr fetchRelated(FetchRelated request); /// Finds an returns a relationship of a primary resource. - /// Use [JsonApiResponse.toOne] or [JsonApiResponse.toMany] to return a successful response. + /// Use [ToOneResponse] or [ToManyResponse] to return a successful response. /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. /// /// See https://jsonapi.org/format/#fetching-relationships FutureOr fetchRelationship(FetchRelationship request); /// Deletes the resource. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. /// Use [JsonApiResponse.notFound] if the resource does not exist. /// /// See https://jsonapi.org/format/#crud-deleting FutureOr deleteResource(DeleteResource request); /// Creates a new resource in the collection. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. /// Use [JsonApiResponse.notFound] if the collection does not exist. /// Use [JsonApiResponse.forbidden] if the server does not support this operation. /// Use [JsonApiResponse.conflict] if the resource already exists or the collection @@ -58,39 +56,37 @@ abstract class JsonApiController { FutureOr createResource(CreateResource request); /// Updates the resource. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.resource]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. /// /// See https://jsonapi.org/format/#crud-updating - FutureOr updateResource(UpdateResourceRequest request); + FutureOr updateResource(UpdateResource request); /// Replaces the to-one relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toOne]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToOneResponse]. /// /// See https://jsonapi.org/format/#crud-updating-to-one-relationships FutureOr replaceToOne(ReplaceToOne request); /// Replaces the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships FutureOr replaceToMany(ReplaceToMany request); /// Removes the given identifiers from the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships FutureOr deleteFromRelationship( DeleteFromRelationship request); /// Adds the given identifiers to the to-many relationship. - /// A successful response may be one of [JsonApiResponse.accepted], [JsonApiResponse.noContent], or [JsonApiResponse.toMany]. + /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. /// /// See https://jsonapi.org/format/#crud-updating-to-many-relationships FutureOr addToRelationship(AddToRelationship request); } - - abstract class JsonApiRequest { FutureOr call(JsonApiController c); } @@ -134,13 +130,13 @@ class CreateResource implements JsonApiRequest { FutureOr call(JsonApiController c) => c.createResource(this); } -class UpdateResourceRequest implements JsonApiRequest { +class UpdateResource implements JsonApiRequest { final String type; final String id; final Resource resource; - UpdateResourceRequest(this.type, this.id, this.resource); + UpdateResource(this.type, this.id, this.resource); @override FutureOr call(JsonApiController c) => c.updateResource(this); diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index e4cefbe9..551dd0e2 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,222 +1,156 @@ import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/response_document_factory.dart'; +import 'package:json_api/src/server/http_response_builder.dart'; abstract class JsonApiResponse { - final int statusCode; - - const JsonApiResponse(this.statusCode); - - void buildDocument(ResponseDocumentFactory factory, Uri self); - - Map buildHeaders(Routing routing); - - static JsonApiResponse noContent() => _NoContent(); + void build(HttpResponseBuilder response); +} - static JsonApiResponse accepted(Resource resource) => _Accepted(resource); +class NoContentResponse implements JsonApiResponse { + @override + void build(HttpResponseBuilder response) { + response.statusCode = 204; + } +} - static JsonApiResponse meta(Map meta) => _Meta(meta); +class CollectionResponse implements JsonApiResponse { + final Iterable collection; + final Iterable included; + final int total; - static JsonApiResponse collection(Iterable collection, - {Iterable included, int total}) => - _Collection(collection, included: included, total: total); + CollectionResponse(this.collection, {this.included, this.total}); - static JsonApiResponse resource(Resource resource, - {Iterable included}) => - _Resource(resource, included: included); + @override + void build(HttpResponseBuilder response) { + response.collectionDocument(collection, included: included, total: total); + } +} - static JsonApiResponse resourceCreated(Resource resource) => - _ResourceCreated(resource); +class AcceptedResponse implements JsonApiResponse { + final Resource resource; - static JsonApiResponse seeOther(String type, String id) => - _SeeOther(type, id); + AcceptedResponse(this.resource); - static JsonApiResponse toMany(String type, String id, String relationship, - Iterable identifiers) => - _ToMany(type, id, relationship, identifiers); + @override + void build(HttpResponseBuilder response) { + response + ..statusCode = 202 + ..addContentLocation(resource.type, resource.id) + ..resourceDocument(resource); + } +} - static JsonApiResponse toOne(String type, String id, String relationship, - Identifiers identifier) => - _ToOne(type, id, relationship, identifier); +class ErrorResponse implements JsonApiResponse { + final Iterable errors; + final int statusCode; - /// Generic error response - static JsonApiResponse error(int statusCode, Iterable errors) => - _Error(statusCode, errors); + ErrorResponse(this.statusCode, this.errors); static JsonApiResponse badRequest(Iterable errors) => - _Error(400, errors); + ErrorResponse(400, errors); static JsonApiResponse forbidden(Iterable errors) => - _Error(403, errors); + ErrorResponse(403, errors); static JsonApiResponse notFound(Iterable errors) => - _Error(404, errors); + ErrorResponse(404, errors); /// The allowed methods can be specified in [allow] static JsonApiResponse methodNotAllowed(Iterable errors, {Iterable allow}) => - _Error(405, errors, headers: {'Allow': allow.join(', ')}); + ErrorResponse(405, errors).._headers['Allow'] = allow.join(', '); static JsonApiResponse conflict(Iterable errors) => - _Error(409, errors); + ErrorResponse(409, errors); static JsonApiResponse notImplemented(Iterable errors) => - _Error(501, errors); -} - -class _NoContent extends JsonApiResponse { - const _NoContent() : super(204); - - @override - Document buildDocument(ResponseDocumentFactory factory, Uri self) => null; - - @override - Map buildHeaders(Routing routing) => {}; -} - -class _Collection extends JsonApiResponse { - final Iterable collection; - final Iterable included; - final int total; - - const _Collection(this.collection, {this.included, this.total}) : super(200); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeCollectionDocument(self, collection, - included: included, total: total); + ErrorResponse(501, errors); @override - Map buildHeaders(Routing routing) => - {'Content-Type': Document.contentType}; -} - -class _Accepted extends JsonApiResponse { - final Resource resource; - - _Accepted(this.resource) : super(202); - - @override - void buildDocument(ResponseDocumentFactory factory, Uri self) => - factory.makeResourceDocument(self, resource); - - @override - Map buildHeaders(Routing routing) => { - 'Content-Type': Document.contentType, - 'Content-Location': - routing.resource(resource.type, resource.id).toString(), - }; -} - -class _Error extends JsonApiResponse { - final Iterable errors; - final Map headers; - - const _Error(int status, this.errors, {this.headers = const {}}) - : super(status); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeErrorDocument(errors); + void build(HttpResponseBuilder response) { + response + ..statusCode = statusCode + ..addHeaders(_headers) + ..errorDocument(errors); + } - @override - Map buildHeaders(Routing routing) => - {...headers, 'Content-Type': Document.contentType}; + final _headers = {}; } -class _Meta extends JsonApiResponse { +class MetaResponse implements JsonApiResponse { final Map meta; - _Meta(this.meta) : super(200); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeMetaDocument(meta); + MetaResponse(this.meta); @override - Map buildHeaders(Routing routing) => - {'Content-Type': Document.contentType}; + void build(HttpResponseBuilder response) { + response.metaDocument(meta); + } } -class _Resource extends JsonApiResponse { +class ResourceResponse implements JsonApiResponse { final Resource resource; final Iterable included; - const _Resource(this.resource, {this.included}) : super(200); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeResourceDocument(self, resource, included: included); + ResourceResponse(this.resource, {this.included}); @override - Map buildHeaders(Routing routing) => - {'Content-Type': Document.contentType}; + void build(HttpResponseBuilder response) { + response.resourceDocument(resource, included: included); + } } -class _ResourceCreated extends JsonApiResponse { +class ResourceCreatedResponse implements JsonApiResponse { final Resource resource; - _ResourceCreated(this.resource) : super(201) { - ArgumentError.checkNotNull(resource.id, 'resource.id'); - } + ResourceCreatedResponse(this.resource); @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeCreatedResourceDocument(resource); - - @override - Map buildHeaders(Routing routing) => { - 'Content-Type': Document.contentType, - 'Location': routing.resource(resource.type, resource.id).toString() - }; + void build(HttpResponseBuilder response) { + response + ..statusCode = 201 + ..addLocation(resource.type, resource.id) + ..createdResourceDocument(resource); + } } -class _SeeOther extends JsonApiResponse { +class SeeOtherResponse implements JsonApiResponse { final String type; final String id; - _SeeOther(this.type, this.id) : super(303); + SeeOtherResponse(this.type, this.id); @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => null; - - @override - Map buildHeaders(Routing routing) => - {'Location': routing.resource(type, id).toString()}; + void build(HttpResponseBuilder response) { + response + ..statusCode = 303 + ..addLocation(type, id); + } } -class _ToMany extends JsonApiResponse { +class ToManyResponse implements JsonApiResponse { final Iterable collection; final String type; final String id; final String relationship; - const _ToMany(this.type, this.id, this.relationship, this.collection) - : super(200); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeToManyDocument(self, collection, type, id, relationship); + ToManyResponse(this.type, this.id, this.relationship, this.collection); @override - Map buildHeaders(Routing routing) => - {'Content-Type': Document.contentType}; + void build(HttpResponseBuilder response) { + response.toManyDocument(collection, type, id, relationship); + } } -class _ToOne extends JsonApiResponse { +class ToOneResponse implements JsonApiResponse { final String type; final String id; final String relationship; final Identifiers identifier; - const _ToOne(this.type, this.id, this.relationship, this.identifier) - : super(200); - - @override - void buildDocument(ResponseDocumentFactory builder, Uri self) => - builder.makeToOneDocument(self, identifier, type, id, relationship); + ToOneResponse(this.type, this.id, this.relationship, this.identifier); @override - Map buildHeaders(Routing routing) => - {'Content-Type': Document.contentType}; + void build(HttpResponseBuilder response) { + response.toOneDocument(identifier, type, id, relationship); + } } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 17989a0b..9fa9f2d9 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; @@ -9,26 +8,21 @@ import 'package:json_api/src/server/request_factory.dart'; class JsonApiServer implements HttpHandler { @override - Future call(HttpRequest request) async { - final rq = JsonApiRequestFactory().getJsonApiRequest(request); + Future call(HttpRequest httpRequest) async { + final jsonApiRequest = + JsonApiRequestFactory().getJsonApiRequest(httpRequest); // Implementation-specific logic (e.g. auth) goes here - final response = await rq.call(_controller); + final jsonApiResponse = await jsonApiRequest.call(_controller); - // Build response Document - response.buildDocument(_factory, request.uri); - final document = _factory.build(); + final httpResponse = HttpResponseBuilder(_routing, httpRequest.uri); + jsonApiResponse.build(httpResponse); // Any response post-processing goes here - return HttpResponse(response.statusCode, - body: document == null ? null : jsonEncode(document), - headers: response.buildHeaders(_routing)); + return httpResponse.buildHttpResponse(); } - JsonApiServer(this._routing, this._controller, - {ResponseDocumentFactory documentFactory}) - : _factory = documentFactory ?? ResponseDocumentFactory(_routing); + JsonApiServer(this._routing, this._controller); final Routing _routing; final JsonApiController _controller; - final ResponseDocumentFactory _factory; } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index d536fb04..1ee855f6 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -13,7 +13,7 @@ class RepositoryController implements JsonApiController { _do(() async { final original = await _repo.get(request.type, request.id); if (!original.toMany.containsKey(request.relationship)) { - return JsonApiResponse.notFound([ + return ErrorResponse.notFound([ JsonApiError( status: '404', title: 'Relationship not found', @@ -30,16 +30,16 @@ class RepositoryController implements JsonApiController { ...request.identifiers } })); - return JsonApiResponse.toMany(request.type, request.id, - request.relationship, updated.toMany[request.relationship]); + return ToManyResponse(request.type, request.id, request.relationship, + updated.toMany[request.relationship]); }); @override FutureOr createResource(CreateResource request) => _do(() async { final modified = await _repo.create(request.type, request.resource); - if (modified == null) return JsonApiResponse.noContent(); - return JsonApiResponse.resourceCreated(modified); + if (modified == null) return NoContentResponse(); + return ResourceCreatedResponse(modified); }); @override @@ -54,15 +54,15 @@ class RepositoryController implements JsonApiController { request.relationship: {...original.toMany[request.relationship]} ..removeAll(request.identifiers) })); - return JsonApiResponse.toMany(request.type, request.id, - request.relationship, updated.toMany[request.relationship]); + return ToManyResponse(request.type, request.id, request.relationship, + updated.toMany[request.relationship]); }); @override FutureOr deleteResource(DeleteResource request) => _do(() async { await _repo.delete(request.type, request.id); - return JsonApiResponse.noContent(); + return NoContentResponse(); }); @override @@ -78,7 +78,7 @@ class RepositoryController implements JsonApiController { } } - return JsonApiResponse.collection(c.elements, + return CollectionResponse(c.elements, total: c.total, included: include.isEmpty ? null : resources); }); @@ -86,7 +86,7 @@ class RepositoryController implements JsonApiController { FutureOr fetchRelated(FetchRelated request) => _do(() async { final resource = await _repo.get(request.type, request.id); if (resource.toOne.containsKey(request.relationship)) { - return JsonApiResponse.resource( + return ResourceResponse( await _getByIdentifier(resource.toOne[request.relationship])); } if (resource.toMany.containsKey(request.relationship)) { @@ -94,7 +94,7 @@ class RepositoryController implements JsonApiController { for (final identifier in resource.toMany[request.relationship]) { related.add(await _getByIdentifier(identifier)); } - return JsonApiResponse.collection(related); + return CollectionResponse(related); } return _relationshipNotFound(request.relationship); }); @@ -104,12 +104,12 @@ class RepositoryController implements JsonApiController { _do(() async { final resource = await _repo.get(request.type, request.id); if (resource.toOne.containsKey(request.relationship)) { - return JsonApiResponse.toOne(request.type, request.id, - request.relationship, resource.toOne[request.relationship]); + return ToOneResponse(request.type, request.id, request.relationship, + resource.toOne[request.relationship]); } if (resource.toMany.containsKey(request.relationship)) { - return JsonApiResponse.toMany(request.type, request.id, - request.relationship, resource.toMany[request.relationship]); + return ToManyResponse(request.type, request.id, request.relationship, + resource.toMany[request.relationship]); } return _relationshipNotFound(request.relationship); }); @@ -123,7 +123,7 @@ class RepositoryController implements JsonApiController { for (final path in include) { resources.addAll(await _getRelated(resource, path.split('.'))); } - return JsonApiResponse.resource(resource, + return ResourceResponse(resource, included: include.isEmpty ? null : resources); }); @@ -135,16 +135,16 @@ class RepositoryController implements JsonApiController { request.id, Resource(request.type, request.id, toMany: {request.relationship: request.identifiers})); - return JsonApiResponse.noContent(); + return NoContentResponse(); }); @override - FutureOr updateResource(UpdateResourceRequest request) => + FutureOr updateResource(UpdateResource request) => _do(() async { final modified = await _repo.update(request.type, request.id, request.resource); - if (modified == null) return JsonApiResponse.noContent(); - return JsonApiResponse.resource(modified); + if (modified == null) return NoContentResponse(); + return ResourceResponse(modified); }); @override @@ -154,7 +154,7 @@ class RepositoryController implements JsonApiController { request.id, Resource(request.type, request.id, toOne: {request.relationship: request.identifier})); - return JsonApiResponse.noContent(); + return NoContentResponse(); }); RepositoryController(this._repo); @@ -193,27 +193,27 @@ class RepositoryController implements JsonApiController { try { return await action(); } on UnsupportedOperation catch (e) { - return JsonApiResponse.forbidden([ + return ErrorResponse.forbidden([ JsonApiError( status: '403', title: 'Unsupported operation', detail: e.message) ]); } on CollectionNotFound catch (e) { - return JsonApiResponse.notFound([ + return ErrorResponse.notFound([ JsonApiError( status: '404', title: 'Collection not found', detail: e.message) ]); } on ResourceNotFound catch (e) { - return JsonApiResponse.notFound([ + return ErrorResponse.notFound([ JsonApiError( status: '404', title: 'Resource not found', detail: e.message) ]); } on InvalidType catch (e) { - return JsonApiResponse.conflict([ + return ErrorResponse.conflict([ JsonApiError( status: '409', title: 'Invalid resource type', detail: e.message) ]); } on ResourceExists catch (e) { - return JsonApiResponse.conflict([ + return ErrorResponse.conflict([ JsonApiError(status: '409', title: 'Resource exists', detail: e.message) ]); } @@ -222,7 +222,7 @@ class RepositoryController implements JsonApiController { JsonApiResponse _relationshipNotFound( String relationship, ) { - return JsonApiResponse.notFound([ + return ErrorResponse.notFound([ JsonApiError( status: '404', title: 'Relationship not found', diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart index e9eb7fc4..458dec8a 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_factory.dart @@ -5,19 +5,20 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/server/json_api_request.dart'; import 'package:json_api/src/server/json_api_response.dart'; +/// TODO: Extract routing class JsonApiRequestFactory { JsonApiRequest getJsonApiRequest(HttpRequest request) { try { return _convert(request); } on FormatException catch (e) { - return PredefinedResponse(JsonApiResponse.badRequest([ + return PredefinedResponse(ErrorResponse.badRequest([ JsonApiError( status: '400', title: 'Bad request', detail: 'Invalid JSON. ${e.message}') ])); } on DocumentException catch (e) { - return PredefinedResponse(JsonApiResponse.badRequest([ + return PredefinedResponse(ErrorResponse.badRequest([ JsonApiError(status: '400', title: 'Bad request', detail: e.message) ])); } @@ -42,7 +43,7 @@ class JsonApiRequestFactory { case 'GET': return FetchResource(s[0], s[1], request.uri.queryParametersAll); case 'PATCH': - return UpdateResourceRequest(s[0], s[1], + return UpdateResource(s[0], s[1], ResourceData.fromJson(jsonDecode(request.body)).unwrap()); default: return _methodNotAllowed(['DELETE', 'GET', 'PATCH']); @@ -70,7 +71,7 @@ class JsonApiRequestFactory { if (rel is ToMany) { return ReplaceToMany(s[0], s[1], s[3], rel.unwrap()); } - return PredefinedResponse(JsonApiResponse.badRequest([ + return PredefinedResponse(ErrorResponse.badRequest([ JsonApiError( status: '400', title: 'Bad request', @@ -83,7 +84,7 @@ class JsonApiRequestFactory { return _methodNotAllowed(['DELETE', 'GET', 'PATCH', 'POST']); } } - return PredefinedResponse(JsonApiResponse.notFound([ + return PredefinedResponse(ErrorResponse.notFound([ JsonApiError( status: '404', title: 'Not Found', @@ -92,7 +93,7 @@ class JsonApiRequestFactory { } JsonApiRequest _methodNotAllowed(Iterable allow) => - PredefinedResponse(JsonApiResponse.methodNotAllowed([ + PredefinedResponse(ErrorResponse.methodNotAllowed([ JsonApiError( status: '405', title: 'Method Not Allowed', diff --git a/lib/src/server/response_document_factory.dart b/lib/src/server/response_document_factory.dart deleted file mode 100644 index d29084d3..00000000 --- a/lib/src/server/response_document_factory.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination.dart'; - -class ResponseDocumentFactory { - /// A document containing a list of errors - Document makeErrorDocument(Iterable errors) { - return _document = Document.error(errors, api: _api); - } - - /// A document containing a collection of resources - Document makeCollectionDocument(Uri self, Iterable collection, - {int total, Iterable included}) { - return _document = Document( - ResourceCollectionData(collection.map(_resourceObject), - links: {'self': Link(self), ..._navigation(self, total)}, - included: included?.map(_resourceObject)), - api: _api); - } - - /// A document containing a single resource - Document makeResourceDocument(Uri self, Resource resource, - {Iterable included}) { - return _document = Document( - ResourceData(_resourceObject(resource), - links: {'self': Link(self)}, - included: included?.map(_resourceObject)), - api: _api); - } - - /// A document containing a single (primary) resource which has been created - /// on the server. The difference with [makeResourceDocument] is that this - /// method generates the `self` link to match the `location` header. - /// - /// This is the quote from the documentation: - /// > If the resource object returned by the response contains a self key - /// > in its links member and a Location header is provided, the value of - /// > the self member MUST match the value of the Location header. - /// - /// See https://jsonapi.org/format/#crud-creating-responses-201 - Document makeCreatedResourceDocument(Resource resource) { - return _document = makeResourceDocument( - _urlFactory.resource(resource.type, resource.id), resource); - } - - /// A document containing a to-many relationship - Document makeToManyDocument(Uri self, Iterable identifiers, - String type, String id, String relationship) { - return _document = Document( - ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) - }, - ), - api: _api); - } - - /// A document containing a to-one relationship - Document makeToOneDocument(Uri self, Identifiers identifier, String type, - String id, String relationship) { - return _document = Document( - ToOne( - nullable(IdentifierObject.fromIdentifier)(identifier), - links: { - 'self': Link(self), - 'related': Link(_urlFactory.related(type, id, relationship)) - }, - ), - api: _api); - } - - /// A document containing just a meta member - Document makeMetaDocument(Map meta) { - return _document = Document.empty(meta, api: _api); - } - - Document build() { - return _document; - } - - ResponseDocumentFactory(this._urlFactory, {Api api, Pagination pagination}) - : _api = api, - _pagination = pagination ?? Pagination.none(); - - final Routing _urlFactory; - final Pagination _pagination; - final Api _api; - Document _document; - - ResourceObject _resourceObject(Resource r) => - ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { - ...r.toOne.map((k, v) => MapEntry( - k, - ToOne( - nullable(IdentifierObject.fromIdentifier)(v), - links: { - 'self': Link(_urlFactory.relationship(r.type, r.id, k)), - 'related': Link(_urlFactory.related(r.type, r.id, k)) - }, - ))), - ...r.toMany.map((k, v) => MapEntry( - k, - ToMany( - v.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(_urlFactory.relationship(r.type, r.id, k)), - 'related': Link(_urlFactory.related(r.type, r.id, k)) - }, - ))) - }, links: { - 'self': Link(_urlFactory.resource(r.type, r.id)) - }); - - Map _navigation(Uri uri, int total) { - final page = Page.fromUri(uri); - - return ({ - 'first': _pagination.first(), - 'last': _pagination.last(total), - 'prev': _pagination.prev(page), - 'next': _pagination.next(page, total) - }..removeWhere((k, v) => v == null)) - .map((k, v) => MapEntry(k, Link(v.addToUri(uri)))); - } -} From c631743e1e35de8224a40e0fd0eef2f5d79f61b9 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 19 Feb 2020 00:47:00 -0800 Subject: [PATCH 26/99] wip --- example/server.dart | 2 +- lib/document.dart | 2 +- lib/server.dart | 2 +- lib/src/client/json_api_response.dart | 2 +- lib/src/document/document.dart | 8 +- ...{json_api_error.dart => error_object.dart} | 10 +- lib/src/server/http_response_builder.dart | 162 ------------------ lib/src/server/http_response_factory.dart | 155 +++++++++++++++++ lib/src/server/json_api_request.dart | 9 - lib/src/server/json_api_response.dart | 82 ++++----- lib/src/server/json_api_server.dart | 50 +++++- lib/src/server/pagination.dart | 16 +- lib/src/server/repository_controller.dart | 14 +- lib/src/server/request_factory.dart | 60 ++----- test/unit/document/json_api_error_test.dart | 10 +- test/unit/server/numbered_page_test.dart | 8 +- 16 files changed, 284 insertions(+), 308 deletions(-) rename lib/src/document/{json_api_error.dart => error_object.dart} (95%) delete mode 100644 lib/src/server/http_response_builder.dart create mode 100644 lib/src/server/http_response_factory.dart diff --git a/example/server.dart b/example/server.dart index 564d4757..e1819d46 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; /// This example shows how to run a simple JSON:API server using the built-in /// HTTP server (dart:io). diff --git a/lib/document.dart b/lib/document.dart index 2db0e09c..ce38bee2 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -3,9 +3,9 @@ library document; export 'package:json_api/src/document/api.dart'; export 'package:json_api/src/document/document.dart'; export 'package:json_api/src/document/document_exception.dart'; +export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/identifier.dart'; export 'package:json_api/src/document/identifier_object.dart'; -export 'package:json_api/src/document/json_api_error.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/primary_data.dart'; export 'package:json_api/src/document/relationship.dart'; diff --git a/lib/server.dart b/lib/server.dart index 12e10a14..7f2e28e8 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -4,7 +4,7 @@ library server; export 'package:json_api/src/server/dart_server_handler.dart'; -export 'package:json_api/src/server/http_response_builder.dart'; +export 'package:json_api/src/server/http_response_factory.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; diff --git a/lib/src/client/json_api_response.dart b/lib/src/client/json_api_response.dart index ffc69be7..241cef01 100644 --- a/lib/src/client/json_api_response.dart +++ b/lib/src/client/json_api_response.dart @@ -27,7 +27,7 @@ class JsonApiResponse { /// List of errors (if any) returned by the server in case of an unsuccessful /// operation. May be empty. Will be null if the operation was successful. - List get errors => document?.errors; + List get errors => document?.errors; /// Primary Data from the async document (if any) ResourceData get asyncData => asyncDocument?.data; diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 31164df4..da4b1f94 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -1,6 +1,6 @@ import 'package:json_api/src/document/api.dart'; import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_api_error.dart'; +import 'package:json_api/src/document/error_object.dart'; import 'package:json_api/src/document/primary_data.dart'; class Document { @@ -13,7 +13,7 @@ class Document { final Api api; /// List of errors. May be null. - final Iterable errors; + final Iterable errors; /// Meta data. May be empty or null. final Map meta; @@ -24,7 +24,7 @@ class Document { meta = (meta == null) ? null : Map.unmodifiable(meta); /// Create a document with errors (no primary data) - Document.error(Iterable errors, + Document.error(Iterable errors, {Map meta, this.api}) : data = null, meta = (meta == null) ? null : Map.unmodifiable(meta), @@ -49,7 +49,7 @@ class Document { if (json.containsKey('errors')) { final errors = json['errors']; if (errors is List) { - return Document.error(errors.map(JsonApiError.fromJson), + return Document.error(errors.map(ErrorObject.fromJson), meta: json['meta'], api: api); } } else if (json.containsKey('data')) { diff --git a/lib/src/document/json_api_error.dart b/lib/src/document/error_object.dart similarity index 95% rename from lib/src/document/json_api_error.dart rename to lib/src/document/error_object.dart index 24b39d10..ca203cdf 100644 --- a/lib/src/document/json_api_error.dart +++ b/lib/src/document/error_object.dart @@ -2,10 +2,10 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; -/// [JsonApiError] represents an error occurred on the server. +/// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class JsonApiError { +class ErrorObject { /// A unique identifier for this particular occurrence of the problem. /// May be null. final String id; @@ -54,7 +54,7 @@ class JsonApiError { /// 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({ + ErrorObject({ this.id, this.status, this.code, @@ -67,7 +67,7 @@ class JsonApiError { }) : links = (links == null) ? null : Map.unmodifiable(links), meta = (meta == null) ? null : Map.unmodifiable(meta); - static JsonApiError fromJson(Object json) { + static ErrorObject fromJson(Object json) { if (json is Map) { String pointer; String parameter; @@ -77,7 +77,7 @@ class JsonApiError { pointer = source['pointer']; } final links = json['links']; - return JsonApiError( + return ErrorObject( id: json['id'], status: json['status'], code: json['code'], diff --git a/lib/src/server/http_response_builder.dart b/lib/src/server/http_response_builder.dart deleted file mode 100644 index cf86515e..00000000 --- a/lib/src/server/http_response_builder.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination.dart'; - -class HttpResponseBuilder { - /// A document containing a list of errors - void errorDocument(Iterable errors) { - _headers['Content-Type'] = Document.contentType; - _document = Document.error(errors, api: _api); - } - - /// A document containing a collection of resources - void collectionDocument(Iterable collection, - {int total, Iterable included}) { - _headers['Content-Type'] = Document.contentType; - - _document = Document( - ResourceCollectionData(collection.map(_resourceObject), - links: {'self': Link(_self), ..._navigation(_self, total)}, - included: included?.map(_resourceObject)), - api: _api); - } - - /// A document containing a single resource - void resourceDocument(Resource resource, {Iterable included}) { - _headers['Content-Type'] = Document.contentType; - - _document = Document( - ResourceData(_resourceObject(resource), - links: {'self': Link(_self)}, - included: included?.map(_resourceObject)), - api: _api); - } - - /// A document containing a single (primary) resource which has been created - /// on the server. The difference with [resourceDocument] is that this - /// method generates the `self` link to match the `location` header. - /// - /// This is the quote from the documentation: - /// > If the resource object returned by the response contains a self key - /// > in its links member and a Location header is provided, the value of - /// > the self member MUST match the value of the Location header. - /// - /// See https://jsonapi.org/format/#crud-creating-responses-201 - void createdResourceDocument(Resource resource) { - _headers['Content-Type'] = Document.contentType; - - _document = Document( - ResourceData(_resourceObject(resource), links: { - 'self': Link(_routing.resource(resource.type, resource.id)) - }), - api: _api); - } - - /// A document containing a to-many relationship - void toManyDocument(Iterable identifiers, String type, String id, - String relationship) { - _headers['Content-Type'] = Document.contentType; - - _document = Document( - ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(_self), - 'related': Link(_routing.related(type, id, relationship)) - }, - ), - api: _api); - } - - /// A document containing a to-one relationship - void toOneDocument( - Identifiers identifier, String type, String id, String relationship) { - _headers['Content-Type'] = Document.contentType; - - _document = Document( - ToOne( - nullable(IdentifierObject.fromIdentifier)(identifier), - links: { - 'self': Link(_self), - 'related': Link(_routing.related(type, id, relationship)) - }, - ), - api: _api); - } - - /// A document containing just a meta member - void metaDocument(Map meta) { - _headers['Content-Type'] = Document.contentType; - - _document = Document.empty(meta, api: _api); - } - - void addContentLocation(String type, String id) { - _headers['Content-Location'] = _routing.resource(type, id).toString(); - } - - void addLocation(String type, String id) { - _headers['Location'] = _routing.resource(type, id).toString(); - } - - HttpResponse buildHttpResponse() { - return HttpResponse(statusCode, - body: _document == null ? null : jsonEncode(_document), - headers: _headers); - } - - void addHeaders(Map headers) { - _headers.addAll(headers); - } - - HttpResponseBuilder(this._routing, this._self); - - final Uri _self; - final Routing _routing; - final Pagination _pagination = Pagination.none(); - final Api _api = Api(version: '1.0'); - Document _document; - int statusCode = 200; - final _headers = {}; - - ResourceObject _resourceObject(Resource r) => - ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { - ...r.toOne.map((k, v) => MapEntry( - k, - ToOne( - nullable(IdentifierObject.fromIdentifier)(v), - links: { - 'self': Link(_routing.relationship(r.type, r.id, k)), - 'related': Link(_routing.related(r.type, r.id, k)) - }, - ))), - ...r.toMany.map((k, v) => MapEntry( - k, - ToMany( - v.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(_routing.relationship(r.type, r.id, k)), - 'related': Link(_routing.related(r.type, r.id, k)) - }, - ))) - }, links: { - 'self': Link(_routing.resource(r.type, r.id)) - }); - - Map _navigation(Uri uri, int total) { - final page = Page.fromUri(uri); - - return ({ - 'first': _pagination.first(), - 'last': _pagination.last(total), - 'prev': _pagination.prev(page), - 'next': _pagination.next(page, total) - }..removeWhere((k, v) => v == null)) - .map((k, v) => MapEntry(k, Link(v.addToUri(uri)))); - } -} diff --git a/lib/src/server/http_response_factory.dart b/lib/src/server/http_response_factory.dart new file mode 100644 index 00000000..e6168131 --- /dev/null +++ b/lib/src/server/http_response_factory.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/query/page.dart'; +import 'package:json_api/src/server/pagination.dart'; + +class HttpResponseFactory { + /// A document containing a list of errors + HttpResponse error(Iterable errors, int statusCode, + Map headers) => + _doc(Document.error(errors, api: _api), + status: statusCode, headers: headers); + + /// A document containing a collection of resources + HttpResponse collection(Iterable collection, + {int total, + Iterable included, + Pagination pagination = const NoPagination()}) { + return _doc(Document( + ResourceCollectionData(collection.map(_resourceObject), + links: { + 'self': Link(_self), + ..._navigation(_self, total, pagination) + }, + included: included?.map(_resourceObject)), + api: _api)); + } + + HttpResponse accepted(Resource resource) => + _doc( + Document( + ResourceData(_resourceObject(resource), + links: {'self': Link(_self)}), + api: _api), + status: 202, + headers: { + 'Content-Location': + _routing.resource(resource.type, resource.id).toString() + }); + + /// A document containing just a meta member + HttpResponse meta(Map meta) => + _doc(Document.empty(meta, api: _api)); + + /// A document containing a single resource + HttpResponse resource(Resource resource, {Iterable included}) => + _doc(Document( + ResourceData(_resourceObject(resource), + links: {'self': Link(_self)}, + included: included?.map(_resourceObject)), + api: _api)); + + /// A document containing a single (primary) resource which has been created + /// on the server. The difference with [resource] is that this + /// method generates the `self` link to match the `location` header. + /// + /// This is the quote from the documentation: + /// > If the resource object returned by the response contains a self key + /// > in its links member and a Location header is provided, the value of + /// > the self member MUST match the value of the Location header. + /// + /// See https://jsonapi.org/format/#crud-creating-responses-201 + HttpResponse resourceCreated(Resource resource) => _doc( + Document( + ResourceData(_resourceObject(resource), links: { + 'self': Link(_routing.resource(resource.type, resource.id)) + }), + api: _api), + status: 201, + headers: { + 'Location': _routing.resource(resource.type, resource.id).toString() + }); + + HttpResponse seeOther(String type, String id) => HttpResponse(303, + headers: {'Location': _routing.resource(type, id).toString()}); + + /// A document containing a to-many relationship + HttpResponse toMany(Iterable identifiers, String type, String id, + String relationship) => + _doc(Document( + ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(_self), + 'related': Link(_routing.related(type, id, relationship)) + }, + ), + api: _api)); + + /// A document containing a to-one relationship + HttpResponse toOneDocument(Identifiers identifier, String type, String id, + String relationship) => + _doc(Document( + ToOne( + nullable(IdentifierObject.fromIdentifier)(identifier), + links: { + 'self': Link(_self), + 'related': Link(_routing.related(type, id, relationship)) + }, + ), + api: _api)); + + HttpResponse noContent() => HttpResponse(204); + + HttpResponseFactory(this._routing, this._self); + + final Uri _self; + final Routing _routing; + final Api _api = Api(version: '1.0'); + + HttpResponse _doc(Document d, + {int status = 200, Map headers = const {}}) => + HttpResponse(status, + body: jsonEncode(d), + headers: {...headers, 'Content-Type': Document.contentType}); + + ResourceObject _resourceObject(Resource r) => + ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { + ...r.toOne.map((k, v) => MapEntry( + k, + ToOne( + nullable(IdentifierObject.fromIdentifier)(v), + links: { + 'self': Link(_routing.relationship(r.type, r.id, k)), + 'related': Link(_routing.related(r.type, r.id, k)) + }, + ))), + ...r.toMany.map((k, v) => MapEntry( + k, + ToMany( + v.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(_routing.relationship(r.type, r.id, k)), + 'related': Link(_routing.related(r.type, r.id, k)) + }, + ))) + }, links: { + 'self': Link(_routing.resource(r.type, r.id)) + }); + + Map _navigation(Uri uri, int total, Pagination pagination) { + final page = Page.fromUri(uri); + + return ({ + 'first': pagination.first(), + 'last': pagination.last(total), + 'prev': pagination.prev(page), + 'next': pagination.next(page, total) + }..removeWhere((k, v) => v == null)) + .map((k, v) => MapEntry(k, Link(v.addToUri(uri)))); + } +} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index ff6f80ed..27a5e7ba 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -97,15 +97,6 @@ abstract class QueryParameters { Include get include => Include.fromQueryParameters(queryParameters); } -class PredefinedResponse implements JsonApiRequest { - final JsonApiResponse response; - - PredefinedResponse(this.response); - - @override - FutureOr call(JsonApiController c) => response; -} - class FetchCollection with QueryParameters implements JsonApiRequest { final String type; diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 551dd0e2..4a105e6c 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,15 +1,15 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/http_response_builder.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/server/http_response_factory.dart'; abstract class JsonApiResponse { - void build(HttpResponseBuilder response); + HttpResponse httpResponse(HttpResponseFactory response); } class NoContentResponse implements JsonApiResponse { @override - void build(HttpResponseBuilder response) { - response.statusCode = 204; - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.noContent(); } class CollectionResponse implements JsonApiResponse { @@ -20,9 +20,8 @@ class CollectionResponse implements JsonApiResponse { CollectionResponse(this.collection, {this.included, this.total}); @override - void build(HttpResponseBuilder response) { - response.collectionDocument(collection, included: included, total: total); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.collection(collection, included: included, total: total); } class AcceptedResponse implements JsonApiResponse { @@ -31,47 +30,39 @@ class AcceptedResponse implements JsonApiResponse { AcceptedResponse(this.resource); @override - void build(HttpResponseBuilder response) { - response - ..statusCode = 202 - ..addContentLocation(resource.type, resource.id) - ..resourceDocument(resource); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.accepted(resource); } class ErrorResponse implements JsonApiResponse { - final Iterable errors; + final Iterable errors; final int statusCode; ErrorResponse(this.statusCode, this.errors); - static JsonApiResponse badRequest(Iterable errors) => + static JsonApiResponse badRequest(Iterable errors) => ErrorResponse(400, errors); - static JsonApiResponse forbidden(Iterable errors) => + static JsonApiResponse forbidden(Iterable errors) => ErrorResponse(403, errors); - static JsonApiResponse notFound(Iterable errors) => + static JsonApiResponse notFound(Iterable errors) => ErrorResponse(404, errors); /// The allowed methods can be specified in [allow] - static JsonApiResponse methodNotAllowed(Iterable errors, - {Iterable allow}) => + static JsonApiResponse methodNotAllowed( + Iterable errors, Iterable allow) => ErrorResponse(405, errors).._headers['Allow'] = allow.join(', '); - static JsonApiResponse conflict(Iterable errors) => + static JsonApiResponse conflict(Iterable errors) => ErrorResponse(409, errors); - static JsonApiResponse notImplemented(Iterable errors) => + static JsonApiResponse notImplemented(Iterable errors) => ErrorResponse(501, errors); @override - void build(HttpResponseBuilder response) { - response - ..statusCode = statusCode - ..addHeaders(_headers) - ..errorDocument(errors); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.error(errors, statusCode, _headers); final _headers = {}; } @@ -82,9 +73,8 @@ class MetaResponse implements JsonApiResponse { MetaResponse(this.meta); @override - void build(HttpResponseBuilder response) { - response.metaDocument(meta); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.meta(meta); } class ResourceResponse implements JsonApiResponse { @@ -94,9 +84,8 @@ class ResourceResponse implements JsonApiResponse { ResourceResponse(this.resource, {this.included}); @override - void build(HttpResponseBuilder response) { - response.resourceDocument(resource, included: included); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.resource(resource, included: included); } class ResourceCreatedResponse implements JsonApiResponse { @@ -105,12 +94,8 @@ class ResourceCreatedResponse implements JsonApiResponse { ResourceCreatedResponse(this.resource); @override - void build(HttpResponseBuilder response) { - response - ..statusCode = 201 - ..addLocation(resource.type, resource.id) - ..createdResourceDocument(resource); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.resourceCreated(resource); } class SeeOtherResponse implements JsonApiResponse { @@ -120,11 +105,8 @@ class SeeOtherResponse implements JsonApiResponse { SeeOtherResponse(this.type, this.id); @override - void build(HttpResponseBuilder response) { - response - ..statusCode = 303 - ..addLocation(type, id); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.seeOther(type, id); } class ToManyResponse implements JsonApiResponse { @@ -136,9 +118,8 @@ class ToManyResponse implements JsonApiResponse { ToManyResponse(this.type, this.id, this.relationship, this.collection); @override - void build(HttpResponseBuilder response) { - response.toManyDocument(collection, type, id, relationship); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.toMany(collection, type, id, relationship); } class ToOneResponse implements JsonApiResponse { @@ -150,7 +131,6 @@ class ToOneResponse implements JsonApiResponse { ToOneResponse(this.type, this.id, this.relationship, this.identifier); @override - void build(HttpResponseBuilder response) { - response.toOneDocument(identifier, type, id, relationship); - } + HttpResponse httpResponse(HttpResponseFactory response) => + response.toOneDocument(identifier, type, id, relationship); } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 9fa9f2d9..8ee5cfd3 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; @@ -9,16 +10,53 @@ import 'package:json_api/src/server/request_factory.dart'; class JsonApiServer implements HttpHandler { @override Future call(HttpRequest httpRequest) async { - final jsonApiRequest = - JsonApiRequestFactory().getJsonApiRequest(httpRequest); + final factory = JsonApiRequestFactory(); + JsonApiRequest jsonApiRequest; + JsonApiResponse jsonApiResponse; + + try { + jsonApiRequest = factory.getJsonApiRequest(httpRequest); + } on FormatException catch (e) { + jsonApiResponse = ErrorResponse.badRequest([ + ErrorObject( + status: '400', + title: 'Bad request', + detail: 'Invalid JSON. ${e.message}') + ]); + } on DocumentException catch (e) { + jsonApiResponse = ErrorResponse.badRequest([ + ErrorObject(status: '400', title: 'Bad request', detail: e.message) + ]); + } on MethodNotAllowedException catch (e) { + jsonApiResponse = ErrorResponse.methodNotAllowed([ + ErrorObject( + status: '405', + title: 'Method Not Allowed', + detail: 'Allowed methods: ${e.allow.join(', ')}') + ], e.allow); + } on InvalidUriException { + jsonApiResponse = ErrorResponse.notFound([ + ErrorObject( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server') + ]); + } on IncompleteRelationshipException { + jsonApiResponse = ErrorResponse.badRequest([ + ErrorObject( + status: '400', + title: 'Bad request', + detail: 'Incomplete relationship object') + ]); + } + // Implementation-specific logic (e.g. auth) goes here - final jsonApiResponse = await jsonApiRequest.call(_controller); - final httpResponse = HttpResponseBuilder(_routing, httpRequest.uri); - jsonApiResponse.build(httpResponse); + jsonApiResponse ??= await jsonApiRequest.call(_controller); // Any response post-processing goes here - return httpResponse.buildHttpResponse(); + return jsonApiResponse + .httpResponse(HttpResponseFactory(_routing, httpRequest.uri)); } JsonApiServer(this._routing, this._controller); diff --git a/lib/src/server/pagination.dart b/lib/src/server/pagination.dart index 667f9c3b..9b5e408e 100644 --- a/lib/src/server/pagination.dart +++ b/lib/src/server/pagination.dart @@ -20,16 +20,11 @@ abstract class Pagination { /// Reference to the first page. Null if not supported or if current page is the first. Page prev(Page page); - - /// No pagination. The server will not be able to produce pagination links. - static Pagination none() => _None(); - - /// Pages of fixed [size]. - static Pagination fixedSize(int size) => _FixedSize(size); } -class _None implements Pagination { - const _None(); +/// No pagination. The server will not be able to produce pagination links. +class NoPagination implements Pagination { + const NoPagination(); @override Page first() => null; @@ -50,10 +45,11 @@ class _None implements Pagination { Page prev(Page page) => null; } -class _FixedSize implements Pagination { +/// Pages of fixed [size]. +class FixedSizePage implements Pagination { final int size; - _FixedSize(this.size) { + FixedSizePage(this.size) { if (size < 1) throw ArgumentError(); } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 1ee855f6..4b157234 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -14,7 +14,7 @@ class RepositoryController implements JsonApiController { final original = await _repo.get(request.type, request.id); if (!original.toMany.containsKey(request.relationship)) { return ErrorResponse.notFound([ - JsonApiError( + ErrorObject( status: '404', title: 'Relationship not found', detail: @@ -194,27 +194,27 @@ class RepositoryController implements JsonApiController { return await action(); } on UnsupportedOperation catch (e) { return ErrorResponse.forbidden([ - JsonApiError( + ErrorObject( status: '403', title: 'Unsupported operation', detail: e.message) ]); } on CollectionNotFound catch (e) { return ErrorResponse.notFound([ - JsonApiError( + ErrorObject( status: '404', title: 'Collection not found', detail: e.message) ]); } on ResourceNotFound catch (e) { return ErrorResponse.notFound([ - JsonApiError( + ErrorObject( status: '404', title: 'Resource not found', detail: e.message) ]); } on InvalidType catch (e) { return ErrorResponse.conflict([ - JsonApiError( + ErrorObject( status: '409', title: 'Invalid resource type', detail: e.message) ]); } on ResourceExists catch (e) { return ErrorResponse.conflict([ - JsonApiError(status: '409', title: 'Resource exists', detail: e.message) + ErrorObject(status: '409', title: 'Resource exists', detail: e.message) ]); } } @@ -223,7 +223,7 @@ class RepositoryController implements JsonApiController { String relationship, ) { return ErrorResponse.notFound([ - JsonApiError( + ErrorObject( status: '404', title: 'Relationship not found', detail: diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart index 458dec8a..60df30a9 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_factory.dart @@ -3,28 +3,10 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/json_api_response.dart'; /// TODO: Extract routing class JsonApiRequestFactory { JsonApiRequest getJsonApiRequest(HttpRequest request) { - try { - return _convert(request); - } on FormatException catch (e) { - return PredefinedResponse(ErrorResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Invalid JSON. ${e.message}') - ])); - } on DocumentException catch (e) { - return PredefinedResponse(ErrorResponse.badRequest([ - JsonApiError(status: '400', title: 'Bad request', detail: e.message) - ])); - } - } - - JsonApiRequest _convert(HttpRequest request) { final s = request.uri.pathSegments; if (s.length == 1) { switch (request.method) { @@ -34,7 +16,7 @@ class JsonApiRequestFactory { return CreateResource( s[0], ResourceData.fromJson(jsonDecode(request.body)).unwrap()); default: - return _methodNotAllowed(['GET', 'POST']); + throw MethodNotAllowedException(allow: ['GET', 'POST']); } } else if (s.length == 2) { switch (request.method) { @@ -46,14 +28,14 @@ class JsonApiRequestFactory { return UpdateResource(s[0], s[1], ResourceData.fromJson(jsonDecode(request.body)).unwrap()); default: - return _methodNotAllowed(['DELETE', 'GET', 'PATCH']); + throw MethodNotAllowedException(allow: ['DELETE', 'GET', 'PATCH']); } } else if (s.length == 3) { switch (request.method) { case 'GET': return FetchRelated(s[0], s[1], s[2], request.uri.queryParametersAll); default: - return _methodNotAllowed(['GET']); + throw MethodNotAllowedException(allow: ['GET']); } } else if (s.length == 4 && s[2] == 'relationships') { switch (request.method) { @@ -71,32 +53,28 @@ class JsonApiRequestFactory { if (rel is ToMany) { return ReplaceToMany(s[0], s[1], s[3], rel.unwrap()); } - return PredefinedResponse(ErrorResponse.badRequest([ - JsonApiError( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ])); + throw IncompleteRelationshipException(); case 'POST': return AddToRelationship(s[0], s[1], s[3], ToMany.fromJson(jsonDecode(request.body)).unwrap()); default: - return _methodNotAllowed(['DELETE', 'GET', 'PATCH', 'POST']); + throw MethodNotAllowedException( + allow: ['DELETE', 'GET', 'PATCH', 'POST']); } } - return PredefinedResponse(ErrorResponse.notFound([ - JsonApiError( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ])); + throw InvalidUriException(); } +} - JsonApiRequest _methodNotAllowed(Iterable allow) => - PredefinedResponse(ErrorResponse.methodNotAllowed([ - JsonApiError( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${allow.join(', ')}') - ], allow: allow)); +/// Thrown if HTTP method is not allowed for the given route +class MethodNotAllowedException implements Exception { + final Iterable allow; + + MethodNotAllowedException({this.allow = const []}); } + +/// Thrown if the request URI can not be matched to a target +class InvalidUriException implements Exception {} + +/// Thrown if the relationship object has no data +class IncompleteRelationshipException implements Exception {} diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart index 717519a7..4cf80bf5 100644 --- a/test/unit/document/json_api_error_test.dart +++ b/test/unit/document/json_api_error_test.dart @@ -6,13 +6,13 @@ import 'package:test/test.dart'; void main() { group('links', () { test('recognizes custom links', () { - final e = JsonApiError( + final e = ErrorObject( links: {'my-link': Link(Uri.parse('http://example.com'))}); expect(e.links['my-link'].toString(), 'http://example.com'); }); test('"links" may contain the "about" key', () { - final e = JsonApiError(links: { + final e = ErrorObject(links: { 'my-link': Link(Uri.parse('http://example.com')), 'about': Link(Uri.parse('/about')) }); @@ -22,10 +22,10 @@ void main() { }); test('custom "links" survives json serialization', () { - final e = JsonApiError( + final e = ErrorObject( links: {'my-link': Link(Uri.parse('http://example.com'))}); expect( - JsonApiError.fromJson(json.decode(json.encode(e))) + ErrorObject.fromJson(json.decode(json.encode(e))) .links['my-link'] .toString(), 'http://example.com'); @@ -35,7 +35,7 @@ void main() { group('fromJson()', () { test('if no links is present, the "links" property is null', () { final e = - JsonApiError.fromJson(json.decode(json.encode((JsonApiError())))); + ErrorObject.fromJson(json.decode(json.encode((ErrorObject())))); expect(e.links, null); expect(e.about, null); }); diff --git a/test/unit/server/numbered_page_test.dart b/test/unit/server/numbered_page_test.dart index 5a6a3eaf..1da56662 100644 --- a/test/unit/server/numbered_page_test.dart +++ b/test/unit/server/numbered_page_test.dart @@ -4,24 +4,24 @@ import 'package:test/test.dart'; void main() { test('page size must be posititve', () { - expect(() => Pagination.fixedSize(0), throwsArgumentError); + expect(() => FixedSizePage(0), throwsArgumentError); }); test('no pages after last', () { final page = Page({'number': '4'}); - final pagination = Pagination.fixedSize(3); + final pagination = FixedSizePage(3); expect(pagination.next(page, 10), isNull); }); test('no pages before first', () { final page = Page({'number': '1'}); - final pagination = Pagination.fixedSize(3); + final pagination = FixedSizePage(3); expect(pagination.prev(page), isNull); }); test('pagination', () { final page = Page({'number': '4'}); - final pagination = Pagination.fixedSize(3); + final pagination = FixedSizePage(3); expect(pagination.prev(page)['number'], '3'); expect(pagination.next(page, 100)['number'], '5'); expect(pagination.first()['number'], '1'); From 7c93212b1389aee8a1b4dc170243cf212314c630 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 19 Feb 2020 00:53:34 -0800 Subject: [PATCH 27/99] wip --- ya.dart | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 ya.dart diff --git a/ya.dart b/ya.dart deleted file mode 100644 index ae423d1f..00000000 --- a/ya.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:http/http.dart'; - -void main() async { - final c = Client(); - final r = await c.get('https://ya.ru'); - r.headers.forEach((k, v) => print('$k : $v\n')); -} \ No newline at end of file From fb868c1285cda0ce78737097ba4c5aefc435fd1a Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 19 Feb 2020 21:20:45 -0800 Subject: [PATCH 28/99] wip --- example/client.dart | 2 +- example/server.dart | 2 +- lib/server.dart | 4 +- lib/src/client/json_api_client.dart | 12 +- lib/src/client/routing_client.dart | 8 +- lib/src/document/identifier.dart | 12 +- lib/src/document/identifier_object.dart | 6 +- lib/src/document/relationship.dart | 12 +- lib/src/document/resource.dart | 14 +- lib/src/document/resource_object.dart | 4 +- ...t_server_handler.dart => dart_server.dart} | 4 +- lib/src/server/json_api_request.dart | 239 ------------------ lib/src/server/json_api_request_handler.dart | 60 +++++ lib/src/server/json_api_response.dart | 47 ++-- lib/src/server/json_api_server.dart | 15 +- lib/src/server/repository_controller.dart | 146 ++++++----- lib/src/server/request.dart | 153 +++++++++++ lib/src/server/request_factory.dart | 4 +- lib/src/server/response_converter.dart | 50 ++++ ...ttp_response_factory.dart => to_http.dart} | 58 ++--- test/e2e/client_server_interaction_test.dart | 4 +- test/functional/compound_document_test.dart | 12 +- .../crud/creating_resources_test.dart | 4 +- test/functional/crud/seed_resources.dart | 4 +- .../crud/updating_relationships_test.dart | 30 +-- .../crud/updating_resources_test.dart | 8 +- test/unit/document/identifier_test.dart | 2 +- test/unit/document/resource_test.dart | 2 +- 28 files changed, 476 insertions(+), 442 deletions(-) rename lib/src/server/{dart_server_handler.dart => dart_server.dart} (92%) delete mode 100644 lib/src/server/json_api_request.dart create mode 100644 lib/src/server/json_api_request_handler.dart create mode 100644 lib/src/server/request.dart create mode 100644 lib/src/server/response_converter.dart rename lib/src/server/{http_response_factory.dart => to_http.dart} (75%) diff --git a/example/client.dart b/example/client.dart index 06b8437d..2e9f003c 100644 --- a/example/client.dart +++ b/example/client.dart @@ -32,7 +32,7 @@ void main() async { await client.createResource(Resource('books', '2', attributes: { 'title': 'Refactoring' }, toMany: { - 'authors': [Identifiers('writers', '1')] + 'authors': [Identifier('writers', '1')] })); /// Fetch the book, including its authors diff --git a/example/server.dart b/example/server.dart index e1819d46..a7246ae6 100644 --- a/example/server.dart +++ b/example/server.dart @@ -35,7 +35,7 @@ void main() async { onResponse: (r) => print('${r.statusCode}')); /// The handler for the built-in HTTP server - final serverHandler = DartServerHandler(loggingJsonApiServer); + final serverHandler = DartServer(loggingJsonApiServer); /// Start the server final server = await HttpServer.bind(address, port); diff --git a/lib/server.dart b/lib/server.dart index 7f2e28e8..fa1b6af1 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -3,11 +3,11 @@ /// The server API is not stable. Expect breaking changes. library server; -export 'package:json_api/src/server/dart_server_handler.dart'; -export 'package:json_api/src/server/http_response_factory.dart'; +export 'package:json_api/src/server/dart_server.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; +export 'package:json_api/src/server/to_http.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 24e4ca02..971bf71a 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -82,7 +82,7 @@ class JsonApiClient { /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOneAt(Uri uri, Identifiers identifier, + Future> replaceToOneAt(Uri uri, Identifier identifier, {Map headers}) => _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); @@ -96,7 +96,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> deleteFromToManyAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -109,7 +109,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> replaceToManyAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -117,7 +117,7 @@ class JsonApiClient { /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> addToRelationshipAt( - Uri uri, Iterable identifiers, + Uri uri, Iterable identifiers, {Map headers}) => _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -131,10 +131,10 @@ class JsonApiClient { Document _resourceDoc(Resource resource) => Document(ResourceData.fromResource(resource), api: _api); - Document _toManyDoc(Iterable identifiers) => + Document _toManyDoc(Iterable identifiers) => Document(ToMany.fromIdentifiers(identifiers), api: _api); - Document _toOneDoc(Identifiers identifier) => + Document _toOneDoc(Identifier identifier) => Document(ToOne.fromIdentifier(identifier), api: _api); HttpRequest _get(Uri uri, Map headers, diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index cb033492..c66b382c 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -74,7 +74,7 @@ class RoutingClient { /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceToOne( - String type, String id, String relationship, Identifiers identifier, + String type, String id, String relationship, Identifier identifier, {Map headers}) => _client.replaceToOneAt(_relationship(type, id, relationship), identifier, headers: headers); @@ -88,7 +88,7 @@ class RoutingClient { /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToManyAt( _relationship(type, id, relationship), identifiers, @@ -96,7 +96,7 @@ class RoutingClient { /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.replaceToManyAt( _relationship(type, id, relationship), identifiers, @@ -104,7 +104,7 @@ class RoutingClient { /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationshipAt( _relationship(type, id, relationship), identifiers, diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 8e0224cd..82b8b848 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -6,7 +6,7 @@ import 'package:json_api/src/document/document_exception.dart'; /// Together with [Resource] forms the core of the Document model. /// Identifiers are passed between the server and the client in the form /// of [IdentifierObject]s. -class Identifiers { +class Identifier { /// Resource type final String type; @@ -14,18 +14,18 @@ class Identifiers { final String id; /// Neither [type] nor [id] can be null or empty. - Identifiers(this.type, this.id) { + Identifier(this.type, this.id) { DocumentException.throwIfNull(id, "Identifier 'id' must not be null"); DocumentException.throwIfNull(type, "Identifier 'type' must not be null"); } - static Identifiers of(Resource resource) => - Identifiers(resource.type, resource.id); + static Identifier of(Resource resource) => + Identifier(resource.type, resource.id); /// Returns true if the two identifiers have the same [type] and [id] - bool equals(Identifiers other) => + bool equals(Identifier other) => other != null && - other.runtimeType == Identifiers && + other.runtimeType == Identifier && other.type == type && other.id == id; diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index f2a84340..a48f1195 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,7 +1,7 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; -/// [IdentifierObject] is a JSON representation of the [Identifiers]. +/// [IdentifierObject] is a JSON representation of the [Identifier]. /// It carries all JSON-related logic and the Meta-data. class IdentifierObject { /// Resource type @@ -21,7 +21,7 @@ class IdentifierObject { ArgumentError.checkNotNull(id); } - static IdentifierObject fromIdentifier(Identifiers identifier, + static IdentifierObject fromIdentifier(Identifier identifier, {Map meta}) => IdentifierObject(identifier.type, identifier.id, meta: meta); @@ -32,7 +32,7 @@ class IdentifierObject { throw DocumentException('A JSON:API identifier must be a JSON object'); } - Identifiers unwrap() => Identifiers(type, id); + Identifier unwrap() => Identifier(type, id); Map toJson() => { 'type': type, diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 7f0dbf93..879d28b3 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -75,7 +75,7 @@ class ToOne extends Relationship { : linkage = null, super(links: links); - static ToOne fromIdentifier(Identifiers identifier) => + static ToOne fromIdentifier(Identifier identifier) => ToOne(nullable(IdentifierObject.fromIdentifier)(identifier)); static ToOne fromJson(Object json) { @@ -97,12 +97,12 @@ class ToOne extends Relationship { ...{'data': linkage} }; - /// Converts to [Identifiers]. + /// Converts to [Identifier]. /// For empty relationships returns null. - Identifiers unwrap() => linkage?.unwrap(); + Identifier unwrap() => linkage?.unwrap(); /// Same as [unwrap()] - Identifiers get identifier => unwrap(); + Identifier get identifier => unwrap(); } /// Relationship to-many @@ -119,7 +119,7 @@ class ToMany extends Relationship { : linkage = List.unmodifiable(linkage), super(included: included, links: links); - static ToMany fromIdentifiers(Iterable identifiers) => + static ToMany fromIdentifiers(Iterable identifiers) => ToMany(identifiers.map(IdentifierObject.fromIdentifier)); static ToMany fromJson(Object json) { @@ -145,5 +145,5 @@ class ToMany extends Relationship { /// Converts to Iterable. /// For empty relationships returns an empty List. - Iterable unwrap() => linkage.map((_) => _.unwrap()); + Iterable unwrap() => linkage.map((_) => _.unwrap()); } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 38f3b422..cd50ad16 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -3,7 +3,7 @@ import 'package:json_api/src/document/identifier.dart'; /// Resource /// -/// Together with [Identifiers] forms the core of the Document model. +/// Together with [Identifier] forms the core of the Document model. /// Resources are passed between the server and the client in the form /// of [ResourceObject]s. class Resource { @@ -19,10 +19,10 @@ class Resource { final Map attributes; /// Unmodifiable map of to-one relationships - final Map toOne; + final Map toOne; /// Unmodifiable map of to-many relationships - final Map> toMany; + final Map> toMany; /// Resource type and id combined String get key => '$type:$id'; @@ -35,8 +35,8 @@ class Resource { /// The [id] may be null for the resources to be created on the server. Resource(this.type, this.id, {Map attributes, - Map toOne, - Map> toMany}) + Map toOne, + Map> toMany}) : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable( @@ -49,7 +49,7 @@ class Resource { class NewResource extends Resource { NewResource(String type, {Map attributes, - Map toOne, - Map> toMany}) + Map toOne, + Map> toMany}) : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); } diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 3f83d911..738df40f 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -83,8 +83,8 @@ class ResourceObject { /// without `data` member. In this case the original [Resource] can not be /// recovered and this method will throw a [StateError]. Resource unwrap() { - final toOne = {}; - final toMany = >{}; + final toOne = {}; + final toMany = >{}; final incomplete = {}; (relationships ?? {}).forEach((name, rel) { if (rel is ToOne) { diff --git a/lib/src/server/dart_server_handler.dart b/lib/src/server/dart_server.dart similarity index 92% rename from lib/src/server/dart_server_handler.dart rename to lib/src/server/dart_server.dart index 78034efe..ccff238e 100644 --- a/lib/src/server/dart_server_handler.dart +++ b/lib/src/server/dart_server.dart @@ -3,10 +3,10 @@ import 'dart:io' as dart; import 'package:json_api/http.dart'; -class DartServerHandler { +class DartServer { final HttpHandler _handler; - DartServerHandler(this._handler); + DartServer(this._handler); Future call(dart.HttpRequest request) async { final response = await _handler(await _convertRequest(request)); diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart deleted file mode 100644 index 27a5e7ba..00000000 --- a/lib/src/server/json_api_request.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -/// The Controller consolidates all possible requests a JSON:API server -/// may handle. Each of the methods is expected to return a -/// [JsonApiResponse] object or a [Future] of it. -/// -/// The response may either be a successful or an error. -abstract class JsonApiController { - /// Finds an returns a primary resource collection. - /// Use [CollectionResponse] to return a successful response. - /// Use [JsonApiResponse.notFound] if the collection does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchCollection(FetchCollection request); - - /// Finds an returns a primary resource. - /// Use [ResourceResponse] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchResource(FetchResource request); - - /// Finds an returns a related resource or a collection of related resources. - /// Use [JsonApiResponse.relatedResource] or [JsonApiResponse.relatedCollection] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. - /// - /// See https://jsonapi.org/format/#fetching-resources - FutureOr fetchRelated(FetchRelated request); - - /// Finds an returns a relationship of a primary resource. - /// Use [ToOneResponse] or [ToManyResponse] to return a successful response. - /// Use [JsonApiResponse.notFound] if the resource or the relationship does not exist. - /// - /// See https://jsonapi.org/format/#fetching-relationships - FutureOr fetchRelationship(FetchRelationship request); - - /// Deletes the resource. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. - /// Use [JsonApiResponse.notFound] if the resource does not exist. - /// - /// See https://jsonapi.org/format/#crud-deleting - FutureOr deleteResource(DeleteResource request); - - /// Creates a new resource in the collection. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. - /// Use [JsonApiResponse.notFound] if the collection does not exist. - /// Use [JsonApiResponse.forbidden] if the server does not support this operation. - /// Use [JsonApiResponse.conflict] if the resource already exists or the collection - /// does not match the [resource] type.. - /// - /// See https://jsonapi.org/format/#crud-creating - FutureOr createResource(CreateResource request); - - /// Updates the resource. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ResourceResponse]. - /// - /// See https://jsonapi.org/format/#crud-updating - FutureOr updateResource(UpdateResource request); - - /// Replaces the to-one relationship. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToOneResponse]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - FutureOr replaceToOne(ReplaceToOne request); - - /// Replaces the to-many relationship. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr replaceToMany(ReplaceToMany request); - - /// Removes the given identifiers from the to-many relationship. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr deleteFromRelationship( - DeleteFromRelationship request); - - /// Adds the given identifiers to the to-many relationship. - /// A successful response may be one of [AcceptedResponse], [NoContentResponse], or [ToManyResponse]. - /// - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - FutureOr addToRelationship(AddToRelationship request); -} - -abstract class JsonApiRequest { - FutureOr call(JsonApiController c); -} - -abstract class QueryParameters { - Map> get queryParameters; - - Include get include => Include.fromQueryParameters(queryParameters); -} - -class FetchCollection with QueryParameters implements JsonApiRequest { - final String type; - - @override - final Map> queryParameters; - - FetchCollection(this.queryParameters, this.type); - - @override - FutureOr call(JsonApiController c) => - c.fetchCollection(this); -} - -class CreateResource implements JsonApiRequest { - final String type; - - final Resource resource; - - CreateResource(this.type, this.resource); - - @override - FutureOr call(JsonApiController c) => c.createResource(this); -} - -class UpdateResource implements JsonApiRequest { - final String type; - final String id; - - final Resource resource; - - UpdateResource(this.type, this.id, this.resource); - - @override - FutureOr call(JsonApiController c) => c.updateResource(this); -} - -class DeleteResource implements JsonApiRequest { - final String type; - - final String id; - - DeleteResource(this.type, this.id); - - @override - FutureOr call(JsonApiController c) => c.deleteResource(this); -} - -class FetchResource with QueryParameters implements JsonApiRequest { - final String type; - final String id; - - @override - final Map> queryParameters; - - FetchResource(this.type, this.id, this.queryParameters); - - @override - FutureOr call(JsonApiController c) => c.fetchResource(this); -} - -class FetchRelated with QueryParameters implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - final Map> queryParameters; - - FetchRelated(this.type, this.id, this.relationship, this.queryParameters); - - @override - FutureOr call(JsonApiController c) => c.fetchRelated(this); -} - -class FetchRelationship with QueryParameters implements JsonApiRequest { - final String type; - final String id; - final String relationship; - - @override - final Map> queryParameters; - - FetchRelationship( - this.type, this.id, this.relationship, this.queryParameters); - - @override - FutureOr call(JsonApiController c) => - c.fetchRelationship(this); -} - -class DeleteFromRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - final Iterable identifiers; - - DeleteFromRelationship( - this.type, this.id, this.relationship, this.identifiers); - - @override - FutureOr call(JsonApiController c) => - c.deleteFromRelationship(this); -} - -class ReplaceToOne implements JsonApiRequest { - final String type; - final String id; - final String relationship; - final Identifiers identifier; - - ReplaceToOne(this.type, this.id, this.relationship, this.identifier); - - @override - FutureOr call(JsonApiController c) => c.replaceToOne(this); -} - -class ReplaceToMany implements JsonApiRequest { - final String type; - final String id; - final String relationship; - final Iterable identifiers; - - ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); - - @override - FutureOr call(JsonApiController c) => c.replaceToMany(this); -} - -class AddToRelationship implements JsonApiRequest { - final String type; - final String id; - final String relationship; - final Iterable identifiers; - - AddToRelationship(this.type, this.id, this.relationship, this.identifiers); - - @override - FutureOr call(JsonApiController c) => - c.addToRelationship(this); -} diff --git a/lib/src/server/json_api_request_handler.dart b/lib/src/server/json_api_request_handler.dart new file mode 100644 index 00000000..52b285d2 --- /dev/null +++ b/lib/src/server/json_api_request_handler.dart @@ -0,0 +1,60 @@ +import 'package:json_api/document.dart'; + +/// This is a controller consolidating all possible requests a JSON:API server +/// may handle. +abstract class JsonApiRequestHandler { + /// Finds an returns a primary resource collection. + /// See https://jsonapi.org/format/#fetching-resources + T fetchCollection( + final String type, final Map> queryParameters); + + /// Finds an returns a primary resource. + /// See https://jsonapi.org/format/#fetching-resources + T fetchResource(final String type, final String id, + final Map> queryParameters); + + /// Finds an returns a related resource or a collection of related resources. + /// See https://jsonapi.org/format/#fetching-resources + T fetchRelated(final String type, final String id, final String relationship, + final Map> queryParameters); + + /// Finds an returns a relationship of a primary resource. + /// See https://jsonapi.org/format/#fetching-relationships + T fetchRelationship( + final String type, + final String id, + final String relationship, + final Map> queryParameters); + + /// Deletes the resource. + /// See https://jsonapi.org/format/#crud-deleting + T deleteResource(String type, String id); + + /// Creates a new resource in the collection. + /// See https://jsonapi.org/format/#crud-creating + T createResource(String type, Resource resource); + + /// Updates the resource. + /// See https://jsonapi.org/format/#crud-updating + T updateResource(String type, String id, Resource resource); + + /// Replaces the to-one relationship. + /// See https://jsonapi.org/format/#crud-updating-to-one-relationships + T replaceToOne(final String type, final String id, final String relationship, + final Identifier identifier); + + /// Replaces the to-many relationship. + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + T replaceToMany(final String type, final String id, final String relationship, + final Iterable identifiers); + + /// Removes the given identifiers from the to-many relationship. + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + T deleteFromRelationship(final String type, final String id, + final String relationship, final Iterable identifiers); + + /// Adds the given identifiers to the to-many relationship. + /// See https://jsonapi.org/format/#crud-updating-to-many-relationships + T addToRelationship(final String type, final String id, + final String relationship, final Iterable identifiers); +} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 4a105e6c..ea02f043 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,15 +1,15 @@ import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/server/http_response_factory.dart'; +import 'package:json_api/src/server/response_converter.dart'; +/// the base interface for all JSON:API responses abstract class JsonApiResponse { - HttpResponse httpResponse(HttpResponseFactory response); + /// Converts the JSON:API response to another object, e.g. HTTP response. + T convert(ResponseConverter converter); } class NoContentResponse implements JsonApiResponse { @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.noContent(); + T convert(ResponseConverter converter) => converter.noContent(); } class CollectionResponse implements JsonApiResponse { @@ -20,8 +20,8 @@ class CollectionResponse implements JsonApiResponse { CollectionResponse(this.collection, {this.included, this.total}); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.collection(collection, included: included, total: total); + T convert(ResponseConverter converter) => + converter.collection(collection, included: included, total: total); } class AcceptedResponse implements JsonApiResponse { @@ -30,8 +30,7 @@ class AcceptedResponse implements JsonApiResponse { AcceptedResponse(this.resource); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.accepted(resource); + T convert(ResponseConverter converter) => converter.accepted(resource); } class ErrorResponse implements JsonApiResponse { @@ -61,8 +60,8 @@ class ErrorResponse implements JsonApiResponse { ErrorResponse(501, errors); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.error(errors, statusCode, _headers); + T convert(ResponseConverter converter) => + converter.error(errors, statusCode, _headers); final _headers = {}; } @@ -73,8 +72,7 @@ class MetaResponse implements JsonApiResponse { MetaResponse(this.meta); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.meta(meta); + T convert(ResponseConverter converter) => converter.meta(meta); } class ResourceResponse implements JsonApiResponse { @@ -84,8 +82,8 @@ class ResourceResponse implements JsonApiResponse { ResourceResponse(this.resource, {this.included}); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.resource(resource, included: included); + T convert(ResponseConverter converter) => + converter.resource(resource, included: included); } class ResourceCreatedResponse implements JsonApiResponse { @@ -94,8 +92,8 @@ class ResourceCreatedResponse implements JsonApiResponse { ResourceCreatedResponse(this.resource); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.resourceCreated(resource); + T convert(ResponseConverter converter) => + converter.resourceCreated(resource); } class SeeOtherResponse implements JsonApiResponse { @@ -105,12 +103,11 @@ class SeeOtherResponse implements JsonApiResponse { SeeOtherResponse(this.type, this.id); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.seeOther(type, id); + T convert(ResponseConverter converter) => converter.seeOther(type, id); } class ToManyResponse implements JsonApiResponse { - final Iterable collection; + final Iterable collection; final String type; final String id; final String relationship; @@ -118,19 +115,19 @@ class ToManyResponse implements JsonApiResponse { ToManyResponse(this.type, this.id, this.relationship, this.collection); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.toMany(collection, type, id, relationship); + T convert(ResponseConverter converter) => + converter.toMany(type, id, relationship, collection); } class ToOneResponse implements JsonApiResponse { final String type; final String id; final String relationship; - final Identifiers identifier; + final Identifier identifier; ToOneResponse(this.type, this.id, this.relationship, this.identifier); @override - HttpResponse httpResponse(HttpResponseFactory response) => - response.toOneDocument(identifier, type, id, relationship); + T convert(ResponseConverter converter) => + converter.toOne(identifier, type, id, relationship); } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 8ee5cfd3..a86f34a7 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -4,18 +4,19 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/json_api_request_handler.dart'; import 'package:json_api/src/server/request_factory.dart'; +/// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { @override Future call(HttpRequest httpRequest) async { - final factory = JsonApiRequestFactory(); - JsonApiRequest jsonApiRequest; + Request jsonApiRequest; JsonApiResponse jsonApiResponse; try { - jsonApiRequest = factory.getJsonApiRequest(httpRequest); + jsonApiRequest = JsonApiRequestFactory().createFromHttp(httpRequest); } on FormatException catch (e) { jsonApiResponse = ErrorResponse.badRequest([ ErrorObject( @@ -52,15 +53,15 @@ class JsonApiServer implements HttpHandler { // Implementation-specific logic (e.g. auth) goes here - jsonApiResponse ??= await jsonApiRequest.call(_controller); + jsonApiResponse ??= await jsonApiRequest.handleWith(_controller); // Any response post-processing goes here return jsonApiResponse - .httpResponse(HttpResponseFactory(_routing, httpRequest.uri)); + .convert(HttpResponseFactory(_routing, httpRequest.uri)); } JsonApiServer(this._routing, this._controller); final Routing _routing; - final JsonApiController _controller; + final JsonApiRequestHandler> _controller; } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 4b157234..c39bc268 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,75 +1,82 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/json_api_request_handler.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; -/// An opinionated implementation of [JsonApiController] -class RepositoryController implements JsonApiController { +/// An opinionated implementation of [JsonApiRequestHandler] +class RepositoryController + implements JsonApiRequestHandler> { @override - FutureOr addToRelationship(AddToRelationship request) => + FutureOr addToRelationship( + final String type, + final String id, + final String relationship, + final Iterable identifiers) => _do(() async { - final original = await _repo.get(request.type, request.id); - if (!original.toMany.containsKey(request.relationship)) { + final original = await _repo.get(type, id); + if (!original.toMany.containsKey(relationship)) { return ErrorResponse.notFound([ ErrorObject( status: '404', title: 'Relationship not found', detail: - "There is no to-many relationship '${request.relationship}' in this resource") + "There is no to-many relationship '${relationship}' in this resource") ]); } final updated = await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, toMany: { - request.relationship: { - ...original.toMany[request.relationship], - ...request.identifiers - } + type, + id, + Resource(type, id, toMany: { + relationship: {...original.toMany[relationship], ...identifiers} })); - return ToManyResponse(request.type, request.id, request.relationship, - updated.toMany[request.relationship]); + return ToManyResponse( + type, id, relationship, updated.toMany[relationship]); }); @override - FutureOr createResource(CreateResource request) => + FutureOr createResource(String type, Resource resource) => _do(() async { - final modified = await _repo.create(request.type, request.resource); + final modified = await _repo.create(type, resource); if (modified == null) return NoContentResponse(); return ResourceCreatedResponse(modified); }); @override FutureOr deleteFromRelationship( - DeleteFromRelationship request) => + final String type, + final String id, + final String relationship, + final Iterable identifiers) => _do(() async { - final original = await _repo.get(request.type, request.id); + final original = await _repo.get(type, id); final updated = await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, toMany: { - request.relationship: {...original.toMany[request.relationship]} - ..removeAll(request.identifiers) + type, + id, + Resource(type, id, toMany: { + relationship: {...original.toMany[relationship]} + ..removeAll(identifiers) })); - return ToManyResponse(request.type, request.id, request.relationship, - updated.toMany[request.relationship]); + return ToManyResponse( + type, id, relationship, updated.toMany[relationship]); }); @override - FutureOr deleteResource(DeleteResource request) => + FutureOr deleteResource(String type, String id) => _do(() async { - await _repo.delete(request.type, request.id); + await _repo.delete(type, id); return NoContentResponse(); }); @override - FutureOr fetchCollection(FetchCollection request) => + FutureOr fetchCollection( + final String type, final Map> queryParameters) => _do(() async { - final c = await _repo.getCollection(request.type); - final include = request.include; + final c = await _repo.getCollection(type); + final include = Include.fromQueryParameters(queryParameters); final resources = []; for (final resource in c.elements) { @@ -83,42 +90,52 @@ class RepositoryController implements JsonApiController { }); @override - FutureOr fetchRelated(FetchRelated request) => _do(() async { - final resource = await _repo.get(request.type, request.id); - if (resource.toOne.containsKey(request.relationship)) { + FutureOr fetchRelated( + final String type, + final String id, + final String relationship, + final Map> queryParameters) => + _do(() async { + final resource = await _repo.get(type, id); + if (resource.toOne.containsKey(relationship)) { return ResourceResponse( - await _getByIdentifier(resource.toOne[request.relationship])); + await _getByIdentifier(resource.toOne[relationship])); } - if (resource.toMany.containsKey(request.relationship)) { + if (resource.toMany.containsKey(relationship)) { final related = []; - for (final identifier in resource.toMany[request.relationship]) { + for (final identifier in resource.toMany[relationship]) { related.add(await _getByIdentifier(identifier)); } return CollectionResponse(related); } - return _relationshipNotFound(request.relationship); + return _relationshipNotFound(relationship); }); @override - FutureOr fetchRelationship(FetchRelationship request) => + FutureOr fetchRelationship( + final String type, + final String id, + final String relationship, + final Map> queryParameters) => _do(() async { - final resource = await _repo.get(request.type, request.id); - if (resource.toOne.containsKey(request.relationship)) { - return ToOneResponse(request.type, request.id, request.relationship, - resource.toOne[request.relationship]); + final resource = await _repo.get(type, id); + if (resource.toOne.containsKey(relationship)) { + return ToOneResponse( + type, id, relationship, resource.toOne[relationship]); } - if (resource.toMany.containsKey(request.relationship)) { - return ToManyResponse(request.type, request.id, request.relationship, - resource.toMany[request.relationship]); + if (resource.toMany.containsKey(relationship)) { + return ToManyResponse( + type, id, relationship, resource.toMany[relationship]); } - return _relationshipNotFound(request.relationship); + return _relationshipNotFound(relationship); }); @override - FutureOr fetchResource(FetchResource request) => + FutureOr fetchResource(final String type, final String id, + final Map> queryParameters) => _do(() async { - final include = request.include; - final resource = await _repo.get(request.type, request.id); + final include = Include.fromQueryParameters(queryParameters); + final resource = await _repo.get(type, id); final resources = []; for (final path in include) { resources.addAll(await _getRelated(resource, path.split('.'))); @@ -128,32 +145,29 @@ class RepositoryController implements JsonApiController { }); @override - FutureOr replaceToMany(ReplaceToMany request) => + FutureOr replaceToMany(final String type, final String id, + final String relationship, final Iterable identifiers) => _do(() async { await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, - toMany: {request.relationship: request.identifiers})); + type, id, Resource(type, id, toMany: {relationship: identifiers})); return NoContentResponse(); }); @override - FutureOr updateResource(UpdateResource request) => + FutureOr updateResource( + String type, String id, Resource resource) => _do(() async { - final modified = - await _repo.update(request.type, request.id, request.resource); + final modified = await _repo.update(type, id, resource); if (modified == null) return NoContentResponse(); return ResourceResponse(modified); }); @override - FutureOr replaceToOne(ReplaceToOne request) => _do(() async { + FutureOr replaceToOne(final String type, final String id, + final String relationship, final Identifier identifier) => + _do(() async { await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, - toOne: {request.relationship: request.identifier})); + type, id, Resource(type, id, toOne: {relationship: identifier})); return NoContentResponse(); }); @@ -161,7 +175,7 @@ class RepositoryController implements JsonApiController { final Repository _repo; - FutureOr _getByIdentifier(Identifiers identifier) => + FutureOr _getByIdentifier(Identifier identifier) => _repo.get(identifier.type, identifier.id); Future> _getRelated( @@ -170,7 +184,7 @@ class RepositoryController implements JsonApiController { ) async { if (path.isEmpty) return []; final resources = []; - final ids = []; + final ids = []; if (resource.toOne.containsKey(path.first)) { ids.add(resource.toOne[path.first]); diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart new file mode 100644 index 00000000..284a63f3 --- /dev/null +++ b/lib/src/server/request.dart @@ -0,0 +1,153 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/json_api_request_handler.dart'; + +/// A base interface for JSON:API requests. +abstract class Request { + /// Calls the appropriate method of [controller] and returns the response + T handleWith(JsonApiRequestHandler controller); +} + +/// A request to fetch a collection of type [type]. +class FetchCollection implements Request { + final String type; + + final Map> queryParameters; + + FetchCollection(this.queryParameters, this.type); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.fetchCollection(type, queryParameters); +} + +class CreateResource implements Request { + final String type; + + final Resource resource; + + CreateResource(this.type, this.resource); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.createResource(type, resource); +} + +class UpdateResource implements Request { + final String type; + final String id; + + final Resource resource; + + UpdateResource(this.type, this.id, this.resource); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.updateResource(type, id, resource); +} + +class DeleteResource implements Request { + final String type; + + final String id; + + DeleteResource(this.type, this.id); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.deleteResource(type, id); +} + +class FetchResource implements Request { + final String type; + final String id; + + final Map> queryParameters; + + FetchResource(this.type, this.id, this.queryParameters); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.fetchResource(type, id, queryParameters); +} + +class FetchRelated implements Request { + final String type; + final String id; + final String relationship; + + final Map> queryParameters; + + FetchRelated(this.type, this.id, this.relationship, this.queryParameters); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.fetchRelated(type, id, relationship, queryParameters); +} + +class FetchRelationship implements Request { + final String type; + final String id; + final String relationship; + + final Map> queryParameters; + + FetchRelationship( + this.type, this.id, this.relationship, this.queryParameters); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.fetchRelationship(type, id, relationship, queryParameters); +} + +class DeleteFromRelationship implements Request { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + DeleteFromRelationship( + this.type, this.id, this.relationship, this.identifiers); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.deleteFromRelationship(type, id, relationship, identifiers); +} + +class ReplaceToOne implements Request { + final String type; + final String id; + final String relationship; + final Identifier identifier; + + ReplaceToOne(this.type, this.id, this.relationship, this.identifier); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.replaceToOne(type, id, relationship, identifier); +} + +class ReplaceToMany implements Request { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.replaceToMany(type, id, relationship, identifiers); +} + +class AddToRelationship implements Request { + final String type; + final String id; + final String relationship; + final Iterable identifiers; + + AddToRelationship(this.type, this.id, this.relationship, this.identifiers); + + @override + T handleWith(JsonApiRequestHandler controller) => + controller.addToRelationship(type, id, relationship, identifiers); +} diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart index 60df30a9..edc67b69 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_factory.dart @@ -2,11 +2,11 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/request.dart'; /// TODO: Extract routing class JsonApiRequestFactory { - JsonApiRequest getJsonApiRequest(HttpRequest request) { + Request createFromHttp(HttpRequest request) { final s = request.uri.pathSegments; if (s.length == 1) { switch (request.method) { diff --git a/lib/src/server/response_converter.dart b/lib/src/server/response_converter.dart new file mode 100644 index 00000000..b8d3a38b --- /dev/null +++ b/lib/src/server/response_converter.dart @@ -0,0 +1,50 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/pagination.dart'; + +/// Converts JsonApi Controller responses to other responses, e.g. HTTP +abstract class ResponseConverter { + /// A document containing a list of errors + T error(Iterable errors, int statusCode, + Map headers); + + /// A document containing a collection of resources + T collection(Iterable collection, + {int total, Iterable included, Pagination pagination}); + + /// HTTP 202 Accepted response + T accepted(Resource resource); + + /// HTTP 200 with a document containing just a meta member + T meta(Map meta); + + /// HTTP 200 with a document containing a single resource + T resource(Resource resource, {Iterable included}); + + /// HTTP 200 with a document containing a single (primary) resource which has been created + /// on the server. The difference with [resource] is that this + /// method generates the `self` link to match the `location` header. + /// + /// This is the quote from the documentation: + /// > If the resource object returned by the response contains a self key + /// > in its links member and a Location header is provided, the value of + /// > the self member MUST match the value of the Location header. + /// + /// See https://jsonapi.org/format/#crud-creating-responses-201 + T resourceCreated(Resource resource); + + /// HTTP 303 See Other response with the Location header pointing + /// to another resource + T seeOther(String type, String id); + + /// HTTP 200 with a document containing a to-many relationship + T toMany(String type, String id, String relationship, + Iterable identifiers, + {Iterable included}); + + /// HTTP 200 with a document containing a to-one relationship + T toOne(Identifier identifier, String type, String id, String relationship, + {Iterable included}); + + /// The HTTP 204 No Content response + T noContent(); +} diff --git a/lib/src/server/http_response_factory.dart b/lib/src/server/to_http.dart similarity index 75% rename from lib/src/server/http_response_factory.dart rename to lib/src/server/to_http.dart index e6168131..64f74132 100644 --- a/lib/src/server/http_response_factory.dart +++ b/lib/src/server/to_http.dart @@ -6,20 +6,22 @@ import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/query/page.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/response_converter.dart'; -class HttpResponseFactory { - /// A document containing a list of errors +/// An implementation of [ResponseConverter] converting to [HttpResponse]. +class HttpResponseFactory implements ResponseConverter { + @override HttpResponse error(Iterable errors, int statusCode, Map headers) => - _doc(Document.error(errors, api: _api), + _document(Document.error(errors, api: _api), status: statusCode, headers: headers); - /// A document containing a collection of resources + @override HttpResponse collection(Iterable collection, {int total, Iterable included, Pagination pagination = const NoPagination()}) { - return _doc(Document( + return _document(Document( ResourceCollectionData(collection.map(_resourceObject), links: { 'self': Link(_self), @@ -29,8 +31,9 @@ class HttpResponseFactory { api: _api)); } + @override HttpResponse accepted(Resource resource) => - _doc( + _document( Document( ResourceData(_resourceObject(resource), links: {'self': Link(_self)}), @@ -41,29 +44,20 @@ class HttpResponseFactory { _routing.resource(resource.type, resource.id).toString() }); - /// A document containing just a meta member + @override HttpResponse meta(Map meta) => - _doc(Document.empty(meta, api: _api)); + _document(Document.empty(meta, api: _api)); - /// A document containing a single resource + @override HttpResponse resource(Resource resource, {Iterable included}) => - _doc(Document( + _document(Document( ResourceData(_resourceObject(resource), links: {'self': Link(_self)}, included: included?.map(_resourceObject)), api: _api)); - /// A document containing a single (primary) resource which has been created - /// on the server. The difference with [resource] is that this - /// method generates the `self` link to match the `location` header. - /// - /// This is the quote from the documentation: - /// > If the resource object returned by the response contains a self key - /// > in its links member and a Location header is provided, the value of - /// > the self member MUST match the value of the Location header. - /// - /// See https://jsonapi.org/format/#crud-creating-responses-201 - HttpResponse resourceCreated(Resource resource) => _doc( + @override + HttpResponse resourceCreated(Resource resource) => _document( Document( ResourceData(_resourceObject(resource), links: { 'self': Link(_routing.resource(resource.type, resource.id)) @@ -74,13 +68,15 @@ class HttpResponseFactory { 'Location': _routing.resource(resource.type, resource.id).toString() }); + @override HttpResponse seeOther(String type, String id) => HttpResponse(303, headers: {'Location': _routing.resource(type, id).toString()}); - /// A document containing a to-many relationship - HttpResponse toMany(Iterable identifiers, String type, String id, - String relationship) => - _doc(Document( + @override + HttpResponse toMany(String type, String id, String relationship, + Iterable identifiers, + {Iterable included}) => + _document(Document( ToMany( identifiers.map(IdentifierObject.fromIdentifier), links: { @@ -90,10 +86,11 @@ class HttpResponseFactory { ), api: _api)); - /// A document containing a to-one relationship - HttpResponse toOneDocument(Identifiers identifier, String type, String id, - String relationship) => - _doc(Document( + @override + HttpResponse toOne( + Identifier identifier, String type, String id, String relationship, + {Iterable included}) => + _document(Document( ToOne( nullable(IdentifierObject.fromIdentifier)(identifier), links: { @@ -103,6 +100,7 @@ class HttpResponseFactory { ), api: _api)); + @override HttpResponse noContent() => HttpResponse(204); HttpResponseFactory(this._routing, this._self); @@ -111,7 +109,7 @@ class HttpResponseFactory { final Routing _routing; final Api _api = Api(version: '1.0'); - HttpResponse _doc(Document d, + HttpResponse _document(Document d, {int status = 200, Map headers = const {}}) => HttpResponse(status, body: jsonEncode(d), diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index b62560bb..f62526f4 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -18,7 +18,7 @@ void main() { StandardRouting(Uri(host: host, port: port, scheme: 'http')); final repo = InMemoryRepository({'writers': {}, 'books': {}}); final jsonApiServer = JsonApiServer(routing, RepositoryController(repo)); - final serverHandler = DartServerHandler(jsonApiServer); + final serverHandler = DartServer(jsonApiServer); Client httpClient; RoutingClient client; HttpServer server; @@ -45,7 +45,7 @@ void main() { await client .updateResource(Resource('books', '2', toMany: {'authors': []})); await client.addToRelationship( - 'books', '2', 'authors', [Identifiers('writers', '1')]); + 'books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 26b6b27f..8c2f61e3 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -20,22 +20,22 @@ void main() async { Resource('countries', '1', attributes: {'name': 'Wonderland'}); final alice = Resource('people', '1', attributes: {'name': 'Alice'}, - toOne: {'birthplace': Identifiers.of(wonderland)}); + toOne: {'birthplace': Identifier.of(wonderland)}); final bob = Resource('people', '2', attributes: {'name': 'Bob'}, - toOne: {'birthplace': Identifiers.of(wonderland)}); + toOne: {'birthplace': Identifier.of(wonderland)}); final comment1 = Resource('comments', '1', attributes: {'text': 'First comment!'}, - toOne: {'author': Identifiers.of(bob)}); + toOne: {'author': Identifier.of(bob)}); final comment2 = Resource('comments', '2', attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': Identifiers.of(alice)}); + toOne: {'author': Identifier.of(alice)}); final post = Resource('posts', '1', attributes: { 'title': 'Hello World' }, toOne: { - 'author': Identifiers.of(alice) + 'author': Identifier.of(alice) }, toMany: { - 'comments': [Identifiers.of(comment1), Identifiers.of(comment2)], + 'comments': [Identifier.of(comment1), Identifier.of(comment2)], 'tags': [] }); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 80710f5c..aa9e70ff 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -98,7 +98,7 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { final book = Resource('books', null, - toOne: {'publisher': Identifiers('companies', '123')}); + toOne: {'publisher': Identifier('companies', '123')}); final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); @@ -112,7 +112,7 @@ void main() async { test('404 when the related resource does not exist (to-many)', () async { final book = Resource('books', null, toMany: { - 'authors': [Identifiers('people', '123')] + 'authors': [Identifier('people', '123')] }); final r = await routingClient.createResource(book); expect(r.isSuccessful, isFalse); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index a40f0f72..bf0cdc2a 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -16,8 +16,8 @@ Future seedResources(RoutingClient client) async { 'title': 'Refactoring', 'ISBN-10': '0134757599' }, toOne: { - 'publisher': Identifiers('companies', '1') + 'publisher': Identifier('companies', '1') }, toMany: { - 'authors': [Identifiers('people', '1'), Identifiers('people', '2')] + 'authors': [Identifier('people', '1'), Identifier('people', '2')] })); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index e9d5801d..3c878193 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -31,7 +31,7 @@ void main() async { group('Updatng a to-one relationship', () { test('204 No Content', () async { final r = await routingClient.replaceToOne( - 'books', '1', 'publisher', Identifiers('companies', '2')); + 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); @@ -42,7 +42,7 @@ void main() async { test('404 on collection', () async { final r = await routingClient.replaceToOne( - 'unicorns', '1', 'breed', Identifiers('companies', '2')); + 'unicorns', '1', 'breed', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -54,7 +54,7 @@ void main() async { test('404 on resource', () async { final r = await routingClient.replaceToOne( - 'books', '42', 'publisher', Identifiers('companies', '2')); + 'books', '42', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -103,7 +103,7 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { final r = await routingClient - .replaceToMany('books', '1', 'authors', [Identifiers('people', '1')]); + .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 204); expect(r.data, isNull); @@ -115,7 +115,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.replaceToMany( - 'unicorns', '1', 'breed', [Identifiers('companies', '2')]); + 'unicorns', '1', 'breed', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -127,7 +127,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.replaceToMany( - 'books', '42', 'publisher', [Identifiers('companies', '2')]); + 'books', '42', 'publisher', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -141,7 +141,7 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifiers('people', '3')]); + 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 3); @@ -154,7 +154,7 @@ void main() async { test('successfully adding an existing identifier', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifiers('people', '2')]); + 'books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -167,7 +167,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.addToRelationship( - 'unicorns', '1', 'breed', [Identifiers('companies', '3')]); + 'unicorns', '1', 'breed', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -179,7 +179,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.addToRelationship( - 'books', '42', 'publisher', [Identifiers('companies', '3')]); + 'books', '42', 'publisher', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -191,7 +191,7 @@ void main() async { test('404 when relationship not found', () async { final r = await routingClient.addToRelationship( - 'books', '1', 'sellers', [Identifiers('companies', '3')]); + 'books', '1', 'sellers', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -206,7 +206,7 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifiers('people', '1')]); + 'books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 1); @@ -218,7 +218,7 @@ void main() async { test('successfully deleting a non-present identifier', () async { final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifiers('people', '3')]); + 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -231,7 +231,7 @@ void main() async { test('404 when collection not found', () async { final r = await routingClient.deleteFromToMany( - 'unicorns', '1', 'breed', [Identifiers('companies', '1')]); + 'unicorns', '1', 'breed', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); @@ -243,7 +243,7 @@ void main() async { test('404 when resource not found', () async { final r = await routingClient.deleteFromToMany( - 'books', '42', 'publisher', [Identifiers('companies', '1')]); + 'books', '42', 'publisher', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.data, isNull); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index ac704dd4..0fabe12a 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -36,8 +36,8 @@ void main() async { }, toOne: { 'publisher': null }, toMany: { - 'authors': [Identifiers('people', '1')], - 'reviewers': [Identifiers('people', '2')] + 'authors': [Identifier('people', '1')], + 'reviewers': [Identifier('people', '2')] })); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); @@ -47,9 +47,9 @@ void main() async { expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); expect(r.data.unwrap().toOne['publisher'], isNull); expect( - r.data.unwrap().toMany['authors'], equals([Identifiers('people', '1')])); + r.data.unwrap().toMany['authors'], equals([Identifier('people', '1')])); expect(r.data.unwrap().toMany['reviewers'], - equals([Identifiers('people', '2')])); + equals([Identifier('people', '2')])); final r1 = await routingClient.fetchResource('books', '1'); expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); diff --git a/test/unit/document/identifier_test.dart b/test/unit/document/identifier_test.dart index 3e979493..05d485c4 100644 --- a/test/unit/document/identifier_test.dart +++ b/test/unit/document/identifier_test.dart @@ -3,6 +3,6 @@ import 'package:test/test.dart'; void main() { test('equal identifiers are detected by Set', () { - expect({Identifiers('foo', '1'), Identifiers('foo', '1')}.length, 1); + expect({Identifier('foo', '1'), Identifier('foo', '1')}.length, 1); }); } diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index bb69a88f..0a4418eb 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('Removes duplicate identifiers in toMany relationships', () { final r = Resource('type', 'id', toMany: { - 'rel': [Identifiers('foo', '1'), Identifiers('foo', '1')] + 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] }); expect(r.toMany['rel'].length, 1); }); From 8ab14e974580455a3fc6b520394237e0a348181d Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 20 Feb 2020 00:00:32 -0800 Subject: [PATCH 29/99] wip --- .gitignore | 6 +- example/server.dart | 13 +-- lib/routing.dart | 61 ++---------- lib/server.dart | 2 +- lib/src/client/routing_client.dart | 2 +- lib/src/routing/collection_route.dart | 5 + lib/src/routing/composite_routing.dart | 46 +++++++++ lib/src/routing/relationship_route.dart | 7 ++ lib/src/routing/resource_route.dart | 5 + lib/src/routing/route_factory.dart | 18 ++++ lib/src/routing/route_matcher.dart | 11 +++ lib/src/routing/routing.dart | 4 + lib/src/routing/standard_routes.dart | 90 ++++++++++++++++++ lib/src/routing/standard_routing.dart | 8 ++ lib/src/server/json_api_server.dart | 24 ++--- lib/src/server/repository_controller.dart | 47 ++++------ lib/src/server/request.dart | 26 ++--- lib/src/server/request_factory.dart | 94 ++++++++++++------- ...uest_handler.dart => request_handler.dart} | 2 +- .../{json_api_response.dart => response.dart} | 34 +++---- lib/src/server/to_http.dart | 6 +- pubspec.yaml | 2 +- test/e2e/client_server_interaction_test.dart | 2 +- test/functional/async_processing_test.dart | 79 ++++++++++++++++ test/functional/compound_document_test.dart | 2 +- .../crud/creating_resources_test.dart | 6 +- .../crud/deleting_resources_test.dart | 2 +- .../crud/fetching_relationships_test.dart | 2 +- .../crud/fetching_resources_test.dart | 25 +++-- .../crud/updating_relationships_test.dart | 5 +- .../crud/updating_resources_test.dart | 7 +- test/helper/shelf_adapter.dart | 18 ---- test/unit/document/json_api_error_test.dart | 3 +- test/unit/server/json_api_server_test.dart | 9 +- 34 files changed, 447 insertions(+), 226 deletions(-) create mode 100644 lib/src/routing/collection_route.dart create mode 100644 lib/src/routing/composite_routing.dart create mode 100644 lib/src/routing/relationship_route.dart create mode 100644 lib/src/routing/resource_route.dart create mode 100644 lib/src/routing/route_factory.dart create mode 100644 lib/src/routing/route_matcher.dart create mode 100644 lib/src/routing/routing.dart create mode 100644 lib/src/routing/standard_routes.dart create mode 100644 lib/src/routing/standard_routing.dart rename lib/src/server/{json_api_request_handler.dart => request_handler.dart} (98%) rename lib/src/server/{json_api_response.dart => response.dart} (75%) create mode 100644 test/functional/async_processing_test.dart delete mode 100644 test/helper/shelf_adapter.dart diff --git a/.gitignore b/.gitignore index adf2127e..5ea50228 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,6 @@ pubspec.lock doc/api/ # Generated by test_coverage -/test/.test_coverage.dart -/coverage/ -/coverage_badge.svg +test/.test_coverage.dart +coverage/ +coverage_badge.svg diff --git a/example/server.dart b/example/server.dart index a7246ae6..8d975a9a 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; /// This example shows how to run a simple JSON:API server using the built-in @@ -14,20 +13,14 @@ void main() async { /// Listening on the localhost final address = 'localhost'; - /// Base URI to let the URI design detect the request target properly - final base = Uri(host: address, port: port, scheme: 'http'); - - /// Use the standard URI design - final routing = StandardRouting(base); - /// Resource repository supports two kind of entities: writers and books final repo = InMemoryRepository({'writers': {}, 'books': {}}); /// Controller provides JSON:API interface to the repository final controller = RepositoryController(repo); - /// The JSON:API server uses the given URI design to route requests to the controller - final jsonApiServer = JsonApiServer(routing, controller); + /// The JSON:API server routes requests to the controller + final jsonApiServer = JsonApiServer(controller); /// We will be logging the requests and responses to the console final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, @@ -39,7 +32,7 @@ void main() async { /// Start the server final server = await HttpServer.bind(address, port); - print('Listening on $base'); + print('Listening on ${Uri(host: address, port: port, scheme: 'http')}'); /// Each HTTP request will be processed by the handler await server.forEach(serverHandler); diff --git a/lib/routing.dart b/lib/routing.dart index 2d7b7bf4..17cf95b3 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,52 +1,9 @@ -/// Makes URIs for specific targets -abstract class Routing { - /// 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); -} - -class StandardRouting implements Routing { - /// Returns a URL for the primary resource collection of type [type] - @override - Uri collection(String type) => _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. - @override - Uri related(String type, String id, String relationship) => - _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. - @override - Uri relationship(String type, String id, String relationship) => - _appendToBase([type, id, _relationships, relationship]); - - /// Returns a URL for the primary resource of type [type] with id [id] - @override - Uri resource(String type, String id) => _appendToBase([type, id]); - - const StandardRouting(this._base); - - static const _relationships = 'relationships'; - - /// The base to be added the the generated URIs - final Uri _base; - - Uri _appendToBase(List segments) => - _base.replace(pathSegments: _base.pathSegments + segments); -} +export 'package:json_api/src/routing/collection_route.dart'; +export 'package:json_api/src/routing/composite_routing.dart'; +export 'package:json_api/src/routing/relationship_route.dart'; +export 'package:json_api/src/routing/resource_route.dart'; +export 'package:json_api/src/routing/route_factory.dart'; +export 'package:json_api/src/routing/route_matcher.dart'; +export 'package:json_api/src/routing/routing.dart'; +export 'package:json_api/src/routing/standard_routes.dart'; +export 'package:json_api/src/routing/standard_routing.dart'; diff --git a/lib/server.dart b/lib/server.dart index fa1b6af1..efafde9c 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -5,9 +5,9 @@ library server; export 'package:json_api/src/server/dart_server.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; -export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; +export 'package:json_api/src/server/response.dart'; export 'package:json_api/src/server/to_http.dart'; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index c66b382c..b7f2e96c 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -113,7 +113,7 @@ class RoutingClient { RoutingClient(this._client, this._routing); final JsonApiClient _client; - final Routing _routing; + final RouteFactory _routing; Uri _collection(String type) => _routing.collection(type); diff --git a/lib/src/routing/collection_route.dart b/lib/src/routing/collection_route.dart new file mode 100644 index 00000000..c28a5cba --- /dev/null +++ b/lib/src/routing/collection_route.dart @@ -0,0 +1,5 @@ +abstract class CollectionRoute { + Uri uri(String type); + + bool match(Uri uri, void Function(String type) onMatch); +} diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart new file mode 100644 index 00000000..5560fe45 --- /dev/null +++ b/lib/src/routing/composite_routing.dart @@ -0,0 +1,46 @@ +import 'package:json_api/src/routing/collection_route.dart'; +import 'package:json_api/src/routing/relationship_route.dart'; +import 'package:json_api/src/routing/resource_route.dart'; +import 'package:json_api/src/routing/routing.dart'; + +class CompositeRouting implements Routing { + @override + Uri collection(String type) => collectionRoute.uri(type); + + @override + Uri related(String type, String id, String relationship) => + relatedRoute.uri(type, id, relationship); + + @override + Uri relationship(String type, String id, String relationship) => + relationshipRoute.uri(type, id, relationship); + + @override + Uri resource(String type, String id) => resourceRoute.uri(type, id); + + CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, + this.relationshipRoute); + + final CollectionRoute collectionRoute; + final ResourceRoute resourceRoute; + final RelationshipRoute relatedRoute; + final RelationshipRoute relationshipRoute; + + @override + bool matchCollection(Uri uri, void Function(String type) onMatch) => + collectionRoute.match(uri, onMatch); + + @override + bool matchRelated(Uri uri, + void Function(String type, String id, String relationship) onMatch) => + relatedRoute.match(uri, onMatch); + + @override + bool matchRelationship(Uri uri, + void Function(String type, String id, String relationship) onMatch) => + relationshipRoute.match(uri, onMatch); + + @override + bool matchResource(Uri uri, void Function(String type, String id) onMatch) => + resourceRoute.match(uri, onMatch); +} diff --git a/lib/src/routing/relationship_route.dart b/lib/src/routing/relationship_route.dart new file mode 100644 index 00000000..711429ae --- /dev/null +++ b/lib/src/routing/relationship_route.dart @@ -0,0 +1,7 @@ + +abstract class RelationshipRoute { + Uri uri(String type, String id, String relationship); + + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} diff --git a/lib/src/routing/resource_route.dart b/lib/src/routing/resource_route.dart new file mode 100644 index 00000000..127ec8d1 --- /dev/null +++ b/lib/src/routing/resource_route.dart @@ -0,0 +1,5 @@ +abstract class ResourceRoute { + Uri uri(String type, String id); + + bool match(Uri uri, void Function(String type, String id) onMatch); +} diff --git a/lib/src/routing/route_factory.dart b/lib/src/routing/route_factory.dart new file mode 100644 index 00000000..78e79f81 --- /dev/null +++ b/lib/src/routing/route_factory.dart @@ -0,0 +1,18 @@ +/// Makes URIs for specific targets +abstract class RouteFactory { + /// 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/routing/route_matcher.dart b/lib/src/routing/route_matcher.dart new file mode 100644 index 00000000..c0d2b5b6 --- /dev/null +++ b/lib/src/routing/route_matcher.dart @@ -0,0 +1,11 @@ +abstract class RouteMatcher { + bool matchCollection(Uri uri, void Function(String type) onMatch); + + bool matchResource(Uri uri, void Function(String type, String id) onMatch); + + bool matchRelated(Uri uri, + void Function(String type, String id, String relationship) onMatch); + + bool matchRelationship(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart new file mode 100644 index 00000000..5872c213 --- /dev/null +++ b/lib/src/routing/routing.dart @@ -0,0 +1,4 @@ +import 'package:json_api/src/routing/route_factory.dart'; +import 'package:json_api/src/routing/route_matcher.dart'; + +abstract class Routing implements RouteFactory, RouteMatcher {} diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart new file mode 100644 index 00000000..29865b02 --- /dev/null +++ b/lib/src/routing/standard_routes.dart @@ -0,0 +1,90 @@ +import 'package:json_api/src/routing/collection_route.dart'; +import 'package:json_api/src/routing/relationship_route.dart'; +import 'package:json_api/src/routing/resource_route.dart'; + +class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { + @override + bool match(Uri uri, void Function(String type) onMatch) { + final seg = _segments(uri); + if (seg.length == 1) { + onMatch(seg.first); + return true; + } + return false; + } + + @override + Uri uri(String type) => _appendToBase([type]); + + StandardCollectionRoute([Uri base]) : super(base); +} + +class StandardResourceRoute extends _BaseRoute implements ResourceRoute { + @override + bool match(Uri uri, void Function(String type, String id) onMatch) { + final seg = _segments(uri); + if (seg.length == 2) { + onMatch(seg.first, seg.last); + return true; + } + return false; + } + + @override + Uri uri(String type, String id) => _appendToBase([type, id]); + + StandardResourceRoute([Uri base]) : super(base); +} + +class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { + @override + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch) { + final seg = _segments(uri); + if (seg.length == 3) { + onMatch(seg.first, seg[1], seg.last); + return true; + } + return false; + } + + @override + Uri uri(String type, String id, String relationship) => + _appendToBase([type, id, relationship]); + + StandardRelatedRoute([Uri base]) : super(base); +} + +class StandardRelationshipRoute extends _BaseRoute + implements RelationshipRoute { + @override + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch) { + final seg = _segments(uri); + if (seg.length == 4 && seg[2] == _rel) { + onMatch(seg.first, seg[1], seg.last); + return true; + } + return false; + } + + @override + Uri uri(String type, String id, String relationship) => + _appendToBase([type, id, _rel, relationship]); + + StandardRelationshipRoute([Uri base]) : super(base); + + static const _rel = 'relationships'; +} + +class _BaseRoute { + _BaseRoute([Uri base]) : _base = base ?? Uri(); + + final Uri _base; + + Uri _appendToBase(List segments) => + _base.replace(pathSegments: _base.pathSegments + segments); + + List _segments(Uri uri) => + uri.pathSegments.skip(_base.pathSegments.length).toList(); +} diff --git a/lib/src/routing/standard_routing.dart b/lib/src/routing/standard_routing.dart new file mode 100644 index 00000000..bc9ea670 --- /dev/null +++ b/lib/src/routing/standard_routing.dart @@ -0,0 +1,8 @@ +import 'package:json_api/src/routing/composite_routing.dart'; +import 'package:json_api/src/routing/standard_routes.dart'; + +class StandardRouting extends CompositeRouting { + StandardRouting([Uri base]) + : super(StandardCollectionRoute(base), StandardResourceRoute(base), + StandardRelatedRoute(base), StandardRelationshipRoute(base)); +} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index a86f34a7..cf02da68 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -5,18 +5,17 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/json_api_request_handler.dart'; import 'package:json_api/src/server/request_factory.dart'; +import 'package:json_api/src/server/request_handler.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { @override Future call(HttpRequest httpRequest) async { Request jsonApiRequest; - JsonApiResponse jsonApiResponse; - + Response jsonApiResponse; try { - jsonApiRequest = JsonApiRequestFactory().createFromHttp(httpRequest); + jsonApiRequest = RequestFactory().createFromHttp(httpRequest); } on FormatException catch (e) { jsonApiResponse = ErrorResponse.badRequest([ ErrorObject( @@ -35,7 +34,7 @@ class JsonApiServer implements HttpHandler { title: 'Method Not Allowed', detail: 'Allowed methods: ${e.allow.join(', ')}') ], e.allow); - } on InvalidUriException { + } on UnmatchedUriException { jsonApiResponse = ErrorResponse.notFound([ ErrorObject( status: '404', @@ -50,18 +49,13 @@ class JsonApiServer implements HttpHandler { detail: 'Incomplete relationship object') ]); } - - // Implementation-specific logic (e.g. auth) goes here - jsonApiResponse ??= await jsonApiRequest.handleWith(_controller); - - // Any response post-processing goes here - return jsonApiResponse - .convert(HttpResponseFactory(_routing, httpRequest.uri)); + return jsonApiResponse.convert(ToHttpResponse(_routing, httpRequest.uri)); } - JsonApiServer(this._routing, this._controller); + JsonApiServer(this._controller, {RouteFactory routing}) + : _routing = routing ?? StandardRouting(); - final Routing _routing; - final JsonApiRequestHandler> _controller; + final RouteFactory _routing; + final RequestHandler> _controller; } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index c39bc268..478c865e 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -3,19 +3,15 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/json_api_request_handler.dart'; -import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; +import 'package:json_api/src/server/request_handler.dart'; +import 'package:json_api/src/server/response.dart'; -/// An opinionated implementation of [JsonApiRequestHandler] -class RepositoryController - implements JsonApiRequestHandler> { +/// An opinionated implementation of [RequestHandler] +class RepositoryController implements RequestHandler> { @override - FutureOr addToRelationship( - final String type, - final String id, - final String relationship, - final Iterable identifiers) => + FutureOr addToRelationship(final String type, final String id, + final String relationship, final Iterable identifiers) => _do(() async { final original = await _repo.get(type, id); if (!original.toMany.containsKey(relationship)) { @@ -38,7 +34,7 @@ class RepositoryController }); @override - FutureOr createResource(String type, Resource resource) => + FutureOr createResource(String type, Resource resource) => _do(() async { final modified = await _repo.create(type, resource); if (modified == null) return NoContentResponse(); @@ -46,11 +42,8 @@ class RepositoryController }); @override - FutureOr deleteFromRelationship( - final String type, - final String id, - final String relationship, - final Iterable identifiers) => + FutureOr deleteFromRelationship(final String type, final String id, + final String relationship, final Iterable identifiers) => _do(() async { final original = await _repo.get(type, id); final updated = await _repo.update( @@ -65,14 +58,13 @@ class RepositoryController }); @override - FutureOr deleteResource(String type, String id) => - _do(() async { + FutureOr deleteResource(String type, String id) => _do(() async { await _repo.delete(type, id); return NoContentResponse(); }); @override - FutureOr fetchCollection( + FutureOr fetchCollection( final String type, final Map> queryParameters) => _do(() async { final c = await _repo.getCollection(type); @@ -90,7 +82,7 @@ class RepositoryController }); @override - FutureOr fetchRelated( + FutureOr fetchRelated( final String type, final String id, final String relationship, @@ -112,7 +104,7 @@ class RepositoryController }); @override - FutureOr fetchRelationship( + FutureOr fetchRelationship( final String type, final String id, final String relationship, @@ -131,7 +123,7 @@ class RepositoryController }); @override - FutureOr fetchResource(final String type, final String id, + FutureOr fetchResource(final String type, final String id, final Map> queryParameters) => _do(() async { final include = Include.fromQueryParameters(queryParameters); @@ -145,7 +137,7 @@ class RepositoryController }); @override - FutureOr replaceToMany(final String type, final String id, + FutureOr replaceToMany(final String type, final String id, final String relationship, final Iterable identifiers) => _do(() async { await _repo.update( @@ -154,7 +146,7 @@ class RepositoryController }); @override - FutureOr updateResource( + FutureOr updateResource( String type, String id, Resource resource) => _do(() async { final modified = await _repo.update(type, id, resource); @@ -163,7 +155,7 @@ class RepositoryController }); @override - FutureOr replaceToOne(final String type, final String id, + FutureOr replaceToOne(final String type, final String id, final String relationship, final Identifier identifier) => _do(() async { await _repo.update( @@ -202,8 +194,7 @@ class RepositoryController return resources; } - FutureOr _do( - FutureOr Function() action) async { + FutureOr _do(FutureOr Function() action) async { try { return await action(); } on UnsupportedOperation catch (e) { @@ -233,7 +224,7 @@ class RepositoryController } } - JsonApiResponse _relationshipNotFound( + Response _relationshipNotFound( String relationship, ) { return ErrorResponse.notFound([ diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 284a63f3..57705507 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -1,10 +1,10 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/json_api_request_handler.dart'; +import 'package:json_api/src/server/request_handler.dart'; /// A base interface for JSON:API requests. abstract class Request { /// Calls the appropriate method of [controller] and returns the response - T handleWith(JsonApiRequestHandler controller); + T handleWith(RequestHandler controller); } /// A request to fetch a collection of type [type]. @@ -16,7 +16,7 @@ class FetchCollection implements Request { FetchCollection(this.queryParameters, this.type); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.fetchCollection(type, queryParameters); } @@ -28,7 +28,7 @@ class CreateResource implements Request { CreateResource(this.type, this.resource); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.createResource(type, resource); } @@ -41,7 +41,7 @@ class UpdateResource implements Request { UpdateResource(this.type, this.id, this.resource); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.updateResource(type, id, resource); } @@ -53,7 +53,7 @@ class DeleteResource implements Request { DeleteResource(this.type, this.id); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.deleteResource(type, id); } @@ -66,7 +66,7 @@ class FetchResource implements Request { FetchResource(this.type, this.id, this.queryParameters); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.fetchResource(type, id, queryParameters); } @@ -80,7 +80,7 @@ class FetchRelated implements Request { FetchRelated(this.type, this.id, this.relationship, this.queryParameters); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.fetchRelated(type, id, relationship, queryParameters); } @@ -95,7 +95,7 @@ class FetchRelationship implements Request { this.type, this.id, this.relationship, this.queryParameters); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.fetchRelationship(type, id, relationship, queryParameters); } @@ -109,7 +109,7 @@ class DeleteFromRelationship implements Request { this.type, this.id, this.relationship, this.identifiers); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.deleteFromRelationship(type, id, relationship, identifiers); } @@ -122,7 +122,7 @@ class ReplaceToOne implements Request { ReplaceToOne(this.type, this.id, this.relationship, this.identifier); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.replaceToOne(type, id, relationship, identifier); } @@ -135,7 +135,7 @@ class ReplaceToMany implements Request { ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.replaceToMany(type, id, relationship, identifiers); } @@ -148,6 +148,6 @@ class AddToRelationship implements Request { AddToRelationship(this.type, this.id, this.relationship, this.identifiers); @override - T handleWith(JsonApiRequestHandler controller) => + T handleWith(RequestHandler controller) => controller.addToRelationship(type, id, relationship, identifiers); } diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart index edc67b69..92a85e95 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_factory.dart @@ -2,79 +2,105 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/server/request.dart'; /// TODO: Extract routing -class JsonApiRequestFactory { - Request createFromHttp(HttpRequest request) { - final s = request.uri.pathSegments; - if (s.length == 1) { - switch (request.method) { +class RequestFactory { + /// Creates a [Request] from [httpRequest] + Request createFromHttp(HttpRequest httpRequest) { + String type; + String id; + String rel; + + void setType(String t) { + type = t; + } + + void setTypeId(String t, String i) { + type = t; + id = i; + } + + void setTypeIdRel(String t, String i, String r) { + type = t; + id = i; + rel = r; + } + + final uri = httpRequest.uri; + if (_matcher.matchCollection(uri, setType)) { + switch (httpRequest.method) { case 'GET': - return FetchCollection(request.uri.queryParametersAll, s[0]); + return FetchCollection(uri.queryParametersAll, type); case 'POST': - return CreateResource( - s[0], ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + return CreateResource(type, + ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: throw MethodNotAllowedException(allow: ['GET', 'POST']); } - } else if (s.length == 2) { - switch (request.method) { + } else if (_matcher.matchResource(uri, setTypeId)) { + switch (httpRequest.method) { case 'DELETE': - return DeleteResource(s[0], s[1]); + return DeleteResource(type, id); case 'GET': - return FetchResource(s[0], s[1], request.uri.queryParametersAll); + return FetchResource(type, id, uri.queryParametersAll); case 'PATCH': - return UpdateResource(s[0], s[1], - ResourceData.fromJson(jsonDecode(request.body)).unwrap()); + return UpdateResource(type, id, + ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: throw MethodNotAllowedException(allow: ['DELETE', 'GET', 'PATCH']); } - } else if (s.length == 3) { - switch (request.method) { + } else if (_matcher.matchRelated(uri, setTypeIdRel)) { + switch (httpRequest.method) { case 'GET': - return FetchRelated(s[0], s[1], s[2], request.uri.queryParametersAll); + return FetchRelated(type, id, rel, uri.queryParametersAll); default: throw MethodNotAllowedException(allow: ['GET']); } - } else if (s.length == 4 && s[2] == 'relationships') { - switch (request.method) { + } else if (_matcher.matchRelationship(uri, setTypeIdRel)) { + switch (httpRequest.method) { case 'DELETE': - return DeleteFromRelationship(s[0], s[1], s[3], - ToMany.fromJson(jsonDecode(request.body)).unwrap()); + return DeleteFromRelationship(type, id, rel, + ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); case 'GET': - return FetchRelationship( - s[0], s[1], s[3], request.uri.queryParametersAll); + return FetchRelationship(type, id, rel, uri.queryParametersAll); case 'PATCH': - final rel = Relationship.fromJson(jsonDecode(request.body)); - if (rel is ToOne) { - return ReplaceToOne(s[0], s[1], s[3], rel.unwrap()); + final r = Relationship.fromJson(jsonDecode(httpRequest.body)); + if (r is ToOne) { + return ReplaceToOne(type, id, rel, r.unwrap()); } - if (rel is ToMany) { - return ReplaceToMany(s[0], s[1], s[3], rel.unwrap()); + if (r is ToMany) { + return ReplaceToMany(type, id, rel, r.unwrap()); } throw IncompleteRelationshipException(); case 'POST': - return AddToRelationship(s[0], s[1], s[3], - ToMany.fromJson(jsonDecode(request.body)).unwrap()); + return AddToRelationship(type, id, rel, + ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: throw MethodNotAllowedException( allow: ['DELETE', 'GET', 'PATCH', 'POST']); } } - throw InvalidUriException(); + throw UnmatchedUriException(); } + + RequestFactory({RouteMatcher routeMatcher}) + : _matcher = routeMatcher ?? StandardRouting(); + final RouteMatcher _matcher; } +class RequestFactoryException implements Exception {} + /// Thrown if HTTP method is not allowed for the given route -class MethodNotAllowedException implements Exception { +class MethodNotAllowedException implements RequestFactoryException { final Iterable allow; MethodNotAllowedException({this.allow = const []}); } /// Thrown if the request URI can not be matched to a target -class InvalidUriException implements Exception {} +class UnmatchedUriException implements RequestFactoryException {} /// Thrown if the relationship object has no data -class IncompleteRelationshipException implements Exception {} +class IncompleteRelationshipException implements RequestFactoryException {} diff --git a/lib/src/server/json_api_request_handler.dart b/lib/src/server/request_handler.dart similarity index 98% rename from lib/src/server/json_api_request_handler.dart rename to lib/src/server/request_handler.dart index 52b285d2..c6776fb7 100644 --- a/lib/src/server/json_api_request_handler.dart +++ b/lib/src/server/request_handler.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. -abstract class JsonApiRequestHandler { +abstract class RequestHandler { /// Finds an returns a primary resource collection. /// See https://jsonapi.org/format/#fetching-resources T fetchCollection( diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/response.dart similarity index 75% rename from lib/src/server/json_api_response.dart rename to lib/src/server/response.dart index ea02f043..eb1bd4fb 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/response.dart @@ -2,17 +2,17 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response_converter.dart'; /// the base interface for all JSON:API responses -abstract class JsonApiResponse { +abstract class Response { /// Converts the JSON:API response to another object, e.g. HTTP response. T convert(ResponseConverter converter); } -class NoContentResponse implements JsonApiResponse { +class NoContentResponse implements Response { @override T convert(ResponseConverter converter) => converter.noContent(); } -class CollectionResponse implements JsonApiResponse { +class CollectionResponse implements Response { final Iterable collection; final Iterable included; final int total; @@ -24,7 +24,7 @@ class CollectionResponse implements JsonApiResponse { converter.collection(collection, included: included, total: total); } -class AcceptedResponse implements JsonApiResponse { +class AcceptedResponse implements Response { final Resource resource; AcceptedResponse(this.resource); @@ -33,30 +33,30 @@ class AcceptedResponse implements JsonApiResponse { T convert(ResponseConverter converter) => converter.accepted(resource); } -class ErrorResponse implements JsonApiResponse { +class ErrorResponse implements Response { final Iterable errors; final int statusCode; ErrorResponse(this.statusCode, this.errors); - static JsonApiResponse badRequest(Iterable errors) => + static Response badRequest(Iterable errors) => ErrorResponse(400, errors); - static JsonApiResponse forbidden(Iterable errors) => + static Response forbidden(Iterable errors) => ErrorResponse(403, errors); - static JsonApiResponse notFound(Iterable errors) => + static Response notFound(Iterable errors) => ErrorResponse(404, errors); /// The allowed methods can be specified in [allow] - static JsonApiResponse methodNotAllowed( + static Response methodNotAllowed( Iterable errors, Iterable allow) => ErrorResponse(405, errors).._headers['Allow'] = allow.join(', '); - static JsonApiResponse conflict(Iterable errors) => + static Response conflict(Iterable errors) => ErrorResponse(409, errors); - static JsonApiResponse notImplemented(Iterable errors) => + static Response notImplemented(Iterable errors) => ErrorResponse(501, errors); @override @@ -66,7 +66,7 @@ class ErrorResponse implements JsonApiResponse { final _headers = {}; } -class MetaResponse implements JsonApiResponse { +class MetaResponse implements Response { final Map meta; MetaResponse(this.meta); @@ -75,7 +75,7 @@ class MetaResponse implements JsonApiResponse { T convert(ResponseConverter converter) => converter.meta(meta); } -class ResourceResponse implements JsonApiResponse { +class ResourceResponse implements Response { final Resource resource; final Iterable included; @@ -86,7 +86,7 @@ class ResourceResponse implements JsonApiResponse { converter.resource(resource, included: included); } -class ResourceCreatedResponse implements JsonApiResponse { +class ResourceCreatedResponse implements Response { final Resource resource; ResourceCreatedResponse(this.resource); @@ -96,7 +96,7 @@ class ResourceCreatedResponse implements JsonApiResponse { converter.resourceCreated(resource); } -class SeeOtherResponse implements JsonApiResponse { +class SeeOtherResponse implements Response { final String type; final String id; @@ -106,7 +106,7 @@ class SeeOtherResponse implements JsonApiResponse { T convert(ResponseConverter converter) => converter.seeOther(type, id); } -class ToManyResponse implements JsonApiResponse { +class ToManyResponse implements Response { final Iterable collection; final String type; final String id; @@ -119,7 +119,7 @@ class ToManyResponse implements JsonApiResponse { converter.toMany(type, id, relationship, collection); } -class ToOneResponse implements JsonApiResponse { +class ToOneResponse implements Response { final String type; final String id; final String relationship; diff --git a/lib/src/server/to_http.dart b/lib/src/server/to_http.dart index 64f74132..a9873eb0 100644 --- a/lib/src/server/to_http.dart +++ b/lib/src/server/to_http.dart @@ -9,7 +9,7 @@ import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/response_converter.dart'; /// An implementation of [ResponseConverter] converting to [HttpResponse]. -class HttpResponseFactory implements ResponseConverter { +class ToHttpResponse implements ResponseConverter { @override HttpResponse error(Iterable errors, int statusCode, Map headers) => @@ -103,10 +103,10 @@ class HttpResponseFactory implements ResponseConverter { @override HttpResponse noContent() => HttpResponse(204); - HttpResponseFactory(this._routing, this._self); + ToHttpResponse(this._routing, this._self); final Uri _self; - final Routing _routing; + final RouteFactory _routing; final Api _api = Api(version: '1.0'); HttpResponse _document(Document d, diff --git a/pubspec.yaml b/pubspec.yaml index 117726a0..92dd89e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-dev.4 +version: 4.0.0-dev.5 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: diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index f62526f4..9d35647d 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -17,7 +17,7 @@ void main() { final routing = StandardRouting(Uri(host: host, port: port, scheme: 'http')); final repo = InMemoryRepository({'writers': {}, 'books': {}}); - final jsonApiServer = JsonApiServer(routing, RepositoryController(repo)); + final jsonApiServer = JsonApiServer(RepositoryController(repo)); final serverHandler = DartServer(jsonApiServer); Client httpClient; RoutingClient client; diff --git a/test/functional/async_processing_test.dart b/test/functional/async_processing_test.dart new file mode 100644 index 00000000..39fb74f5 --- /dev/null +++ b/test/functional/async_processing_test.dart @@ -0,0 +1,79 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/server/request_handler.dart'; +import 'package:test/test.dart'; + +void main() { + + test('Async creation', () { + + }); +} + +class AsyncCreation implements RequestHandler { + @override + Response createResource(String type, Resource resource) { + // TODO: implement createResource + return null; + } + + @override + Response fetchResource( + String type, String id, Map> queryParameters) { + // TODO: implement fetchResource + return null; + } + + @override + Response addToRelationship(String type, String id, String relationship, + Iterable identifiers) { + return null; + } + + @override + Response deleteFromRelationship(String type, String id, String relationship, + Iterable identifiers) { + return null; + } + + @override + Response deleteResource(String type, String id) { + return null; + } + + @override + Response fetchCollection( + String type, Map> queryParameters) { + return null; + } + + @override + Response fetchRelated(String type, String id, String relationship, + Map> queryParameters) { + return null; + } + + @override + Response fetchRelationship(String type, String id, String relationship, + Map> queryParameters) { + return null; + } + + @override + Response replaceToMany(String type, String id, String relationship, + Iterable identifiers) { + return null; + } + + @override + Response replaceToOne( + String type, String id, String relationship, Identifier identifier) { + return null; + } + + @override + Response updateResource(String type, String id, Resource resource) { + return null; + } +} diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 8c2f61e3..9b6d8d16 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -47,7 +47,7 @@ void main() async { 'countries': {'1': wonderland}, 'tags': {} }); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = RoutingClient(JsonApiClient(server), routing); }); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index aa9e70ff..4c7579b8 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -18,7 +18,7 @@ void main() async { final repository = InMemoryRepository({ 'people': {}, }, nextId: Uuid().v4); - final server = JsonApiServer(routing, RepositoryController(repository)); + final server = JsonApiServer(RepositoryController(repository)); final client = JsonApiClient(server); final routingClient = RoutingClient(client, routing); @@ -39,7 +39,7 @@ void main() async { test('403 when the id can not be generated', () async { final repository = InMemoryRepository({'people': {}}); - final server = JsonApiServer(routing, RepositoryController(repository)); + final server = JsonApiServer(RepositoryController(repository)); final client = JsonApiClient(server); final routingClient = RoutingClient(client, routing); @@ -65,7 +65,7 @@ void main() async { 'fruits': {}, 'apples': {} }); - final server = JsonApiServer(routing, RepositoryController(repository)); + final server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); }); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 4fe74d89..9b99fa21 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -20,7 +20,7 @@ void main() async { setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 4b08dc75..a5a4da6f 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -21,7 +21,7 @@ void main() async { setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 099c3981..c547d249 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -1,9 +1,9 @@ import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; @@ -20,7 +20,7 @@ void main() async { setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); @@ -76,7 +76,8 @@ void main() async { group('Related Resource', () { test('200 OK', () async { - final r = await routingClient.fetchRelatedResource('books', '1', 'publisher'); + final r = + await routingClient.fetchRelatedResource('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().type, 'companies'); @@ -84,7 +85,8 @@ void main() async { }); test('404 on collection', () async { - final r = await routingClient.fetchRelatedResource('unicorns', '1', 'publisher'); + final r = await routingClient.fetchRelatedResource( + 'unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -93,7 +95,8 @@ void main() async { }); test('404 on resource', () async { - final r = await routingClient.fetchRelatedResource('books', '42', 'publisher'); + final r = + await routingClient.fetchRelatedResource('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -114,7 +117,8 @@ void main() async { group('Related Collection', () { test('successful', () async { - final r = await routingClient.fetchRelatedCollection('books', '1', 'authors'); + final r = + await routingClient.fetchRelatedCollection('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.data.unwrap().length, 2); @@ -122,7 +126,8 @@ void main() async { }); test('404 on collection', () async { - final r = await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); + final r = + await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -131,7 +136,8 @@ void main() async { }); test('404 on resource', () async { - final r = await routingClient.fetchRelatedCollection('books', '42', 'authors'); + final r = + await routingClient.fetchRelatedCollection('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); @@ -140,7 +146,8 @@ void main() async { }); test('404 on relationship', () async { - final r = await routingClient.fetchRelatedCollection('books', '1', 'readers'); + final r = + await routingClient.fetchRelatedCollection('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); expect(r.errors.first.status, '404'); diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 3c878193..fd051102 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -1,10 +1,10 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import 'seed_resources.dart'; @@ -21,7 +21,7 @@ void main() async { setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); @@ -63,7 +63,6 @@ void main() async { expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); - }); group('Deleting a to-one relationship', () { diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 0fabe12a..efaf684e 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -1,10 +1,10 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; -import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import '../../helper/expect_resources_equal.dart'; @@ -22,7 +22,7 @@ void main() async { setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(routing, RepositoryController(repository)); + server = JsonApiServer(RepositoryController(repository)); client = JsonApiClient(server); routingClient = RoutingClient(client, routing); @@ -30,7 +30,8 @@ void main() async { }); test('200 OK', () async { - final r = await routingClient.updateResource(Resource('books', '1', attributes: { + final r = + await routingClient.updateResource(Resource('books', '1', attributes: { 'title': 'Refactoring. Improving the Design of Existing Code', 'pages': 448 }, toOne: { diff --git a/test/helper/shelf_adapter.dart b/test/helper/shelf_adapter.dart deleted file mode 100644 index bdd4944d..00000000 --- a/test/helper/shelf_adapter.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/http.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:shelf/shelf.dart'; - -class ShelfAdapter { - final JsonApiServer _server; - - ShelfAdapter(this._server); - - FutureOr call(Request request) async { - final rq = HttpRequest(request.method, request.requestedUri, - body: await request.readAsString(), headers: request.headers); - final rs = await _server(rq); - return Response(rs.statusCode, body: rs.body, headers: rs.headers); - } -} diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart index 4cf80bf5..a64e826e 100644 --- a/test/unit/document/json_api_error_test.dart +++ b/test/unit/document/json_api_error_test.dart @@ -34,8 +34,7 @@ void main() { group('fromJson()', () { test('if no links is present, the "links" property is null', () { - final e = - ErrorObject.fromJson(json.decode(json.encode((ErrorObject())))); + final e = ErrorObject.fromJson(json.decode(json.encode((ErrorObject())))); expect(e.links, null); expect(e.about, null); }); diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index dbd38024..d3c951b0 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -2,14 +2,13 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; import 'package:test/test.dart'; void main() { final routing = StandardRouting(Uri.parse('http://example.com')); - final server = - JsonApiServer(routing, RepositoryController(InMemoryRepository({}))); + final server = JsonApiServer(RepositoryController(InMemoryRepository({}))); group('JsonApiServer', () { test('returns `bad request` on incomplete relationship', () async { @@ -49,8 +48,8 @@ void main() { }); test('returns `bad request` when payload violates JSON:API', () async { - final rq = - HttpRequest('POST', routing.collection('books'), body: '{"data": {}}'); + final rq = HttpRequest('POST', routing.collection('books'), + body: '{"data": {}}'); final rs = await server(rq); expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; From e73c0baadf628c77d642654e1dfae06103caa49d Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 20 Feb 2020 00:10:23 -0800 Subject: [PATCH 30/99] wip --- lib/src/server/request_factory.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_factory.dart index 92a85e95..9faa1d77 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_factory.dart @@ -5,7 +5,6 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/request.dart'; -/// TODO: Extract routing class RequestFactory { /// Creates a [Request] from [httpRequest] Request createFromHttp(HttpRequest httpRequest) { From 5a4aac37d1dc380c3752ac744fe98127e9661911 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 20 Feb 2020 22:16:03 -0800 Subject: [PATCH 31/99] wip --- lib/server.dart | 6 +- lib/src/routing/composite_routing.dart | 16 +- lib/src/routing/relationship_route.dart | 1 - lib/src/routing/standard_routes.dart | 14 +- lib/src/server/document_factory.dart | 79 ++++++++++ lib/src/server/http_response_factory.dart | 75 +++++++++ lib/src/server/json_api_server.dart | 14 +- lib/src/server/links/links_factory.dart | 19 +++ lib/src/server/links/no_links.dart | 23 +++ lib/src/server/links/standard_links.dart | 48 ++++++ lib/src/server/response.dart | 3 + lib/src/server/to_http.dart | 153 ------------------- test/functional/async_processing_test.dart | 79 ---------- test/helper/test_http_handler.dart | 12 ++ test/unit/client/async_processing_test.dart | 28 ++++ test/unit/routing/standard_routing_test.dart | 46 ++++++ 16 files changed, 365 insertions(+), 251 deletions(-) create mode 100644 lib/src/server/document_factory.dart create mode 100644 lib/src/server/http_response_factory.dart create mode 100644 lib/src/server/links/links_factory.dart create mode 100644 lib/src/server/links/no_links.dart create mode 100644 lib/src/server/links/standard_links.dart delete mode 100644 lib/src/server/to_http.dart delete mode 100644 test/functional/async_processing_test.dart create mode 100644 test/helper/test_http_handler.dart create mode 100644 test/unit/client/async_processing_test.dart create mode 100644 test/unit/routing/standard_routing_test.dart diff --git a/lib/server.dart b/lib/server.dart index efafde9c..a1fd1e28 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -4,10 +4,14 @@ library server; export 'package:json_api/src/server/dart_server.dart'; +export 'package:json_api/src/server/document_factory.dart'; +export 'package:json_api/src/server/http_response_factory.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_server.dart'; +export 'package:json_api/src/server/links/links_factory.dart'; +export 'package:json_api/src/server/links/no_links.dart'; +export 'package:json_api/src/server/links/standard_links.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; export 'package:json_api/src/server/response.dart'; -export 'package:json_api/src/server/to_http.dart'; diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart index 5560fe45..dea64c47 100644 --- a/lib/src/routing/composite_routing.dart +++ b/lib/src/routing/composite_routing.dart @@ -18,14 +18,6 @@ class CompositeRouting implements Routing { @override Uri resource(String type, String id) => resourceRoute.uri(type, id); - CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, - this.relationshipRoute); - - final CollectionRoute collectionRoute; - final ResourceRoute resourceRoute; - final RelationshipRoute relatedRoute; - final RelationshipRoute relationshipRoute; - @override bool matchCollection(Uri uri, void Function(String type) onMatch) => collectionRoute.match(uri, onMatch); @@ -43,4 +35,12 @@ class CompositeRouting implements Routing { @override bool matchResource(Uri uri, void Function(String type, String id) onMatch) => resourceRoute.match(uri, onMatch); + + CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, + this.relationshipRoute); + + final CollectionRoute collectionRoute; + final ResourceRoute resourceRoute; + final RelationshipRoute relatedRoute; + final RelationshipRoute relationshipRoute; } diff --git a/lib/src/routing/relationship_route.dart b/lib/src/routing/relationship_route.dart index 711429ae..3dd4f7bf 100644 --- a/lib/src/routing/relationship_route.dart +++ b/lib/src/routing/relationship_route.dart @@ -1,4 +1,3 @@ - abstract class RelationshipRoute { Uri uri(String type, String id, String relationship); diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart index 29865b02..721f101f 100644 --- a/lib/src/routing/standard_routes.dart +++ b/lib/src/routing/standard_routes.dart @@ -14,7 +14,7 @@ class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { } @override - Uri uri(String type) => _appendToBase([type]); + Uri uri(String type) => _resolve([type]); StandardCollectionRoute([Uri base]) : super(base); } @@ -31,7 +31,7 @@ class StandardResourceRoute extends _BaseRoute implements ResourceRoute { } @override - Uri uri(String type, String id) => _appendToBase([type, id]); + Uri uri(String type, String id) => _resolve([type, id]); StandardResourceRoute([Uri base]) : super(base); } @@ -50,7 +50,7 @@ class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { @override Uri uri(String type, String id, String relationship) => - _appendToBase([type, id, relationship]); + _resolve([type, id, relationship]); StandardRelatedRoute([Uri base]) : super(base); } @@ -70,7 +70,7 @@ class StandardRelationshipRoute extends _BaseRoute @override Uri uri(String type, String id, String relationship) => - _appendToBase([type, id, _rel, relationship]); + _resolve([type, id, _rel, relationship]); StandardRelationshipRoute([Uri base]) : super(base); @@ -78,12 +78,12 @@ class StandardRelationshipRoute extends _BaseRoute } class _BaseRoute { - _BaseRoute([Uri base]) : _base = base ?? Uri(); + _BaseRoute([Uri base]) : _base = base ?? Uri(path: '/'); final Uri _base; - Uri _appendToBase(List segments) => - _base.replace(pathSegments: _base.pathSegments + segments); + Uri _resolve(List pathSegments) => + _base.resolveUri(Uri(pathSegments: pathSegments)); List _segments(Uri uri) => uri.pathSegments.skip(_base.pathSegments.length).toList(); diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart new file mode 100644 index 00000000..cd73c4dd --- /dev/null +++ b/lib/src/server/document_factory.dart @@ -0,0 +1,79 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/links/links_factory.dart'; +import 'package:json_api/src/server/pagination.dart'; + +/// The factory producing JSON:API Documents +class DocumentFactory { + /// An error document + Document error(Iterable errors) => + Document.error(errors, api: _api); + + /// A resource collection document + Document collection(Iterable collection, + {int total, + Iterable included, + Pagination pagination = const NoPagination()}) => + Document( + ResourceCollectionData(collection.map(_resourceObject), + links: _links.collection(total, pagination), + included: included?.map(_resourceObject)), + api: _api); + + /// An empty (meta) document + Document empty(Map meta) => Document.empty(meta, api: _api); + + Document resource(Resource resource, + {Iterable included}) => + Document( + ResourceData(_resourceObject(resource), + links: _links.resource(), + included: included?.map(_resourceObject)), + api: _api); + + Document resourceCreated(Resource resource) => Document( + ResourceData(_resourceObject(resource), + links: _links.createdResource(resource.type, resource.id)), + api: _api); + + Document toMany(String type, String id, String relationship, + Iterable identifiers, + {Iterable included}) => + Document( + ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + links: _links.relationship(type, id, relationship), + ), + api: _api); + + Document toOne( + Identifier identifier, String type, String id, String relationship, + {Iterable included}) => + Document( + ToOne( + nullable(IdentifierObject.fromIdentifier)(identifier), + links: _links.relationship(type, id, relationship), + ), + api: _api); + + DocumentFactory({LinksFactory links = const NoLinks()}) : _links = links; + + final Api _api = Api(version: '1.0'); + + final LinksFactory _links; + + ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, + attributes: r.attributes, + relationships: { + ...r.toOne.map((k, v) => MapEntry( + k, + ToOne(nullable(IdentifierObject.fromIdentifier)(v), + links: _links.resourceRelationship(r.type, r.id, k)))), + ...r.toMany.map((k, v) => MapEntry( + k, + ToMany(v.map(IdentifierObject.fromIdentifier), + links: _links.resourceRelationship(r.type, r.id, k)))) + }, + links: _links.createdResource(r.type, r.id)); +} diff --git a/lib/src/server/http_response_factory.dart b/lib/src/server/http_response_factory.dart new file mode 100644 index 00000000..60ba9018 --- /dev/null +++ b/lib/src/server/http_response_factory.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/document_factory.dart'; +import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/response_converter.dart'; + +/// An implementation of [ResponseConverter] converting to [HttpResponse]. +class HttpResponseFactory implements ResponseConverter { + @override + HttpResponse error(Iterable errors, int statusCode, + Map headers) => + _ok(_doc.error(errors), status: statusCode, headers: headers); + + @override + HttpResponse collection(Iterable collection, + {int total, + Iterable included, + Pagination pagination = const NoPagination()}) { + return _ok(_doc.collection(collection, + total: total, included: included, pagination: pagination)); + } + + @override + HttpResponse accepted(Resource resource) => + _ok(_doc.resource(resource), status: 202, headers: { + 'Content-Location': + _routing.resource(resource.type, resource.id).toString() + }); + + @override + HttpResponse meta(Map meta) => _ok(_doc.empty(meta)); + + @override + HttpResponse resource(Resource resource, {Iterable included}) => + _ok(_doc.resource(resource, included: included)); + + @override + HttpResponse resourceCreated(Resource resource) => + _ok(_doc.resourceCreated(resource), status: 201, headers: { + 'Location': _routing.resource(resource.type, resource.id).toString() + }); + + @override + HttpResponse seeOther(String type, String id) => HttpResponse(303, + headers: {'Location': _routing.resource(type, id).toString()}); + + @override + HttpResponse toMany(String type, String id, String relationship, + Iterable identifiers, + {Iterable included}) => + _ok(_doc.toMany(type, id, relationship, identifiers, included: included)); + + @override + HttpResponse toOne( + Identifier identifier, String type, String id, String relationship, + {Iterable included}) => + _ok(_doc.toOne(identifier, type, id, relationship, included: included)); + + @override + HttpResponse noContent() => HttpResponse(204); + + HttpResponseFactory(this._doc, this._routing); + + final RouteFactory _routing; + final DocumentFactory _doc; + + HttpResponse _ok(Document d, + {int status = 200, Map headers = const {}}) => + HttpResponse(status, + body: jsonEncode(d), + headers: {...headers, 'Content-Type': Document.contentType}); +} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index cf02da68..dfa5d283 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -49,8 +49,18 @@ class JsonApiServer implements HttpHandler { detail: 'Incomplete relationship object') ]); } - jsonApiResponse ??= await jsonApiRequest.handleWith(_controller); - return jsonApiResponse.convert(ToHttpResponse(_routing, httpRequest.uri)); + jsonApiResponse ??= await jsonApiRequest.handleWith(_controller) ?? + ErrorResponse.internalServerError([ + ErrorObject( + status: '500', + title: 'Internal Server Error', + detail: 'Controller responded with null') + ]); + + final links = StandardLinks(httpRequest.uri, _routing); + final documentFactory = DocumentFactory(links: links); + final responseFactory = HttpResponseFactory(documentFactory, _routing); + return jsonApiResponse.convert(responseFactory); } JsonApiServer(this._controller, {RouteFactory routing}) diff --git a/lib/src/server/links/links_factory.dart b/lib/src/server/links/links_factory.dart new file mode 100644 index 00000000..313607fa --- /dev/null +++ b/lib/src/server/links/links_factory.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/pagination.dart'; + +abstract class LinksFactory { + /// Links for a resource object (primary or related) + Map resource(); + + /// Links for a collection (primary or related) + Map collection(int total, Pagination pagination); + + /// Links for a newly created resource + Map createdResource(String type, String id); + + /// Links for a standalone relationship + Map relationship(String type, String id, String rel); + + /// Links for a relationship inside a resource + Map resourceRelationship(String type, String id, String rel); +} diff --git a/lib/src/server/links/no_links.dart b/lib/src/server/links/no_links.dart new file mode 100644 index 00000000..b1122f16 --- /dev/null +++ b/lib/src/server/links/no_links.dart @@ -0,0 +1,23 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/src/document/link.dart'; + +class NoLinks implements LinksFactory { + @override + Map collection(int total, Pagination pagination) => const {}; + + @override + Map createdResource(String type, String id) => const {}; + + @override + Map relationship(String type, String id, String rel) => + const {}; + + @override + Map resource() => const {}; + + @override + Map resourceRelationship(String type, String id, String rel) => + const {}; + + const NoLinks(); +} diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart new file mode 100644 index 00000000..2fff065a --- /dev/null +++ b/lib/src/server/links/standard_links.dart @@ -0,0 +1,48 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/links/links_factory.dart'; +import 'package:json_api/src/server/pagination.dart'; + +class StandardLinks implements LinksFactory { + @override + Map resource() => {'self': Link(_requested)}; + + @override + Map collection(int total, Pagination pagination) => + {'self': Link(_requested), ..._navigation(total, pagination)}; + + @override + Map createdResource(String type, String id) => + {'self': Link(_route.resource(type, id))}; + + @override + Map relationship(String type, String id, String rel) => { + 'self': Link(_requested), + 'related': Link(_route.related(type, id, rel)) + }; + + @override + Map resourceRelationship(String type, String id, String rel) => + { + 'self': Link(_route.relationship(type, id, rel)), + 'related': Link(_route.related(type, id, rel)) + }; + + StandardLinks(this._requested, this._route); + + final Uri _requested; + final RouteFactory _route; + + Map _navigation(int total, Pagination pagination) { + final page = Page.fromUri(_requested); + + return ({ + 'first': pagination.first(), + 'last': pagination.last(total), + 'prev': pagination.prev(page), + 'next': pagination.next(page, total) + }..removeWhere((k, v) => v == null)) + .map((k, v) => MapEntry(k, Link(v.addToUri(_requested)))); + } +} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index eb1bd4fb..bd45b059 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -56,6 +56,9 @@ class ErrorResponse implements Response { static Response conflict(Iterable errors) => ErrorResponse(409, errors); + static ErrorResponse internalServerError(Iterable errors) => + ErrorResponse(500, errors); + static Response notImplemented(Iterable errors) => ErrorResponse(501, errors); diff --git a/lib/src/server/to_http.dart b/lib/src/server/to_http.dart deleted file mode 100644 index a9873eb0..00000000 --- a/lib/src/server/to_http.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/response_converter.dart'; - -/// An implementation of [ResponseConverter] converting to [HttpResponse]. -class ToHttpResponse implements ResponseConverter { - @override - HttpResponse error(Iterable errors, int statusCode, - Map headers) => - _document(Document.error(errors, api: _api), - status: statusCode, headers: headers); - - @override - HttpResponse collection(Iterable collection, - {int total, - Iterable included, - Pagination pagination = const NoPagination()}) { - return _document(Document( - ResourceCollectionData(collection.map(_resourceObject), - links: { - 'self': Link(_self), - ..._navigation(_self, total, pagination) - }, - included: included?.map(_resourceObject)), - api: _api)); - } - - @override - HttpResponse accepted(Resource resource) => - _document( - Document( - ResourceData(_resourceObject(resource), - links: {'self': Link(_self)}), - api: _api), - status: 202, - headers: { - 'Content-Location': - _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse meta(Map meta) => - _document(Document.empty(meta, api: _api)); - - @override - HttpResponse resource(Resource resource, {Iterable included}) => - _document(Document( - ResourceData(_resourceObject(resource), - links: {'self': Link(_self)}, - included: included?.map(_resourceObject)), - api: _api)); - - @override - HttpResponse resourceCreated(Resource resource) => _document( - Document( - ResourceData(_resourceObject(resource), links: { - 'self': Link(_routing.resource(resource.type, resource.id)) - }), - api: _api), - status: 201, - headers: { - 'Location': _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse seeOther(String type, String id) => HttpResponse(303, - headers: {'Location': _routing.resource(type, id).toString()}); - - @override - HttpResponse toMany(String type, String id, String relationship, - Iterable identifiers, - {Iterable included}) => - _document(Document( - ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(_self), - 'related': Link(_routing.related(type, id, relationship)) - }, - ), - api: _api)); - - @override - HttpResponse toOne( - Identifier identifier, String type, String id, String relationship, - {Iterable included}) => - _document(Document( - ToOne( - nullable(IdentifierObject.fromIdentifier)(identifier), - links: { - 'self': Link(_self), - 'related': Link(_routing.related(type, id, relationship)) - }, - ), - api: _api)); - - @override - HttpResponse noContent() => HttpResponse(204); - - ToHttpResponse(this._routing, this._self); - - final Uri _self; - final RouteFactory _routing; - final Api _api = Api(version: '1.0'); - - HttpResponse _document(Document d, - {int status = 200, Map headers = const {}}) => - HttpResponse(status, - body: jsonEncode(d), - headers: {...headers, 'Content-Type': Document.contentType}); - - ResourceObject _resourceObject(Resource r) => - ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { - ...r.toOne.map((k, v) => MapEntry( - k, - ToOne( - nullable(IdentifierObject.fromIdentifier)(v), - links: { - 'self': Link(_routing.relationship(r.type, r.id, k)), - 'related': Link(_routing.related(r.type, r.id, k)) - }, - ))), - ...r.toMany.map((k, v) => MapEntry( - k, - ToMany( - v.map(IdentifierObject.fromIdentifier), - links: { - 'self': Link(_routing.relationship(r.type, r.id, k)), - 'related': Link(_routing.related(r.type, r.id, k)) - }, - ))) - }, links: { - 'self': Link(_routing.resource(r.type, r.id)) - }); - - Map _navigation(Uri uri, int total, Pagination pagination) { - final page = Page.fromUri(uri); - - return ({ - 'first': pagination.first(), - 'last': pagination.last(total), - 'prev': pagination.prev(page), - 'next': pagination.next(page, total) - }..removeWhere((k, v) => v == null)) - .map((k, v) => MapEntry(k, Link(v.addToUri(uri)))); - } -} diff --git a/test/functional/async_processing_test.dart b/test/functional/async_processing_test.dart deleted file mode 100644 index 39fb74f5..00000000 --- a/test/functional/async_processing_test.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/server/request_handler.dart'; -import 'package:test/test.dart'; - -void main() { - - test('Async creation', () { - - }); -} - -class AsyncCreation implements RequestHandler { - @override - Response createResource(String type, Resource resource) { - // TODO: implement createResource - return null; - } - - @override - Response fetchResource( - String type, String id, Map> queryParameters) { - // TODO: implement fetchResource - return null; - } - - @override - Response addToRelationship(String type, String id, String relationship, - Iterable identifiers) { - return null; - } - - @override - Response deleteFromRelationship(String type, String id, String relationship, - Iterable identifiers) { - return null; - } - - @override - Response deleteResource(String type, String id) { - return null; - } - - @override - Response fetchCollection( - String type, Map> queryParameters) { - return null; - } - - @override - Response fetchRelated(String type, String id, String relationship, - Map> queryParameters) { - return null; - } - - @override - Response fetchRelationship(String type, String id, String relationship, - Map> queryParameters) { - return null; - } - - @override - Response replaceToMany(String type, String id, String relationship, - Iterable identifiers) { - return null; - } - - @override - Response replaceToOne( - String type, String id, String relationship, Identifier identifier) { - return null; - } - - @override - Response updateResource(String type, String id, Resource resource) { - return null; - } -} diff --git a/test/helper/test_http_handler.dart b/test/helper/test_http_handler.dart new file mode 100644 index 00000000..26a9275e --- /dev/null +++ b/test/helper/test_http_handler.dart @@ -0,0 +1,12 @@ +import 'package:json_api/http.dart'; + +class TestHttpHandler implements HttpHandler { + final requestLog = []; + HttpResponse nextResponse; + + @override + Future call(HttpRequest request) async { + requestLog.add(request); + return nextResponse; + } +} diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart new file mode 100644 index 00000000..53baa790 --- /dev/null +++ b/test/unit/client/async_processing_test.dart @@ -0,0 +1,28 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:test/test.dart'; + +import '../../helper/test_http_handler.dart'; + +void main() { + final handler = TestHttpHandler(); + final client = RoutingClient(JsonApiClient(handler), StandardRouting()); + final routing = StandardRouting(); + + test('Client understands async responses', () async { + final links = StandardLinks(Uri.parse('/books'), routing); + final responseFactory = + HttpResponseFactory(DocumentFactory(links: links), routing); + handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); + + final r = await client.createResource(Resource('books', '1')); + expect(r.isAsync, true); + expect(r.isSuccessful, false); + expect(r.isFailed, false); + expect(r.asyncData.unwrap().type, 'jobs'); + expect(r.asyncData.unwrap().id, '42'); + expect(r.contentLocation.toString(), '/jobs/42'); + }); +} diff --git a/test/unit/routing/standard_routing_test.dart b/test/unit/routing/standard_routing_test.dart new file mode 100644 index 00000000..a7f278f8 --- /dev/null +++ b/test/unit/routing/standard_routing_test.dart @@ -0,0 +1,46 @@ +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +void main() { + test('URIs start with slashes when no base provided', () { + final r = StandardRouting(); + expect(r.collection('books').toString(), '/books'); + expect(r.resource('books', '42').toString(), '/books/42'); + expect(r.related('books', '42', 'author').toString(), '/books/42/author'); + expect(r.relationship('books', '42', 'author').toString(), + '/books/42/relationships/author'); + }); + + test('Authority is retained if exists in base', () { + final r = StandardRouting(Uri.parse('https://example.com')); + expect(r.collection('books').toString(), 'https://example.com/books'); + expect( + r.resource('books', '42').toString(), 'https://example.com/books/42'); + expect(r.related('books', '42', 'author').toString(), + 'https://example.com/books/42/author'); + expect(r.relationship('books', '42', 'author').toString(), + 'https://example.com/books/42/relationships/author'); + }); + + test('Authority is retained if exists in base (non-directory path)', () { + final r = StandardRouting(Uri.parse('https://example.com/foo')); + expect(r.collection('books').toString(), 'https://example.com/books'); + expect( + r.resource('books', '42').toString(), 'https://example.com/books/42'); + expect(r.related('books', '42', 'author').toString(), + 'https://example.com/books/42/author'); + expect(r.relationship('books', '42', 'author').toString(), + 'https://example.com/books/42/relationships/author'); + }); + + test('Authority and path is retained if exists in base (directory path)', () { + final r = StandardRouting(Uri.parse('https://example.com/foo/')); + expect(r.collection('books').toString(), 'https://example.com/foo/books'); + expect(r.resource('books', '42').toString(), + 'https://example.com/foo/books/42'); + expect(r.related('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/author'); + expect(r.relationship('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/relationships/author'); + }); +} From 8007e94b3cf8c982aa5a603c917866964e903605 Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 21 Feb 2020 08:42:57 -0800 Subject: [PATCH 32/99] r1 --- CHANGELOG.md | 4 ++++ example/client.dart | 1 - lib/client.dart | 1 + lib/server.dart | 3 --- lib/src/server/links/links_factory.dart | 1 + pubspec.yaml | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c38419fc..64679811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 4.0.0 +### Changed +- This is a major **BC-breaking** rework which affected pretty much all areas. + ## [3.2.2] - 2020-01-07 ### Fixed - Can not decode related resource which is null ([#77](https://github.com/f3ath/json-api-dart/issues/77)) diff --git a/example/client.dart b/example/client.dart index 2e9f003c..0533b5e7 100644 --- a/example/client.dart +++ b/example/client.dart @@ -4,7 +4,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/dart_http.dart'; /// This example shows how to use the JSON:API client. /// Run the server first! diff --git a/lib/client.dart b/lib/client.dart index 9d84d7f4..7e8fd22e 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,5 +1,6 @@ library client; +export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_response.dart'; export 'package:json_api/src/client/routing_client.dart'; diff --git a/lib/server.dart b/lib/server.dart index a1fd1e28..453140b1 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,6 +1,3 @@ -/// # The JSON:API Server -/// -/// The server API is not stable. Expect breaking changes. library server; export 'package:json_api/src/server/dart_server.dart'; diff --git a/lib/src/server/links/links_factory.dart b/lib/src/server/links/links_factory.dart index 313607fa..d63bd882 100644 --- a/lib/src/server/links/links_factory.dart +++ b/lib/src/server/links/links_factory.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/pagination.dart'; +/// Creates `links` objects for JSON:API documents abstract class LinksFactory { /// Links for a resource object (primary or related) Map resource(); diff --git a/pubspec.yaml b/pubspec.yaml index 92dd89e1..93eb86b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-dev.5 +version: 4.0.0-rc.1 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: From ed771f5d9cac1f36975f11c1dabbdeb29b1f6d23 Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 21 Feb 2020 20:42:47 -0800 Subject: [PATCH 33/99] Formatting and docs --- analysis_options.yaml | 4 + lib/client.dart | 3 +- lib/server.dart | 2 +- lib/src/client/dart_http.dart | 4 +- lib/src/client/json_api_client.dart | 53 +++--- .../{json_api_response.dart => response.dart} | 4 +- lib/src/client/routing_client.dart | 42 ++--- lib/src/client/status_code.dart | 4 +- lib/src/document/api.dart | 12 +- lib/src/document/document.dart | 43 ++--- lib/src/document/document_exception.dart | 11 +- lib/src/document/error_object.dart | 86 ++++----- lib/src/document/identifier.dart | 20 +- lib/src/document/identifier_object.dart | 20 +- lib/src/document/json_encodable.dart | 4 + lib/src/document/link.dart | 12 +- lib/src/document/primary_data.dart | 24 ++- lib/src/document/relationship.dart | 43 ++--- lib/src/document/resource.dart | 36 ++-- .../document/resource_collection_data.dart | 8 +- lib/src/document/resource_data.dart | 4 +- lib/src/document/resource_object.dart | 28 ++- lib/src/http/http_handler.dart | 4 +- lib/src/http/http_request.dart | 14 +- lib/src/http/http_response.dart | 10 +- lib/src/http/logging_http_handler.dart | 4 +- lib/src/query/query_parameters.dart | 6 +- lib/src/query/sort.dart | 23 ++- lib/src/routing/composite_routing.dart | 16 +- lib/src/routing/standard_routes.dart | 16 +- lib/src/server/dart_server.dart | 4 +- lib/src/server/document_factory.dart | 12 +- ...tory.dart => http_response_converter.dart} | 16 +- lib/src/server/in_memory_repository.dart | 5 +- lib/src/server/json_api_server.dart | 18 +- lib/src/server/links/no_links.dart | 4 +- lib/src/server/links/standard_links.dart | 10 +- lib/src/server/pagination.dart | 4 +- lib/src/server/repository.dart | 38 ++-- lib/src/server/repository_controller.dart | 24 ++- lib/src/server/request.dart | 153 ++++++++++++--- ...st_factory.dart => request_converter.dart} | 33 ++-- lib/src/server/request_handler.dart | 4 + lib/src/server/response.dart | 174 +++++++++++++----- lib/src/server/response_converter.dart | 52 ++++-- test/unit/client/async_processing_test.dart | 2 +- test/unit/document/document_test.dart | 9 + test/unit/document/example.json | 97 ---------- test/unit/server/json_api_server_test.dart | 2 +- 49 files changed, 690 insertions(+), 531 deletions(-) rename lib/src/client/{json_api_response.dart => response.dart} (96%) create mode 100644 lib/src/document/json_encodable.dart rename lib/src/server/{http_response_factory.dart => http_response_converter.dart} (91%) rename lib/src/server/{request_factory.dart => request_converter.dart} (80%) create mode 100644 test/unit/document/document_test.dart delete mode 100644 test/unit/document/example.json diff --git a/analysis_options.yaml b/analysis_options.yaml index 108d1058..d9406f33 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,5 @@ include: package:pedantic/analysis_options.yaml +linter: + rules: + - sort_constructors_first + - sort_unnamed_constructors_first diff --git a/lib/client.dart b/lib/client.dart index 7e8fd22e..38a2080f 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -2,5 +2,6 @@ library client; export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/json_api_response.dart'; +export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/routing_client.dart'; +export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/server.dart b/lib/server.dart index 453140b1..b0cabea8 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -2,7 +2,7 @@ library server; export 'package:json_api/src/server/dart_server.dart'; export 'package:json_api/src/server/document_factory.dart'; -export 'package:json_api/src/server/http_response_factory.dart'; +export 'package:json_api/src/server/http_response_converter.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/links/links_factory.dart'; diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart index b08d1ff8..5cfc12b0 100644 --- a/lib/src/client/dart_http.dart +++ b/lib/src/client/dart_http.dart @@ -3,6 +3,8 @@ import 'package:json_api/http.dart'; /// A handler using the Dart's built-in http client class DartHttp implements HttpHandler { + DartHttp(this._client); + @override Future call(HttpRequest request) async { final response = await _send(Request(request.method, request.uri) @@ -12,8 +14,6 @@ class DartHttp implements HttpHandler { body: response.body, headers: response.headers); } - DartHttp(this._client); - final Client _client; Future _send(Request dartRequest) async => diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 971bf71a..e1048ec9 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -4,17 +4,23 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/json_api_response.dart'; +import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/status_code.dart'; /// The JSON:API Client. class JsonApiClient { + /// Creates an instance of JSON:API client. + /// Provide instances of [HttpHandler] (e.g. [DartHttp]) + JsonApiClient(this._httpHandler); + + final HttpHandler _httpHandler; + /// Fetches a resource collection at the [uri]. /// Use [headers] to pass extra HTTP headers. /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollectionAt(Uri uri, + Future> fetchCollectionAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); @@ -23,7 +29,7 @@ class JsonApiClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResourceAt(Uri uri, + Future> fetchResourceAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ResourceData.fromJson); @@ -32,7 +38,7 @@ class JsonApiClient { /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOneAt(Uri uri, + Future> fetchToOneAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToOne.fromJson); @@ -41,7 +47,7 @@ class JsonApiClient { /// Use [queryParameters] to specify extra request parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToManyAt(Uri uri, + Future> fetchToManyAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), ToMany.fromJson); @@ -51,7 +57,7 @@ class JsonApiClient { /// Use [parameters] to specify extra query parameters, such as: /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationshipAt(Uri uri, + Future> fetchRelationshipAt(Uri uri, {Map headers, QueryParameters parameters}) => _call(_get(uri, headers, parameters), Relationship.fromJson); @@ -59,43 +65,41 @@ class JsonApiClient { /// according to its type. /// /// https://jsonapi.org/format/#crud-creating - Future> createResourceAt( - Uri uri, Resource resource, + Future> createResourceAt(Uri uri, Resource resource, {Map headers}) => _call(_post(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); /// Deletes the resource. /// /// https://jsonapi.org/format/#crud-deleting - Future deleteResourceAt(Uri uri, - {Map headers}) => + Future deleteResourceAt(Uri uri, {Map headers}) => _call(_delete(uri, headers), null); /// Updates the resource via PATCH query. /// /// https://jsonapi.org/format/#crud-updating - Future> updateResourceAt( - Uri uri, Resource resource, {Map headers}) => + Future> updateResourceAt(Uri uri, Resource resource, + {Map headers}) => _call( _patch(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); /// Updates a to-one relationship via PATCH query /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOneAt(Uri uri, Identifier identifier, + Future> replaceToOneAt(Uri uri, Identifier identifier, {Map headers}) => _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt] /// with id = null. - Future> deleteToOneAt(Uri uri, + Future> deleteToOneAt(Uri uri, {Map headers}) => replaceToOneAt(uri, null, headers: headers); /// Removes the [identifiers] from the to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToManyAt( + Future> deleteFromToManyAt( Uri uri, Iterable identifiers, {Map headers}) => _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), @@ -108,7 +112,7 @@ class JsonApiClient { /// or return a 403 Forbidden response if complete replacement is not allowed by the server. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToManyAt( + Future> replaceToManyAt( Uri uri, Iterable identifiers, {Map headers}) => _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); @@ -116,17 +120,12 @@ class JsonApiClient { /// Adds the given set of [identifiers] to a to-many relationship. /// /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationshipAt( + Future> addToRelationshipAt( Uri uri, Iterable identifiers, {Map headers}) => _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); - /// Creates an instance of JSON:API client. - /// Provide instances of [HttpHandler] (e.g. [DartHttp]) - JsonApiClient(this._httpHandler); - - final HttpHandler _httpHandler; - static final _api = Api(version: '1.0'); + final _api = Api(version: '1.0'); Document _resourceDoc(Resource resource) => Document(ResourceData.fromResource(resource), api: _api); @@ -179,20 +178,20 @@ class JsonApiClient { }, body: jsonEncode(doc)); - Future> _call( + Future> _call( HttpRequest request, D Function(Object _) decodePrimaryData) async { final response = await _httpHandler(request); final document = response.body.isEmpty ? null : jsonDecode(response.body); if (document == null) { - return JsonApiResponse(response.statusCode, response.headers); + return Response(response.statusCode, response.headers); } if (StatusCode(response.statusCode).isPending) { - return JsonApiResponse(response.statusCode, response.headers, + return Response(response.statusCode, response.headers, asyncDocument: document == null ? null : Document.fromJson(document, ResourceData.fromJson)); } - return JsonApiResponse(response.statusCode, response.headers, + return Response(response.statusCode, response.headers, document: document == null ? null : Document.fromJson(document, decodePrimaryData)); diff --git a/lib/src/client/json_api_response.dart b/lib/src/client/response.dart similarity index 96% rename from lib/src/client/json_api_response.dart rename to lib/src/client/response.dart index 241cef01..8fb34cff 100644 --- a/lib/src/client/json_api_response.dart +++ b/lib/src/client/response.dart @@ -3,8 +3,8 @@ import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/nullable.dart'; /// A response returned by JSON:API client -class JsonApiResponse { - const JsonApiResponse(this.statusCode, this.headers, +class Response { + const Response(this.statusCode, this.headers, {this.document, this.asyncDocument}); /// HTTP status code diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index b7f2e96c..13a21ce2 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -3,91 +3,96 @@ import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'json_api_response.dart'; +import 'response.dart'; /// This is a wrapper over [JsonApiClient] capable of building the /// request URIs by itself. class RoutingClient { + RoutingClient(this._client, this._routing); + + final JsonApiClient _client; + final RouteFactory _routing; + /// Fetches a primary resource collection by [type]. - Future> fetchCollection(String type, + Future> fetchCollection(String type, {Map headers, QueryParameters parameters}) => _client.fetchCollectionAt(_collection(type), headers: headers, parameters: parameters); /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - Future> fetchRelatedCollection( + Future> fetchRelatedCollection( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchCollectionAt(_related(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a primary resource by [type] and [id]. - Future> fetchResource(String type, String id, + Future> fetchResource(String type, String id, {Map headers, QueryParameters parameters}) => _client.fetchResourceAt(_resource(type, id), headers: headers, parameters: parameters); /// Fetches a related resource by [type], [id], [relationship]. - Future> fetchRelatedResource( + Future> fetchRelatedResource( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchResourceAt(_related(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( + Future> fetchToOne( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchToOneAt(_relationship(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( + Future> fetchToMany( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchToManyAt(_relationship(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( + Future> fetchRelationship( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => _client.fetchRelationshipAt(_relationship(type, id, relationship), headers: headers, parameters: parameters); /// Creates the [resource] on the server. - Future> createResource(Resource resource, + Future> createResource(Resource resource, {Map headers}) => _client.createResourceAt(_collection(resource.type), resource, headers: headers); /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, + Future deleteResource(String type, String id, {Map headers}) => _client.deleteResourceAt(_resource(type, id), headers: headers); /// Updates the [resource]. - Future> updateResource(Resource resource, + Future> updateResource(Resource resource, {Map headers}) => _client.updateResourceAt(_resource(resource.type, resource.id), resource, headers: headers); /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( + Future> replaceToOne( String type, String id, String relationship, Identifier identifier, {Map headers}) => _client.replaceToOneAt(_relationship(type, id, relationship), identifier, headers: headers); /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( + Future> deleteToOne( String type, String id, String relationship, {Map headers}) => _client.deleteToOneAt(_relationship(type, id, relationship), headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, + Future> deleteFromToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToManyAt( @@ -95,7 +100,7 @@ class RoutingClient { headers: headers); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, + Future> replaceToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.replaceToManyAt( @@ -103,18 +108,13 @@ class RoutingClient { headers: headers); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToRelationship(String type, String id, + Future> addToRelationship(String type, String id, String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationshipAt( _relationship(type, id, relationship), identifiers, headers: headers); - RoutingClient(this._client, this._routing); - - final JsonApiClient _client; - final RouteFactory _routing; - Uri _collection(String type) => _routing.collection(type); Uri _relationship(String type, String id, String relationship) => diff --git a/lib/src/client/status_code.dart b/lib/src/client/status_code.dart index 4160ce5b..e2971bfe 100644 --- a/lib/src/client/status_code.dart +++ b/lib/src/client/status_code.dart @@ -1,10 +1,10 @@ /// The status code in the HTTP response class StatusCode { + const StatusCode(this.code); + /// The code final int code; - const StatusCode(this.code); - /// True for the requests processed asynchronously. /// @see https://jsonapi.org/recommendations/#asynchronous-processing). bool get isPending => code == 202; diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index cd93d6a0..d9b85194 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -1,8 +1,10 @@ import 'package:json_api/src/document/document_exception.dart'; +import 'package:json_api/src/document/json_encodable.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object -class Api { - static const memberName = 'jsonapi'; +class Api implements JsonEncodable { + Api({this.version, Map meta}) + : meta = meta == null ? null : Map.unmodifiable(meta); /// The JSON:API version. May be null. final String version; @@ -10,16 +12,14 @@ class Api { /// Meta data. May be empty or null. final Map meta; - Api({this.version, Map meta}) - : meta = meta == null ? null : Map.unmodifiable(meta); - static Api fromJson(Object json) { if (json is Map) { return Api(version: json['version'], meta: json['meta']); } - throw DocumentException("The '$memberName' member must be a JSON object"); + throw DocumentException("The 'jsonapi' member must be a JSON object"); } + @override Map toJson() => { if (version != null) ...{'version': version}, if (meta != null) ...{'meta': meta}, diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index da4b1f94..f3201ba6 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -1,23 +1,11 @@ import 'package:json_api/src/document/api.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/primary_data.dart'; +import 'package:json_api/src/nullable.dart'; -class Document { - static const contentType = 'application/vnd.api+json'; - - /// The Primary Data - final Data data; - - /// The jsonapi object. May be null. - final Api api; - - /// List of errors. May be null. - final Iterable errors; - - /// Meta data. May be empty or null. - final Map meta; - +class Document implements JsonEncodable { /// Create a document with primary data Document(this.data, {Map meta, this.api}) : errors = null, @@ -35,17 +23,14 @@ class Document { : data = null, meta = (meta == null) ? null : Map.unmodifiable(meta), errors = null { - DocumentException.throwIfNull(meta, "The 'meta' member must not be null"); + ArgumentError.checkNotNull(meta); } /// Reconstructs a document with the specified primary data static Document fromJson( Object json, Data Function(Object json) primaryData) { if (json is Map) { - Api api; - if (json.containsKey(Api.memberName)) { - api = Api.fromJson(json[Api.memberName]); - } + final api = nullable(Api.fromJson)(json['jsonapi']); if (json.containsKey('errors')) { final errors = json['errors']; if (errors is List) { @@ -54,13 +39,29 @@ class Document { } } else if (json.containsKey('data')) { return Document(primaryData(json), meta: json['meta'], api: api); - } else { + } else if (json['meta'] != null) { return Document.empty(json['meta'], api: api); } + throw DocumentException('Unrecognized JSON:API document structure'); } throw DocumentException('A JSON:API document must be a JSON object'); } + static const contentType = 'application/vnd.api+json'; + + /// The Primary Data + final Data data; + + /// The `jsonapi` object. May be null. + final Api api; + + /// List of errors. May be null. + final Iterable errors; + + /// Meta data. May be empty or null. + final Map meta; + + @override Map toJson() => { if (data != null) ...data.toJson() diff --git a/lib/src/document/document_exception.dart b/lib/src/document/document_exception.dart index 55b0002b..65e02d6c 100644 --- a/lib/src/document/document_exception.dart +++ b/lib/src/document/document_exception.dart @@ -1,12 +1,7 @@ -/// Indicates a violation of JSON:API Document structure or data. +/// Indicates a violation of JSON:API Document structure or data constraints. class DocumentException implements Exception { - /// Human-readable text explaining the issue.. - final String message; - DocumentException(this.message); - /// Throws a [DocumentException] with the [message] if [value] is null. - static void throwIfNull(Object value, String message) { - if (value == null) throw DocumentException(message); - } + /// Human-readable text explaining the issue. + final String message; } diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index ca203cdf..4caa0ada 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,11 +1,53 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject { +class ErrorObject implements JsonEncodable { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [about] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + this.id, + this.status, + this.code, + this.title, + this.detail, + this.parameter, + this.pointer, + Map meta, + Map links, + }) : links = (links == null) ? null : Map.unmodifiable(links), + meta = (meta == null) ? null : Map.unmodifiable(meta); + + static ErrorObject fromJson(Object json) { + if (json is Map) { + String pointer; + String parameter; + final source = json['source']; + if (source is Map) { + parameter = source['parameter']; + pointer = source['pointer']; + } + final links = json['links']; + return ErrorObject( + id: json['id'], + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + pointer: pointer, + parameter: parameter, + meta: json['meta'], + links: (links == null) ? null : Link.mapFromJson(links)); + } + throw DocumentException('A JSON:API error must be a JSON object'); + } + /// A unique identifier for this particular occurrence of the problem. /// May be null. final String id; @@ -50,47 +92,7 @@ class ErrorObject { /// https://jsonapi.org/format/#document-links 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]. - ErrorObject({ - this.id, - this.status, - this.code, - this.title, - this.detail, - this.parameter, - this.pointer, - Map meta, - Map links, - }) : links = (links == null) ? null : Map.unmodifiable(links), - meta = (meta == null) ? null : Map.unmodifiable(meta); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - String pointer; - String parameter; - final source = json['source']; - if (source is Map) { - parameter = source['parameter']; - pointer = source['pointer']; - } - final links = json['links']; - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - pointer: pointer, - parameter: parameter, - meta: json['meta'], - links: (links == null) ? null : Link.mapFromJson(links)); - } - throw DocumentException('A JSON:API error must be a JSON object'); - } - + @override Map toJson() { final source = { if (pointer != null) ...{'pointer': pointer}, diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 82b8b848..43641cac 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -7,21 +7,25 @@ import 'package:json_api/src/document/document_exception.dart'; /// Identifiers are passed between the server and the client in the form /// of [IdentifierObject]s. class Identifier { - /// Resource type - final String type; - - /// Resource id - final String id; - /// Neither [type] nor [id] can be null or empty. Identifier(this.type, this.id) { - DocumentException.throwIfNull(id, "Identifier 'id' must not be null"); - DocumentException.throwIfNull(type, "Identifier 'type' must not be null"); + if (id == null || id.isEmpty) { + throw DocumentException("Identifier 'id' must be not empty"); + } + if (type == null || type.isEmpty) { + throw DocumentException("Identifier 'type' must be not empty"); + } } static Identifier of(Resource resource) => Identifier(resource.type, resource.id); + /// Resource type + final String type; + + /// Resource id + final String id; + /// Returns true if the two identifiers have the same [type] and [id] bool equals(Identifier other) => other != null && diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index a48f1195..c0e6ab30 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,9 +1,18 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/json_encodable.dart'; /// [IdentifierObject] is a JSON representation of the [Identifier]. /// It carries all JSON-related logic and the Meta-data. -class IdentifierObject { +class IdentifierObject implements JsonEncodable { + /// Creates an instance of [IdentifierObject]. + /// [type] and [id] can not be null. + IdentifierObject(this.type, this.id, {Map meta}) + : meta = (meta == null) ? null : Map.unmodifiable(meta) { + ArgumentError.checkNotNull(type); + ArgumentError.checkNotNull(id); + } + /// Resource type final String type; @@ -13,14 +22,6 @@ class IdentifierObject { /// Meta data. May be empty or null. final Map meta; - /// Creates an instance of [IdentifierObject]. - /// [type] and [id] can not be null. - IdentifierObject(this.type, this.id, {Map meta}) - : meta = (meta == null) ? null : Map.unmodifiable(meta) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - } - static IdentifierObject fromIdentifier(Identifier identifier, {Map meta}) => IdentifierObject(identifier.type, identifier.id, meta: meta); @@ -34,6 +35,7 @@ class IdentifierObject { Identifier unwrap() => Identifier(type, id); + @override Map toJson() => { 'type': type, 'id': id, diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart new file mode 100644 index 00000000..78477116 --- /dev/null +++ b/lib/src/document/json_encodable.dart @@ -0,0 +1,4 @@ +abstract class JsonEncodable { + /// Converts the object to a json-encodable representation + Object toJson(); +} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index d9d78a2c..ebf523ea 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,14 +1,15 @@ import 'package:json_api/src/document/document_exception.dart'; +import 'package:json_api/src/document/json_encodable.dart'; /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link { - final Uri uri; - +class Link implements JsonEncodable { Link(this.uri) { ArgumentError.checkNotNull(uri, 'uri'); } + final Uri uri; + /// Reconstructs the link from the [json] object static Link fromJson(Object json) { if (json is String) return Link(Uri.parse(json)); @@ -32,6 +33,7 @@ class Link { throw DocumentException('A JSON:API links object must be a JSON object'); } + @override Object toJson() => uri.toString(); @override @@ -41,10 +43,10 @@ class Link { /// A JSON:API link object /// https://jsonapi.org/format/#document-links class LinkObject extends Link { - final Map meta; - LinkObject(Uri href, {this.meta}) : super(href); + final Map meta; + @override Object toJson() => { 'href': uri.toString(), diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 4504a0da..4ca5577f 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -1,3 +1,4 @@ +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/resource_object.dart'; @@ -6,29 +7,26 @@ import 'package:json_api/src/document/resource_object.dart'; /// [PrimaryData] may be considered a Document itself with two limitations: /// - 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 a Compound document this member contains the included resources. - /// May be empty or null. +abstract class PrimaryData implements JsonEncodable { + PrimaryData({Iterable included, Map links}) + : included = (included == null) ? null : List.unmodifiable(included), + links = (links == null) ? null : Map.unmodifiable(links); + + /// In a Compound document, this member contains the included resources. + /// May be empty or null, this is to distinguish between two cases: + /// - Inclusion was requested, but no resources were found (empty list) + /// - Inclusion was not requested (null) final List included; /// The top-level `links` object. May be empty or null. final Map links; - PrimaryData({Iterable included, Map links}) - : included = - (included == null) ? null : List.unmodifiable(_unique(included)), - links = (links == null) ? null : Map.unmodifiable(links); - /// The `self` link. May be null. Link get self => (links ?? {})['self']; - /// Top-level JSON object + @override Map toJson() => { if (links != null) ...{'links': links}, if (included != null) ...{'included': included} }; } - -Iterable _unique(Iterable included) => - Map.fromIterable(included, - key: (_) => '${_.type}:${_.id}').values; diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 879d28b3..8ea0fbe4 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -15,9 +15,6 @@ import 'package:json_api/src/nullable.dart'; /// /// More on this: https://jsonapi.org/format/#document-resource-object-relationships class Relationship extends PrimaryData { - /// The "related" link. May be null. - Link get related => (links ?? {})['related']; - Relationship({Iterable included, Map links}) : super(included: included, links: links); @@ -50,23 +47,12 @@ class Relationship extends PrimaryData { throw DocumentException("The 'relationships' member must be a JSON object"); } - /// Top-level JSON object - @override - Map toJson() => { - ...super.toJson(), - if (links != null) ...{'links': links} - }; + /// The "related" link. May be null. + Link get related => (links ?? {})['related']; } /// Relationship to-one class ToOne extends Relationship { - /// Resource Linkage - /// - /// Can be null for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final IdentifierObject linkage; - ToOne(this.linkage, {Iterable included, Map links}) : super(included: included, links: links); @@ -85,12 +71,19 @@ class ToOne extends Relationship { return ToOne(nullable(IdentifierObject.fromJson)(json['data']), links: (links == null) ? null : Link.mapFromJson(links), included: - included is List ? ResourceObject.fromJsonList(included) : null); + included is List ? included.map(ResourceObject.fromJson) : null); } throw DocumentException( "A to-one relationship must be a JSON object and contain the 'data' member"); } + /// Resource Linkage + /// + /// Can be null for empty relationships + /// + /// More on this: https://jsonapi.org/format/#document-resource-object-linkage + final IdentifierObject linkage; + @override Map toJson() => { ...super.toJson(), @@ -101,19 +94,12 @@ class ToOne extends Relationship { /// For empty relationships returns null. Identifier unwrap() => linkage?.unwrap(); - /// Same as [unwrap()] + /// Same as [unwrap] Identifier get identifier => unwrap(); } /// Relationship to-many class ToMany extends Relationship { - /// Resource Linkage - /// - /// Can be empty for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final Iterable linkage; - ToMany(Iterable linkage, {Iterable included, Map links}) : linkage = List.unmodifiable(linkage), @@ -137,6 +123,13 @@ class ToMany extends Relationship { "A to-many relationship must be a JSON object and contain the 'data' member"); } + /// Resource Linkage + /// + /// Can be empty for empty relationships + /// + /// More on this: https://jsonapi.org/format/#document-resource-object-linkage + final Iterable linkage; + @override Map toJson() => { ...super.toJson(), diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index cd50ad16..1fbc5797 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -7,10 +7,26 @@ import 'package:json_api/src/document/identifier.dart'; /// Resources are passed between the server and the client in the form /// of [ResourceObject]s. class Resource { - /// Resource type. + /// Creates an instance of [Resource]. + /// The [type] can not be null. + /// The [id] may be null for the resources to be created on the server. + Resource(this.type, this.id, + {Map attributes, + Map toOne, + Map> toMany}) + : attributes = Map.unmodifiable(attributes ?? {}), + toOne = Map.unmodifiable(toOne ?? {}), + toMany = Map.unmodifiable( + (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { + if (type == null || type.isEmpty) { + throw DocumentException("Resource 'type' must be not empty"); + } + } + + /// Resource type final String type; - /// Resource id. + /// Resource id /// /// May be null for resources to be created on the server final String id; @@ -29,23 +45,9 @@ class Resource { @override String toString() => 'Resource($key $attributes)'; - - /// Creates an instance of [Resource]. - /// The [type] can not be null. - /// The [id] may be null for the resources to be created on the server. - Resource(this.type, this.id, - {Map attributes, - Map toOne, - Map> toMany}) - : attributes = Map.unmodifiable(attributes ?? {}), - toOne = Map.unmodifiable(toOne ?? {}), - toMany = Map.unmodifiable( - (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { - DocumentException.throwIfNull(type, "Resource 'type' must not be null"); - } } -/// Resource to be created on the server. Does not have the id yet. +/// Resource to be created on the server. Does not have the id yet class NewResource extends Resource { NewResource(String type, {Map attributes, diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index f2f100c6..58124101 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -6,10 +6,8 @@ import 'package:json_api/src/document/resource_object.dart'; /// Represents a resource collection or a collection of related resources of a to-many relationship class ResourceCollectionData extends PrimaryData { - final Iterable collection; - ResourceCollectionData(Iterable collection, - {Iterable included, Map links = const {}}) + {Iterable included, Map links}) : collection = List.unmodifiable(collection), super(included: included, links: links); @@ -21,7 +19,7 @@ class ResourceCollectionData extends PrimaryData { return ResourceCollectionData(data.map(ResourceObject.fromJson), links: Link.mapFromJson(json['links'] ?? {}), included: included is List - ? ResourceObject.fromJsonList(included) + ? included.map(ResourceObject.fromJson) : null); } } @@ -29,6 +27,8 @@ class ResourceCollectionData extends PrimaryData { "A JSON:API resource collection document must be a JSON object with a JSON array in the 'data' member"); } + final Iterable collection; + /// The link to the last page. May be null. Link get last => (links ?? {})['last']; diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 28d391d1..dd2e2490 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -7,8 +7,6 @@ import 'package:json_api/src/nullable.dart'; /// Represents a single resource or a single related resource of a to-one relationship class ResourceData extends PrimaryData { - final ResourceObject resourceObject; - ResourceData(this.resourceObject, {Iterable included, Map links}) : super( @@ -37,6 +35,8 @@ class ResourceData extends PrimaryData { "A JSON:API resource document must be a JSON object and contain the 'data' member"); } + final ResourceObject resourceObject; + @override Map toJson() => { ...super.toJson(), diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 738df40f..d03c3e0b 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -1,6 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/document/resource.dart'; @@ -13,17 +14,7 @@ import 'package:json_api/src/nullable.dart'; /// resource collection. /// /// More on this: https://jsonapi.org/format/#document-resource-objects -class ResourceObject { - final String type; - final String id; - - final Map attributes; - final Map relationships; - final Map meta; - - /// Read-only `links` object. May be empty. - final Map links; - +class ResourceObject implements JsonEncodable { ResourceObject(this.type, this.id, {Map attributes, Map relationships, @@ -35,8 +26,6 @@ class ResourceObject { relationships = (relationships == null) ? null : Map.unmodifiable(relationships); - Link get self => (links ?? {})['self']; - static ResourceObject fromResource(Resource resource) => ResourceObject(resource.type, resource.id, attributes: resource.attributes, @@ -65,11 +54,20 @@ class ResourceObject { throw DocumentException('A JSON:API resource must be a JSON object'); } - static Iterable fromJsonList(Iterable json) => - json.map(fromJson); + final String type; + final String id; + final Map attributes; + final Map relationships; + final Map meta; + + /// Read-only `links` object. May be empty. + final Map links; + + Link get self => (links ?? {})['self']; /// Returns the JSON object to be used in the `data` or `included` members /// of a JSON:API Document + @override Map toJson() => { 'type': type, if (id != null) ...{'id': id}, diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart index 794702b9..7693fd07 100644 --- a/lib/src/http/http_handler.dart +++ b/lib/src/http/http_handler.dart @@ -14,10 +14,10 @@ abstract class HttpHandler { typedef HttpHandlerFunc = Future Function(HttpRequest request); class _HandlerFromFunction implements HttpHandler { + const _HandlerFromFunction(this._f); + @override Future call(HttpRequest request) => _f(request); - const _HandlerFromFunction(this._f); - final HttpHandlerFunc _f; } diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index e180b1d7..8fa85ce4 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,5 +1,12 @@ /// The request which is sent by the client and received by the server class HttpRequest { + HttpRequest(String method, this.uri, + {String body, Map headers}) + : headers = Map.unmodifiable( + (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), + method = method.toUpperCase(), + body = body ?? ''; + /// Requested URI final Uri uri; @@ -11,11 +18,4 @@ class HttpRequest { /// Request headers. Unmodifiable. Lowercase keys final Map headers; - - HttpRequest(String method, this.uri, - {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - method = method.toUpperCase(), - body = body ?? ''; } diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index b5d70721..dbe90e7e 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -1,5 +1,10 @@ /// The response sent by the server and received by the client class HttpResponse { + HttpResponse(this.statusCode, {String body, Map headers}) + : headers = Map.unmodifiable( + (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), + body = body ?? ''; + /// Response status code final int statusCode; @@ -8,9 +13,4 @@ class HttpResponse { /// Response headers. Unmodifiable. Lowercase keys final Map headers; - - HttpResponse(this.statusCode, {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - body = body ?? ''; } diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart index ccbd23d9..ddc092d6 100644 --- a/lib/src/http/logging_http_handler.dart +++ b/lib/src/http/logging_http_handler.dart @@ -4,6 +4,8 @@ import 'package:json_api/src/http/http_response.dart'; /// A wrapper over [HttpHandler] which allows logging class LoggingHttpHandler implements HttpHandler { + LoggingHttpHandler(this.wrapped, {this.onRequest, this.onResponse}); + /// The wrapped handler final HttpHandler wrapped; @@ -20,6 +22,4 @@ class LoggingHttpHandler implements HttpHandler { onResponse?.call(response); return response; } - - LoggingHttpHandler(this.wrapped, {this.onRequest, this.onResponse}); } diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart index 3a6e4e67..42385739 100644 --- a/lib/src/query/query_parameters.dart +++ b/lib/src/query/query_parameters.dart @@ -1,6 +1,9 @@ /// This class and its descendants describe the query parameters recognized /// by JSON:API. class QueryParameters { + QueryParameters(Map parameters) + : _parameters = {...parameters}; + bool get isEmpty => _parameters.isEmpty; bool get isNotEmpty => _parameters.isNotEmpty; @@ -18,8 +21,5 @@ class QueryParameters { QueryParameters operator &(QueryParameters moreParameters) => merge(moreParameters); - QueryParameters(Map parameters) - : _parameters = {...parameters}; - final Map _parameters; } diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 108e98c8..0a6ffc56 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -31,18 +31,6 @@ class Sort extends QueryParameters with IterableMixin { } class SortField { - final bool isAsc; - - final bool isDesc; - - final String name; - - /// Returns 1 for Ascending fields, -1 for Descending - int get comparisonFactor => isAsc ? 1 : -1; - - @override - String toString() => isAsc ? name : '-$name'; - SortField.Asc(this.name) : isAsc = true, isDesc = false; @@ -54,6 +42,17 @@ class SortField { static SortField parse(String queryParam) => queryParam.startsWith('-') ? Desc(queryParam.substring(1)) : Asc(queryParam); + final bool isAsc; + + final bool isDesc; + + final String name; + + /// Returns 1 for Ascending fields, -1 for Descending + int get comparisonFactor => isAsc ? 1 : -1; + + @override + String toString() => isAsc ? name : '-$name'; } class Asc extends SortField { diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart index dea64c47..2d40b0e1 100644 --- a/lib/src/routing/composite_routing.dart +++ b/lib/src/routing/composite_routing.dart @@ -4,6 +4,14 @@ import 'package:json_api/src/routing/resource_route.dart'; import 'package:json_api/src/routing/routing.dart'; class CompositeRouting implements Routing { + CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, + this.relationshipRoute); + + final CollectionRoute collectionRoute; + final ResourceRoute resourceRoute; + final RelationshipRoute relatedRoute; + final RelationshipRoute relationshipRoute; + @override Uri collection(String type) => collectionRoute.uri(type); @@ -35,12 +43,4 @@ class CompositeRouting implements Routing { @override bool matchResource(Uri uri, void Function(String type, String id) onMatch) => resourceRoute.match(uri, onMatch); - - CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, - this.relationshipRoute); - - final CollectionRoute collectionRoute; - final ResourceRoute resourceRoute; - final RelationshipRoute relatedRoute; - final RelationshipRoute relationshipRoute; } diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart index 721f101f..e57b32af 100644 --- a/lib/src/routing/standard_routes.dart +++ b/lib/src/routing/standard_routes.dart @@ -3,6 +3,8 @@ import 'package:json_api/src/routing/relationship_route.dart'; import 'package:json_api/src/routing/resource_route.dart'; class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { + StandardCollectionRoute([Uri base]) : super(base); + @override bool match(Uri uri, void Function(String type) onMatch) { final seg = _segments(uri); @@ -15,11 +17,11 @@ class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { @override Uri uri(String type) => _resolve([type]); - - StandardCollectionRoute([Uri base]) : super(base); } class StandardResourceRoute extends _BaseRoute implements ResourceRoute { + StandardResourceRoute([Uri base]) : super(base); + @override bool match(Uri uri, void Function(String type, String id) onMatch) { final seg = _segments(uri); @@ -32,11 +34,11 @@ class StandardResourceRoute extends _BaseRoute implements ResourceRoute { @override Uri uri(String type, String id) => _resolve([type, id]); - - StandardResourceRoute([Uri base]) : super(base); } class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { + StandardRelatedRoute([Uri base]) : super(base); + @override bool match(Uri uri, void Function(String type, String id, String relationship) onMatch) { @@ -51,12 +53,12 @@ class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { @override Uri uri(String type, String id, String relationship) => _resolve([type, id, relationship]); - - StandardRelatedRoute([Uri base]) : super(base); } class StandardRelationshipRoute extends _BaseRoute implements RelationshipRoute { + StandardRelationshipRoute([Uri base]) : super(base); + @override bool match(Uri uri, void Function(String type, String id, String relationship) onMatch) { @@ -72,8 +74,6 @@ class StandardRelationshipRoute extends _BaseRoute Uri uri(String type, String id, String relationship) => _resolve([type, id, _rel, relationship]); - StandardRelationshipRoute([Uri base]) : super(base); - static const _rel = 'relationships'; } diff --git a/lib/src/server/dart_server.dart b/lib/src/server/dart_server.dart index ccff238e..04850ba1 100644 --- a/lib/src/server/dart_server.dart +++ b/lib/src/server/dart_server.dart @@ -4,10 +4,10 @@ import 'dart:io' as dart; import 'package:json_api/http.dart'; class DartServer { - final HttpHandler _handler; - DartServer(this._handler); + final HttpHandler _handler; + Future call(dart.HttpRequest request) async { final response = await _handler(await _convertRequest(request)); response.headers.forEach(request.response.headers.add); diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart index cd73c4dd..4887c56d 100644 --- a/lib/src/server/document_factory.dart +++ b/lib/src/server/document_factory.dart @@ -6,6 +6,12 @@ import 'package:json_api/src/server/pagination.dart'; /// The factory producing JSON:API Documents class DocumentFactory { + DocumentFactory({LinksFactory links = const NoLinks()}) : _links = links; + + final Api _api = Api(version: '1.0'); + + final LinksFactory _links; + /// An error document Document error(Iterable errors) => Document.error(errors, api: _api); @@ -57,12 +63,6 @@ class DocumentFactory { ), api: _api); - DocumentFactory({LinksFactory links = const NoLinks()}) : _links = links; - - final Api _api = Api(version: '1.0'); - - final LinksFactory _links; - ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, attributes: r.attributes, relationships: { diff --git a/lib/src/server/http_response_factory.dart b/lib/src/server/http_response_converter.dart similarity index 91% rename from lib/src/server/http_response_factory.dart rename to lib/src/server/http_response_converter.dart index 60ba9018..217ee888 100644 --- a/lib/src/server/http_response_factory.dart +++ b/lib/src/server/http_response_converter.dart @@ -8,18 +8,23 @@ import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/response_converter.dart'; /// An implementation of [ResponseConverter] converting to [HttpResponse]. -class HttpResponseFactory implements ResponseConverter { +class HttpResponseConverter implements ResponseConverter { + HttpResponseConverter(this._doc, this._routing); + + final RouteFactory _routing; + final DocumentFactory _doc; + @override HttpResponse error(Iterable errors, int statusCode, Map headers) => _ok(_doc.error(errors), status: statusCode, headers: headers); @override - HttpResponse collection(Iterable collection, + HttpResponse collection(Iterable resources, {int total, Iterable included, Pagination pagination = const NoPagination()}) { - return _ok(_doc.collection(collection, + return _ok(_doc.collection(resources, total: total, included: included, pagination: pagination)); } @@ -62,11 +67,6 @@ class HttpResponseFactory implements ResponseConverter { @override HttpResponse noContent() => HttpResponse(204); - HttpResponseFactory(this._doc, this._routing); - - final RouteFactory _routing; - final DocumentFactory _doc; - HttpResponse _ok(Document d, {int status = 200, Map headers = const {}}) => HttpResponse(status, diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 89db25d9..f4d5fe64 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -8,6 +8,8 @@ typedef TypeAttributionCriteria = bool Function(String collection, String type); /// An in-memory implementation of [Repository] class InMemoryRepository implements Repository { + InMemoryRepository(this._collections, {IdGenerator nextId}) + : _nextId = nextId; final Map> _collections; final IdGenerator _nextId; @@ -99,7 +101,4 @@ class InMemoryRepository implements Repository { return InvalidType( "Type '${resource.type}' does not belong in '$collection'"); } - - InMemoryRepository(this._collections, {IdGenerator nextId}) - : _nextId = nextId; } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index dfa5d283..a9bd3a5a 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -5,17 +5,23 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/request_factory.dart'; +import 'package:json_api/src/server/request_converter.dart'; import 'package:json_api/src/server/request_handler.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { + JsonApiServer(this._controller, {RouteFactory routing}) + : _routing = routing ?? StandardRouting(); + + final RouteFactory _routing; + final RequestHandler> _controller; + @override Future call(HttpRequest httpRequest) async { Request jsonApiRequest; Response jsonApiResponse; try { - jsonApiRequest = RequestFactory().createFromHttp(httpRequest); + jsonApiRequest = RequestConverter().convert(httpRequest); } on FormatException catch (e) { jsonApiResponse = ErrorResponse.badRequest([ ErrorObject( @@ -59,13 +65,7 @@ class JsonApiServer implements HttpHandler { final links = StandardLinks(httpRequest.uri, _routing); final documentFactory = DocumentFactory(links: links); - final responseFactory = HttpResponseFactory(documentFactory, _routing); + final responseFactory = HttpResponseConverter(documentFactory, _routing); return jsonApiResponse.convert(responseFactory); } - - JsonApiServer(this._controller, {RouteFactory routing}) - : _routing = routing ?? StandardRouting(); - - final RouteFactory _routing; - final RequestHandler> _controller; } diff --git a/lib/src/server/links/no_links.dart b/lib/src/server/links/no_links.dart index b1122f16..f1cc86b7 100644 --- a/lib/src/server/links/no_links.dart +++ b/lib/src/server/links/no_links.dart @@ -2,6 +2,8 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/document/link.dart'; class NoLinks implements LinksFactory { + const NoLinks(); + @override Map collection(int total, Pagination pagination) => const {}; @@ -18,6 +20,4 @@ class NoLinks implements LinksFactory { @override Map resourceRelationship(String type, String id, String rel) => const {}; - - const NoLinks(); } diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart index 2fff065a..8f1cb6d9 100644 --- a/lib/src/server/links/standard_links.dart +++ b/lib/src/server/links/standard_links.dart @@ -5,6 +5,11 @@ import 'package:json_api/src/server/links/links_factory.dart'; import 'package:json_api/src/server/pagination.dart'; class StandardLinks implements LinksFactory { + StandardLinks(this._requested, this._route); + + final Uri _requested; + final RouteFactory _route; + @override Map resource() => {'self': Link(_requested)}; @@ -29,11 +34,6 @@ class StandardLinks implements LinksFactory { 'related': Link(_route.related(type, id, rel)) }; - StandardLinks(this._requested, this._route); - - final Uri _requested; - final RouteFactory _route; - Map _navigation(int total, Pagination pagination) { final page = Page.fromUri(_requested); diff --git a/lib/src/server/pagination.dart b/lib/src/server/pagination.dart index 9b5e408e..9877a16d 100644 --- a/lib/src/server/pagination.dart +++ b/lib/src/server/pagination.dart @@ -47,12 +47,12 @@ class NoPagination implements Pagination { /// Pages of fixed [size]. class FixedSizePage implements Pagination { - final int size; - FixedSizePage(this.size) { if (size < 1) throw ArgumentError(); } + final int size; + @override Page first() => _page(1); diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 8e89e6e2..a3fcc018 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:json_api/src/document/resource.dart'; +/// The Repository translates CRUD operations on resources to actual data +/// manipulation. abstract class Repository { /// Creates the [resource] in the [collection]. /// If the resource was modified during creation, @@ -39,40 +41,52 @@ abstract class Repository { /// A collection of elements (e.g. resources) returned by the server. class Collection { + Collection(this.elements, [this.total]); + final Iterable elements; /// Total count of the elements on the server. May be null. final int total; - - Collection(this.elements, [this.total]); } +/// Thrown when the requested collection does not exist +/// This exception should result in HTTP 404. class CollectionNotFound implements Exception { - final String message; - CollectionNotFound(this.message); -} -class ResourceNotFound implements Exception { final String message; +} +/// Thrown when the requested resource does not exist. +/// This exception should result in HTTP 404. +class ResourceNotFound implements Exception { ResourceNotFound(this.message); -} -class UnsupportedOperation implements Exception { final String message; +} +/// Thrown if the operation +/// is not supported (e.g. the client sent a resource without the id, but +/// the id generation is not supported by this repository). +/// This exception should result in HTTP 403. +class UnsupportedOperation implements Exception { UnsupportedOperation(this.message); -} -class InvalidType implements Exception { final String message; +} +/// Thrown if the resource type does not belong to the collection. +/// This exception should result in HTTP 409. +class InvalidType implements Exception { InvalidType(this.message); -} -class ResourceExists implements Exception { final String message; +} +/// Thrown if the client asks to create a resource which already exists. +/// This exception should result in HTTP 409. +class ResourceExists implements Exception { ResourceExists(this.message); + + final String message; } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 478c865e..6d2907f6 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -7,8 +7,13 @@ import 'package:json_api/src/server/repository.dart'; import 'package:json_api/src/server/request_handler.dart'; import 'package:json_api/src/server/response.dart'; -/// An opinionated implementation of [RequestHandler] +/// An opinionated implementation of [RequestHandler]. It translates JSON:API +/// requests to [Repository] methods calls. class RepositoryController implements RequestHandler> { + RepositoryController(this._repo); + + final Repository _repo; + @override FutureOr addToRelationship(final String type, final String id, final String relationship, final Iterable identifiers) => @@ -163,9 +168,10 @@ class RepositoryController implements RequestHandler> { return NoContentResponse(); }); - RepositoryController(this._repo); - - final Repository _repo; + @override + FutureOr deleteToOne( + final String type, final String id, final String relationship) => + replaceToOne(type, id, relationship, null); FutureOr _getByIdentifier(Identifier identifier) => _repo.get(identifier.type, identifier.id); @@ -191,9 +197,13 @@ class RepositoryController implements RequestHandler> { resources.add(r); } } - return resources; + return _unique(resources); } + Iterable _unique(Iterable included) => + Map.fromIterable(included, + key: (_) => '${_.type}:${_.id}').values; + FutureOr _do(FutureOr Function() action) async { try { return await action(); @@ -224,9 +234,7 @@ class RepositoryController implements RequestHandler> { } } - Response _relationshipNotFound( - String relationship, - ) { + Response _relationshipNotFound(String relationship) { return ErrorResponse.notFound([ ErrorObject( status: '404', diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 57705507..79216fbb 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -8,144 +8,249 @@ abstract class Request { } /// A request to fetch a collection of type [type]. +/// +/// See: https://jsonapi.org/format/#fetching-resources class FetchCollection implements Request { + FetchCollection(this.queryParameters, this.type); + + /// Resource type final String type; + /// URI query parameters final Map> queryParameters; - FetchCollection(this.queryParameters, this.type); - @override T handleWith(RequestHandler controller) => controller.fetchCollection(type, queryParameters); } +/// A request to create a resource on the server +/// +/// See: https://jsonapi.org/format/#crud-creating class CreateResource implements Request { + CreateResource(this.type, this.resource); + + /// Resource type final String type; + /// Resource to create final Resource resource; - CreateResource(this.type, this.resource); - @override T handleWith(RequestHandler controller) => controller.createResource(type, resource); } +/// A request to update a resource on the server +/// +/// See: https://jsonapi.org/format/#crud-updating class UpdateResource implements Request { + UpdateResource(this.type, this.id, this.resource); + + /// Resource type final String type; + + /// Resource id final String id; + /// Resource containing fields to be updated final Resource resource; - UpdateResource(this.type, this.id, this.resource); - @override T handleWith(RequestHandler controller) => controller.updateResource(type, id, resource); } +/// A request to delete a resource on the server +/// +/// See: https://jsonapi.org/format/#crud-deleting class DeleteResource implements Request { + DeleteResource(this.type, this.id); + + /// Resource type final String type; + /// Resource id final String id; - DeleteResource(this.type, this.id); - @override T handleWith(RequestHandler controller) => controller.deleteResource(type, id); } +/// A request to fetch a resource +/// +/// See: https://jsonapi.org/format/#fetching-resources class FetchResource implements Request { + FetchResource(this.type, this.id, this.queryParameters); + + /// Resource type final String type; + + /// Resource id final String id; + /// URI query parameters final Map> queryParameters; - FetchResource(this.type, this.id, this.queryParameters); - @override T handleWith(RequestHandler controller) => controller.fetchResource(type, id, queryParameters); } +/// A request to fetch a related resource or collection +/// +/// See: https://jsonapi.org/format/#fetching class FetchRelated implements Request { + FetchRelated(this.type, this.id, this.relationship, this.queryParameters); + + /// Resource type final String type; + + /// Resource id final String id; + + /// Relationship name final String relationship; + /// URI query parameters final Map> queryParameters; - FetchRelated(this.type, this.id, this.relationship, this.queryParameters); - @override T handleWith(RequestHandler controller) => controller.fetchRelated(type, id, relationship, queryParameters); } +/// A request to fetch a relationship +/// +/// See: https://jsonapi.org/format/#fetching-relationships class FetchRelationship implements Request { + FetchRelationship( + this.type, this.id, this.relationship, this.queryParameters); + + /// Resource type final String type; + + /// Resource id final String id; + + /// Relationship name final String relationship; + /// URI query parameters final Map> queryParameters; - FetchRelationship( - this.type, this.id, this.relationship, this.queryParameters); - @override T handleWith(RequestHandler controller) => controller.fetchRelationship(type, id, relationship, queryParameters); } +/// A request to delete identifiers from a relationship +/// +/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships class DeleteFromRelationship implements Request { + DeleteFromRelationship( + this.type, this.id, this.relationship, this.identifiers); + + /// Resource type final String type; + + /// Resource id final String id; + + /// Relationship name final String relationship; - final Iterable identifiers; - DeleteFromRelationship( - this.type, this.id, this.relationship, this.identifiers); + /// The identifiers to delete + final Iterable identifiers; @override T handleWith(RequestHandler controller) => controller.deleteFromRelationship(type, id, relationship, identifiers); } +/// A request to replace a to-one relationship +/// +/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships class ReplaceToOne implements Request { + ReplaceToOne(this.type, this.id, this.relationship, this.identifier); + + /// Resource type final String type; + + /// Resource id final String id; + + /// Relationship name final String relationship; - final Identifier identifier; - ReplaceToOne(this.type, this.id, this.relationship, this.identifier); + /// The identifier to be put instead of the existing + final Identifier identifier; @override T handleWith(RequestHandler controller) => controller.replaceToOne(type, id, relationship, identifier); } -class ReplaceToMany implements Request { +/// A request to delete a to-one relationship +/// +/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships +class DeleteToOne implements Request { + DeleteToOne(this.type, this.id, this.relationship); + + /// Resource type final String type; + + /// Resource id final String id; + final String relationship; - final Iterable identifiers; + @override + T handleWith(RequestHandler controller) => + controller.replaceToOne(type, id, relationship, null); +} + +/// A request to completely replace a to-many relationship +/// +/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships +class ReplaceToMany implements Request { ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); + /// Resource type + final String type; + + /// Resource id + final String id; + + /// Relationship name + final String relationship; + + /// The set of identifiers to replace the current ones + final Iterable identifiers; + @override T handleWith(RequestHandler controller) => controller.replaceToMany(type, id, relationship, identifiers); } +/// A request to add identifiers to a to-many relationship +/// +/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships class AddToRelationship implements Request { + AddToRelationship(this.type, this.id, this.relationship, this.identifiers); + + /// Resource type final String type; + + /// Resource id final String id; + + /// Relationship name final String relationship; - final Iterable identifiers; - AddToRelationship(this.type, this.id, this.relationship, this.identifiers); + /// The identifiers to be added to the existing ones + final Iterable identifiers; @override T handleWith(RequestHandler controller) => diff --git a/lib/src/server/request_factory.dart b/lib/src/server/request_converter.dart similarity index 80% rename from lib/src/server/request_factory.dart rename to lib/src/server/request_converter.dart index 9faa1d77..65ea5bb6 100644 --- a/lib/src/server/request_factory.dart +++ b/lib/src/server/request_converter.dart @@ -5,9 +5,14 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/request.dart'; -class RequestFactory { +/// Converts HTTP requests to JSON:API requests +class RequestConverter { + RequestConverter({RouteMatcher routeMatcher}) + : _matcher = routeMatcher ?? StandardRouting(); + final RouteMatcher _matcher; + /// Creates a [Request] from [httpRequest] - Request createFromHttp(HttpRequest httpRequest) { + Request convert(HttpRequest httpRequest) { String type; String id; String rel; @@ -36,7 +41,7 @@ class RequestFactory { return CreateResource(type, ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: - throw MethodNotAllowedException(allow: ['GET', 'POST']); + throw MethodNotAllowedException(['GET', 'POST']); } } else if (_matcher.matchResource(uri, setTypeId)) { switch (httpRequest.method) { @@ -48,14 +53,14 @@ class RequestFactory { return UpdateResource(type, id, ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: - throw MethodNotAllowedException(allow: ['DELETE', 'GET', 'PATCH']); + throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH']); } } else if (_matcher.matchRelated(uri, setTypeIdRel)) { switch (httpRequest.method) { case 'GET': return FetchRelated(type, id, rel, uri.queryParametersAll); default: - throw MethodNotAllowedException(allow: ['GET']); + throw MethodNotAllowedException(['GET']); } } else if (_matcher.matchRelationship(uri, setTypeIdRel)) { switch (httpRequest.method) { @@ -67,7 +72,11 @@ class RequestFactory { case 'PATCH': final r = Relationship.fromJson(jsonDecode(httpRequest.body)); if (r is ToOne) { - return ReplaceToOne(type, id, rel, r.unwrap()); + final identifier = r.unwrap(); + if (identifier != null) { + return ReplaceToOne(type, id, rel, identifier); + } + return DeleteToOne(type, id, rel); } if (r is ToMany) { return ReplaceToMany(type, id, rel, r.unwrap()); @@ -77,25 +86,21 @@ class RequestFactory { return AddToRelationship(type, id, rel, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: - throw MethodNotAllowedException( - allow: ['DELETE', 'GET', 'PATCH', 'POST']); + throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH', 'POST']); } } throw UnmatchedUriException(); } - - RequestFactory({RouteMatcher routeMatcher}) - : _matcher = routeMatcher ?? StandardRouting(); - final RouteMatcher _matcher; } class RequestFactoryException implements Exception {} /// Thrown if HTTP method is not allowed for the given route class MethodNotAllowedException implements RequestFactoryException { - final Iterable allow; + MethodNotAllowedException(this.allow); - MethodNotAllowedException({this.allow = const []}); + /// List of allowed methods + final Iterable allow; } /// Thrown if the request URI can not be matched to a target diff --git a/lib/src/server/request_handler.dart b/lib/src/server/request_handler.dart index c6776fb7..7f4494d8 100644 --- a/lib/src/server/request_handler.dart +++ b/lib/src/server/request_handler.dart @@ -43,6 +43,10 @@ abstract class RequestHandler { T replaceToOne(final String type, final String id, final String relationship, final Identifier identifier); + /// Deletes the to-one relationship. + /// See https://jsonapi.org/format/#crud-updating-to-one-relationships + T deleteToOne(final String type, final String id, final String relationship); + /// Replaces the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships T replaceToMany(final String type, final String id, final String relationship, diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index bd45b059..a77dae82 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,134 +1,212 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response_converter.dart'; -/// the base interface for all JSON:API responses +/// The base interface for all JSON:API responses abstract class Response { /// Converts the JSON:API response to another object, e.g. HTTP response. T convert(ResponseConverter converter); } +/// HTTP 204 No Content response. +/// +/// See: +/// - https://jsonapi.org/format/#crud-creating-responses-204 +/// - https://jsonapi.org/format/#crud-updating-responses-204 +/// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 +/// - https://jsonapi.org/format/#crud-deleting-responses-204 class NoContentResponse implements Response { @override T convert(ResponseConverter converter) => converter.noContent(); } +/// HTTP 200 OK response with a resource collection. +/// +/// See: https://jsonapi.org/format/#fetching-resources-responses-200 class CollectionResponse implements Response { - final Iterable collection; + CollectionResponse(this.resources, {this.included, this.total}); + + final Iterable resources; final Iterable included; - final int total; - CollectionResponse(this.collection, {this.included, this.total}); + final int total; @override T convert(ResponseConverter converter) => - converter.collection(collection, included: included, total: total); + converter.collection(resources, included: included, total: total); } +/// HTTP 202 Accepted response. +/// +/// See: https://jsonapi.org/recommendations/#asynchronous-processing class AcceptedResponse implements Response { - final Resource resource; - AcceptedResponse(this.resource); + final Resource resource; + @override T convert(ResponseConverter converter) => converter.accepted(resource); } +/// A common error response. +/// +/// See: https://jsonapi.org/format/#errors class ErrorResponse implements Response { - final Iterable errors; - final int statusCode; - - ErrorResponse(this.statusCode, this.errors); - - static Response badRequest(Iterable errors) => - ErrorResponse(400, errors); - - static Response forbidden(Iterable errors) => - ErrorResponse(403, errors); - - static Response notFound(Iterable errors) => - ErrorResponse(404, errors); - + ErrorResponse(this.statusCode, this.errors, + {Map headers = const {}}) + : _headers = Map.unmodifiable(headers); + + /// HTTP 400 Bad Request response. + /// + /// See: + /// - https://jsonapi.org/format/#fetching-includes + /// - https://jsonapi.org/format/#fetching-sorting + /// - https://jsonapi.org/format/#query-parameters + ErrorResponse.badRequest(Iterable errors) : this(400, errors); + + /// HTTP 403 Forbidden response. + /// + /// See: + /// - https://jsonapi.org/format/#crud-creating-client-ids + /// - https://jsonapi.org/format/#crud-creating-responses-403 + /// - https://jsonapi.org/format/#crud-updating-resource-relationships + /// - https://jsonapi.org/format/#crud-updating-relationship-responses-403 + ErrorResponse.forbidden(Iterable errors) : this(403, errors); + + /// HTTP 404 Not Found response. + /// + /// See: + /// - https://jsonapi.org/format/#fetching-resources-responses-404 + /// - https://jsonapi.org/format/#fetching-relationships-responses-404 + /// - https://jsonapi.org/format/#crud-creating-responses-404 + /// - https://jsonapi.org/format/#crud-updating-responses-404 + /// - https://jsonapi.org/format/#crud-deleting-responses-404 + ErrorResponse.notFound(Iterable errors) : this(404, errors); + + /// HTTP 405 Method Not Allowed response. /// The allowed methods can be specified in [allow] - static Response methodNotAllowed( - Iterable errors, Iterable allow) => - ErrorResponse(405, errors).._headers['Allow'] = allow.join(', '); - - static Response conflict(Iterable errors) => - ErrorResponse(409, errors); - - static ErrorResponse internalServerError(Iterable errors) => - ErrorResponse(500, errors); + ErrorResponse.methodNotAllowed( + Iterable errors, Iterable allow) + : this(405, errors, headers: {'Allow': allow.join(', ')}); + + /// HTTP 409 Conflict response. + /// + /// See: + /// - https://jsonapi.org/format/#crud-creating-responses-409 + /// - https://jsonapi.org/format/#crud-updating-responses-409 + ErrorResponse.conflict(Iterable errors) : this(409, errors); + + /// HTTP 500 Internal Server Error response. + ErrorResponse.internalServerError(Iterable errors) + : this(500, errors); + + /// HTTP 501 Not Implemented response. + ErrorResponse.notImplemented(Iterable errors) + : this(501, errors); + + /// Error objects to send with the response + final Iterable errors; - static Response notImplemented(Iterable errors) => - ErrorResponse(501, errors); + /// HTTP status code + final int statusCode; + final Map _headers; @override T convert(ResponseConverter converter) => converter.error(errors, statusCode, _headers); - - final _headers = {}; } +/// HTTP 200 OK response containing an empty document. +/// +/// See: +/// - https://jsonapi.org/format/#crud-updating-responses-200 +/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 +/// - https://jsonapi.org/format/#crud-deleting-responses-200 class MetaResponse implements Response { - final Map meta; + MetaResponse(Map meta) : meta = Map.unmodifiable(meta); - MetaResponse(this.meta); + final Map meta; @override T convert(ResponseConverter converter) => converter.meta(meta); } +/// A successful response containing a resource object. +/// +/// See: +/// - https://jsonapi.org/format/#fetching-resources-responses-200 +/// - https://jsonapi.org/format/#crud-updating-responses-200 class ResourceResponse implements Response { + ResourceResponse(this.resource, {this.included}); + final Resource resource; - final Iterable included; - ResourceResponse(this.resource, {this.included}); + final Iterable included; @override T convert(ResponseConverter converter) => converter.resource(resource, included: included); } +/// HTTP 201 Created response containing a newly created resource +/// +/// See: https://jsonapi.org/format/#crud-creating-responses-201 class ResourceCreatedResponse implements Response { - final Resource resource; - ResourceCreatedResponse(this.resource); + final Resource resource; + @override T convert(ResponseConverter converter) => converter.resourceCreated(resource); } +/// HTTP 303 See Other response. +/// +/// See: https://jsonapi.org/recommendations/#asynchronous-processing class SeeOtherResponse implements Response { + SeeOtherResponse(this.type, this.id); + + /// Resource type final String type; - final String id; - SeeOtherResponse(this.type, this.id); + /// Resource id + final String id; @override T convert(ResponseConverter converter) => converter.seeOther(type, id); } +/// HTTP 200 OK response containing a to-may relationship. +/// +/// See: +/// - https://jsonapi.org/format/#fetching-relationships-responses-200 +/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 class ToManyResponse implements Response { - final Iterable collection; + ToManyResponse(this.type, this.id, this.relationship, this.identifiers); + final String type; final String id; final String relationship; - - ToManyResponse(this.type, this.id, this.relationship, this.collection); + final Iterable identifiers; @override T convert(ResponseConverter converter) => - converter.toMany(type, id, relationship, collection); + converter.toMany(type, id, relationship, identifiers); } +/// HTTP 200 OK response containing a to-one relationship +/// +/// See: +/// - https://jsonapi.org/format/#fetching-relationships-responses-200 +/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 class ToOneResponse implements Response { + ToOneResponse(this.type, this.id, this.relationship, this.identifier); + final String type; final String id; final String relationship; - final Identifier identifier; - ToOneResponse(this.type, this.id, this.relationship, this.identifier); + final Identifier identifier; @override T convert(ResponseConverter converter) => diff --git a/lib/src/server/response_converter.dart b/lib/src/server/response_converter.dart index b8d3a38b..31ad506d 100644 --- a/lib/src/server/response_converter.dart +++ b/lib/src/server/response_converter.dart @@ -3,21 +3,36 @@ import 'package:json_api/src/server/pagination.dart'; /// Converts JsonApi Controller responses to other responses, e.g. HTTP abstract class ResponseConverter { - /// A document containing a list of errors + /// A common error response. + /// + /// See: https://jsonapi.org/format/#errors T error(Iterable errors, int statusCode, Map headers); - /// A document containing a collection of resources - T collection(Iterable collection, + /// HTTP 200 OK response with a resource collection. + /// + /// See: https://jsonapi.org/format/#fetching-resources-responses-200 + T collection(Iterable resources, {int total, Iterable included, Pagination pagination}); - /// HTTP 202 Accepted response + /// HTTP 202 Accepted response. + /// + /// See: https://jsonapi.org/recommendations/#asynchronous-processing T accepted(Resource resource); - /// HTTP 200 with a document containing just a meta member + /// HTTP 200 OK response containing an empty document. + /// + /// See: + /// - https://jsonapi.org/format/#crud-updating-responses-200 + /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 + /// - https://jsonapi.org/format/#crud-deleting-responses-200 T meta(Map meta); - /// HTTP 200 with a document containing a single resource + /// A successful response containing a resource object. + /// + /// See: + /// - https://jsonapi.org/format/#fetching-resources-responses-200 + /// - https://jsonapi.org/format/#crud-updating-responses-200 T resource(Resource resource, {Iterable included}); /// HTTP 200 with a document containing a single (primary) resource which has been created @@ -32,19 +47,34 @@ abstract class ResponseConverter { /// See https://jsonapi.org/format/#crud-creating-responses-201 T resourceCreated(Resource resource); - /// HTTP 303 See Other response with the Location header pointing - /// to another resource + /// HTTP 303 See Other response. + /// + /// See: https://jsonapi.org/recommendations/#asynchronous-processing T seeOther(String type, String id); - /// HTTP 200 with a document containing a to-many relationship + /// HTTP 200 OK response containing a to-may relationship. + /// + /// See: + /// - https://jsonapi.org/format/#fetching-relationships-responses-200 + /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 T toMany(String type, String id, String relationship, Iterable identifiers, {Iterable included}); - /// HTTP 200 with a document containing a to-one relationship + /// HTTP 200 OK response containing a to-one relationship + /// + /// See: + /// - https://jsonapi.org/format/#fetching-relationships-responses-200 + /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 T toOne(Identifier identifier, String type, String id, String relationship, {Iterable included}); - /// The HTTP 204 No Content response + /// HTTP 204 No Content response. + /// + /// See: + /// - https://jsonapi.org/format/#crud-creating-responses-204 + /// - https://jsonapi.org/format/#crud-updating-responses-204 + /// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 + /// - https://jsonapi.org/format/#crud-deleting-responses-204 T noContent(); } diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart index 53baa790..e0b376d0 100644 --- a/test/unit/client/async_processing_test.dart +++ b/test/unit/client/async_processing_test.dart @@ -14,7 +14,7 @@ void main() { test('Client understands async responses', () async { final links = StandardLinks(Uri.parse('/books'), routing); final responseFactory = - HttpResponseFactory(DocumentFactory(links: links), routing); + HttpResponseConverter(DocumentFactory(links: links), routing); handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); final r = await client.createResource(Resource('books', '1')); diff --git a/test/unit/document/document_test.dart b/test/unit/document/document_test.dart new file mode 100644 index 00000000..5dc2b45f --- /dev/null +++ b/test/unit/document/document_test.dart @@ -0,0 +1,9 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + test('Unrecognized structure', () { + expect(() => Document.fromJson({}, ResourceData.fromJson), + throwsA(TypeMatcher())); + }); +} diff --git a/test/unit/document/example.json b/test/unit/document/example.json deleted file mode 100644 index 3ccb7fcf..00000000 --- a/test/unit/document/example.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "links": { - "self": "http://example.com/articles", - "next": "http://example.com/articles?page=2", - "last": "http://example.com/articles?page=10" - }, - "data": [ - { - "type": "articles", - "id": "1", - "attributes": { - "title": "JSON:API paints my bikeshed!" - }, - "relationships": { - "author": { - "links": { - "self": "http://example.com/articles/1/relationships/author", - "related": "http://example.com/articles/1/author" - }, - "data": { - "type": "people", - "id": "9" - } - }, - "comments": { - "links": { - "self": "http://example.com/articles/1/relationships/comments", - "related": "http://example.com/articles/1/comments" - }, - "data": [ - { - "type": "comments", - "id": "5" - }, - { - "type": "comments", - "id": "12" - } - ] - } - }, - "links": { - "self": "http://example.com/articles/1" - } - } - ], - "included": [ - { - "type": "people", - "id": "9", - "attributes": { - "firstName": "Dan", - "lastName": "Gebhardt", - "twitter": "dgeb" - }, - "links": { - "self": "http://example.com/people/9" - } - }, - { - "type": "comments", - "id": "5", - "attributes": { - "body": "First!" - }, - "relationships": { - "author": { - "data": { - "type": "people", - "id": "2" - } - } - }, - "links": { - "self": "http://example.com/comments/5" - } - }, - { - "type": "comments", - "id": "12", - "attributes": { - "body": "I like XML better" - }, - "relationships": { - "author": { - "data": { - "type": "people", - "id": "9" - } - } - }, - "links": { - "self": "http://example.com/comments/12" - } - } - ] -} \ No newline at end of file diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index d3c951b0..fe715920 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -55,7 +55,7 @@ void main() { final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); expect(error.title, 'Bad request'); - expect(error.detail, "Resource 'type' must not be null"); + expect(error.detail, "Resource 'type' must be not empty"); }); test('returns `not found` if URI is not recognized', () async { From 57a2f2ef416b72a487be3f4e4dbc9c2f858f9e20 Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 21 Feb 2020 20:44:09 -0800 Subject: [PATCH 34/99] RC2 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 93eb86b3..dfc7ae09 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-rc.1 +version: 4.0.0-rc.2 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: From 9a59c377f6bb7a6dc12e0b9ac7685bd5610903a4 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 22 Feb 2020 00:15:26 -0800 Subject: [PATCH 35/99] wip --- README.md | 78 +++++++++++++++++-- lib/server.dart | 3 +- lib/src/document/document.dart | 2 +- lib/src/document/relationship.dart | 6 +- lib/src/document/resource.dart | 8 +- .../document/resource_collection_data.dart | 4 +- lib/src/document/resource_object.dart | 2 +- lib/src/query/fields.dart | 2 +- .../{request_handler.dart => controller.dart} | 38 ++++----- .../{request.dart => json_api_request.dart} | 73 +++++++++-------- .../{response.dart => json_api_response.dart} | 52 +++++++------ lib/src/server/json_api_server.dart | 10 +-- lib/src/server/repository.dart | 5 +- lib/src/server/repository_controller.dart | 70 ++++++++--------- lib/src/server/request_converter.dart | 11 +-- 15 files changed, 218 insertions(+), 146 deletions(-) rename lib/src/server/{request_handler.dart => controller.dart} (59%) rename lib/src/server/{request.dart => json_api_request.dart} (72%) rename lib/src/server/{response.dart => json_api_response.dart} (79%) diff --git a/README.md b/README.md index 98ef3618..a1d4e284 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,20 @@ [JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: +- The [Document library] to model for resources, relationships, identifiers, etc - The [Client library] to make requests to JSON:API servers - The [Server library] which is still under development -- The [Document library] model for resources, relationships, identifiers, etc +- The [HTTP library] to interact with Dart's native HTTP client and server - The [Query library] to build and parse the query parameters (pagination, sorting, etc) - The [URI Design library] to build and match URIs for resources, collections, and relationships -- The [HTTP library] to interact with Dart's native HTTP client and server ## Document model -This part assumes that you have a basic understanding of the JSON:API standard. If not, please read the [JSON:API] spec. -The main concept of JSON:API model is the [Resource]. Resources are passed between the client and the server in the -form of a [Document]. A resource has its `type`, `id`, and a map of `attributes`. Resources refer to other resources -with the [Identifier] objects which contain a `type` and `id` of the resource being referred. -Relationship between resources may be either `toOne` (maps to a single identifier) -or `toMany` (maps to a list of identifiers). +The main concept of JSON:API model is the [Resource]. +Resources are passed between the client and the server in the form of a JSON-encodable [Document]. +A resource has its `type`, `id`, and a map of `attributes`. +Resources refer to other resources with the an [Identifier] which contains a `type` and `id` of the resource being referred. +Relationship between resources may be either `toOne` (maps to a single identifier) or `toMany` (maps to a list of identifiers). ## Client [JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: @@ -28,6 +27,69 @@ or `toMany` (maps to a list of identifiers). - direct modification of relationships (both to-one and to-many) - [async processing](https://jsonapi.org/recommendations/#asynchronous-processing) +The client returns back a [Response] which contains the HTTP status code, headers and the JSON:API [Document]. + +Sometimes the request URIs can be inferred from the context. +For such cases you may use the [RoutingClient] which is a wrapper over the [JsonApiClient] capable of inferring the URIs. +The [RoutingClient] requires an instance of [RouteFactory] to be provided. + +[JsonApiClient] itself does not make actual HTTP calls. +To instantiate [JsonApiClient] you must provide an instance of [HttpHandler] which would act a an HTTP client. +There is an implementation of [HttpHandler] called [DartHttp] which uses the Dart's native http client. +You may use it or make your own if you prefer a different HTTP client. + +## Server +This is a framework-agnostic library for implementing a JSON:API server. +It may be used on its own (it has a fully functional server implementation) or as a set of independent components. + +### Request lifecycle +#### HTTP request +The server receives an incoming [HttpRequest]. +It is a thin abstraction over the underlying HTTP system. +[HttpRequest] carries the headers and the body represented as a String. +When this request is received, your server may decide to check for authentication or other non-JSON:API concerns +to prepare for the request processing or it may decide to fail out with an error response. + +#### JSON:API request +The [RequestConverter] then used to convert it to a [JsonApiRequest] which abstracts the JSON:API specific details, +such as the request target (e.g. type, id, relationships) and the decoded body (e.g. [Resource] or [Identifier]). +At this point it is possible to determine if the request is a valid JSON:API request and to read the decoded payload. +You may perform some application-specific logic, e.g. check for authentication. +Each implementation of [JsonApiRequest] has the `handleWith()` to call the right method of the [Controller]. + +#### Controller +The [Controller] consolidates all methods to process JSON:API requests. +This is where the actual data manipulation happens. +Every controller method must return an instance of the response. +Controllers are generic (generalized by the response type), so your implementation may decide to use its own responses. +You are also welcome to use the included [JsonApiResponse] interface and its implementers covering a wide range +of cases. +This library also comes with a particular implementation of the [Controller] called [RepositoryController]. +The [RepositoryController] takes care of all JSON:API specific logic (e.g. validation, filtering, resource +inclusion) and translates the JSON:API requests to calls to a resource [Repository]. + +#### Repository (optional) +The [Repository] is an interface separating the data storage concerns from the specifics of the API. + +#### JSON:API response +When an instance of [JsonApiResponse] is returned from the controller, the [ResponseConverter] +converts it to an [HttpResponse]. +The converter takes care of JSON:API transport-layer concerns. +In particular, it: +- generates a proper [Document], including the HATEOAS links or meta-data +- encodes the document to JSON string +- sets the response headers + +#### HTTP response +The generated [HttpResponse] is sent to the underlying HTTP system. +This is the final step. + +## HTTP +This library is used by both the Client and the Server to abstract out the HTTP protocol specifics. +The [HttpHandler] interface turns an [HttpRequest] to an [HttpResponse]. +The Client consumes an implementation of [HttpHandler] as a low-level HTTP client. +The Server is itself an implementation of [HttpHandler]. + [JSON:API]: http://jsonapi.org diff --git a/lib/server.dart b/lib/server.dart index b0cabea8..36ea172d 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -4,6 +4,8 @@ export 'package:json_api/src/server/dart_server.dart'; export 'package:json_api/src/server/document_factory.dart'; export 'package:json_api/src/server/http_response_converter.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; +export 'package:json_api/src/server/json_api_request.dart'; +export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; export 'package:json_api/src/server/links/links_factory.dart'; export 'package:json_api/src/server/links/no_links.dart'; @@ -11,4 +13,3 @@ export 'package:json_api/src/server/links/standard_links.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; -export 'package:json_api/src/server/response.dart'; diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index f3201ba6..1312a7e7 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -56,7 +56,7 @@ class Document implements JsonEncodable { final Api api; /// List of errors. May be null. - final Iterable errors; + final List errors; /// Meta data. May be empty or null. final Map meta; diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 8ea0fbe4..85dc0ff7 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -128,7 +128,7 @@ class ToMany extends Relationship { /// Can be empty for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final Iterable linkage; + final List linkage; @override Map toJson() => { @@ -136,7 +136,7 @@ class ToMany extends Relationship { 'data': linkage, }; - /// Converts to Iterable. + /// Converts to List. /// For empty relationships returns an empty List. - Iterable unwrap() => linkage.map((_) => _.unwrap()); + List unwrap() => linkage.map((_) => _.unwrap()).toList(); } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 1fbc5797..09b989fa 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -13,11 +13,11 @@ class Resource { Resource(this.type, this.id, {Map attributes, Map toOne, - Map> toMany}) + Map> toMany}) : attributes = Map.unmodifiable(attributes ?? {}), toOne = Map.unmodifiable(toOne ?? {}), toMany = Map.unmodifiable( - (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v)))) { + (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v).toList()))) { if (type == null || type.isEmpty) { throw DocumentException("Resource 'type' must be not empty"); } @@ -38,7 +38,7 @@ class Resource { final Map toOne; /// Unmodifiable map of to-many relationships - final Map> toMany; + final Map> toMany; /// Resource type and id combined String get key => '$type:$id'; @@ -52,6 +52,6 @@ class NewResource extends Resource { NewResource(String type, {Map attributes, Map toOne, - Map> toMany}) + Map> toMany}) : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); } diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index 58124101..f1634423 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -27,7 +27,7 @@ class ResourceCollectionData extends PrimaryData { "A JSON:API resource collection document must be a JSON object with a JSON array in the 'data' member"); } - final Iterable collection; + final List collection; /// The link to the last page. May be null. Link get last => (links ?? {})['last']; @@ -42,7 +42,7 @@ class ResourceCollectionData extends PrimaryData { Link get prev => (links ?? {})['prev']; /// Returns a list of resources contained in the collection - Iterable unwrap() => collection.map((_) => _.unwrap()); + List unwrap() => collection.map((_) => _.unwrap()).toList(); /// Returns a map of resources indexed by ids Map unwrapToMap() => diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index d03c3e0b..83e3f65e 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -82,7 +82,7 @@ class ResourceObject implements JsonEncodable { /// recovered and this method will throw a [StateError]. Resource unwrap() { final toOne = {}; - final toMany = >{}; + final toMany = >{}; final incomplete = {}; (relationships ?? {}).forEach((name, rel) { if (rel is ToOne) { diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 0ca58dbc..13f2f889 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -13,7 +13,7 @@ class Fields extends QueryParameters { /// ``` /// ?fields[articles]=title,body&fields[people]=name /// ``` - Fields(Map> fields) + Fields(Map> fields) : _fields = {...fields}, super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); diff --git a/lib/src/server/request_handler.dart b/lib/src/server/controller.dart similarity index 59% rename from lib/src/server/request_handler.dart rename to lib/src/server/controller.dart index 7f4494d8..66058af4 100644 --- a/lib/src/server/request_handler.dart +++ b/lib/src/server/controller.dart @@ -2,29 +2,25 @@ import 'package:json_api/document.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. -abstract class RequestHandler { +abstract class Controller { /// Finds an returns a primary resource collection. /// See https://jsonapi.org/format/#fetching-resources - T fetchCollection( - final String type, final Map> queryParameters); + T fetchCollection(String type, Map> queryParameters); /// Finds an returns a primary resource. /// See https://jsonapi.org/format/#fetching-resources - T fetchResource(final String type, final String id, - final Map> queryParameters); + T fetchResource( + String type, String id, Map> queryParameters); /// Finds an returns a related resource or a collection of related resources. /// See https://jsonapi.org/format/#fetching-resources - T fetchRelated(final String type, final String id, final String relationship, - final Map> queryParameters); + T fetchRelated(String type, String id, String relationship, + Map> queryParameters); /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships - T fetchRelationship( - final String type, - final String id, - final String relationship, - final Map> queryParameters); + T fetchRelationship(String type, String id, String relationship, + Map> queryParameters); /// Deletes the resource. /// See https://jsonapi.org/format/#crud-deleting @@ -40,25 +36,25 @@ abstract class RequestHandler { /// Replaces the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T replaceToOne(final String type, final String id, final String relationship, - final Identifier identifier); + T replaceToOne( + String type, String id, String relationship, Identifier identifier); /// Deletes the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T deleteToOne(final String type, final String id, final String relationship); + T deleteToOne(String type, String id, String relationship); /// Replaces the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T replaceToMany(final String type, final String id, final String relationship, - final Iterable identifiers); + T replaceToMany(String type, String id, String relationship, + Iterable identifiers); /// Removes the given identifiers from the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T deleteFromRelationship(final String type, final String id, - final String relationship, final Iterable identifiers); + T deleteFromRelationship(String type, String id, String relationship, + Iterable identifiers); /// Adds the given identifiers to the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T addToRelationship(final String type, final String id, - final String relationship, final Iterable identifiers); + T addToRelationship(String type, String id, String relationship, + Iterable identifiers); } diff --git a/lib/src/server/request.dart b/lib/src/server/json_api_request.dart similarity index 72% rename from lib/src/server/request.dart rename to lib/src/server/json_api_request.dart index 79216fbb..8791c9bf 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/json_api_request.dart @@ -1,16 +1,16 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/request_handler.dart'; +import 'package:json_api/src/server/controller.dart'; -/// A base interface for JSON:API requests. -abstract class Request { +/// The base interface for JSON:API requests. +abstract class JsonApiRequest { /// Calls the appropriate method of [controller] and returns the response - T handleWith(RequestHandler controller); + T handleWith(Controller controller); } /// A request to fetch a collection of type [type]. /// /// See: https://jsonapi.org/format/#fetching-resources -class FetchCollection implements Request { +class FetchCollection implements JsonApiRequest { FetchCollection(this.queryParameters, this.type); /// Resource type @@ -20,14 +20,14 @@ class FetchCollection implements Request { final Map> queryParameters; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.fetchCollection(type, queryParameters); } /// A request to create a resource on the server /// /// See: https://jsonapi.org/format/#crud-creating -class CreateResource implements Request { +class CreateResource implements JsonApiRequest { CreateResource(this.type, this.resource); /// Resource type @@ -37,14 +37,14 @@ class CreateResource implements Request { final Resource resource; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.createResource(type, resource); } /// A request to update a resource on the server /// /// See: https://jsonapi.org/format/#crud-updating -class UpdateResource implements Request { +class UpdateResource implements JsonApiRequest { UpdateResource(this.type, this.id, this.resource); /// Resource type @@ -57,14 +57,14 @@ class UpdateResource implements Request { final Resource resource; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.updateResource(type, id, resource); } /// A request to delete a resource on the server /// /// See: https://jsonapi.org/format/#crud-deleting -class DeleteResource implements Request { +class DeleteResource implements JsonApiRequest { DeleteResource(this.type, this.id); /// Resource type @@ -74,14 +74,14 @@ class DeleteResource implements Request { final String id; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.deleteResource(type, id); } /// A request to fetch a resource /// /// See: https://jsonapi.org/format/#fetching-resources -class FetchResource implements Request { +class FetchResource implements JsonApiRequest { FetchResource(this.type, this.id, this.queryParameters); /// Resource type @@ -94,14 +94,14 @@ class FetchResource implements Request { final Map> queryParameters; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.fetchResource(type, id, queryParameters); } /// A request to fetch a related resource or collection /// /// See: https://jsonapi.org/format/#fetching -class FetchRelated implements Request { +class FetchRelated implements JsonApiRequest { FetchRelated(this.type, this.id, this.relationship, this.queryParameters); /// Resource type @@ -117,14 +117,14 @@ class FetchRelated implements Request { final Map> queryParameters; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.fetchRelated(type, id, relationship, queryParameters); } /// A request to fetch a relationship /// /// See: https://jsonapi.org/format/#fetching-relationships -class FetchRelationship implements Request { +class FetchRelationship implements JsonApiRequest { FetchRelationship( this.type, this.id, this.relationship, this.queryParameters); @@ -141,16 +141,17 @@ class FetchRelationship implements Request { final Map> queryParameters; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.fetchRelationship(type, id, relationship, queryParameters); } /// A request to delete identifiers from a relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class DeleteFromRelationship implements Request { +class DeleteFromRelationship implements JsonApiRequest { DeleteFromRelationship( - this.type, this.id, this.relationship, this.identifiers); + this.type, this.id, this.relationship, Iterable identifiers) + : identifiers = List.unmodifiable(identifiers); /// Resource type final String type; @@ -162,17 +163,17 @@ class DeleteFromRelationship implements Request { final String relationship; /// The identifiers to delete - final Iterable identifiers; + final List identifiers; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.deleteFromRelationship(type, id, relationship, identifiers); } /// A request to replace a to-one relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class ReplaceToOne implements Request { +class ReplaceToOne implements JsonApiRequest { ReplaceToOne(this.type, this.id, this.relationship, this.identifier); /// Resource type @@ -188,14 +189,14 @@ class ReplaceToOne implements Request { final Identifier identifier; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.replaceToOne(type, id, relationship, identifier); } /// A request to delete a to-one relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class DeleteToOne implements Request { +class DeleteToOne implements JsonApiRequest { DeleteToOne(this.type, this.id, this.relationship); /// Resource type @@ -207,15 +208,17 @@ class DeleteToOne implements Request { final String relationship; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.replaceToOne(type, id, relationship, null); } /// A request to completely replace a to-many relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class ReplaceToMany implements Request { - ReplaceToMany(this.type, this.id, this.relationship, this.identifiers); +class ReplaceToMany implements JsonApiRequest { + ReplaceToMany( + this.type, this.id, this.relationship, Iterable identifiers) + : identifiers = List.unmodifiable(identifiers); /// Resource type final String type; @@ -227,18 +230,20 @@ class ReplaceToMany implements Request { final String relationship; /// The set of identifiers to replace the current ones - final Iterable identifiers; + final List identifiers; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.replaceToMany(type, id, relationship, identifiers); } /// A request to add identifiers to a to-many relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class AddToRelationship implements Request { - AddToRelationship(this.type, this.id, this.relationship, this.identifiers); +class AddToRelationship implements JsonApiRequest { + AddToRelationship( + this.type, this.id, this.relationship, Iterable identifiers) + : identifiers = List.unmodifiable(identifiers); /// Resource type final String type; @@ -250,9 +255,9 @@ class AddToRelationship implements Request { final String relationship; /// The identifiers to be added to the existing ones - final Iterable identifiers; + final List identifiers; @override - T handleWith(RequestHandler controller) => + T handleWith(Controller controller) => controller.addToRelationship(type, id, relationship, identifiers); } diff --git a/lib/src/server/response.dart b/lib/src/server/json_api_response.dart similarity index 79% rename from lib/src/server/response.dart rename to lib/src/server/json_api_response.dart index a77dae82..d63668aa 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,8 +1,8 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/response_converter.dart'; -/// The base interface for all JSON:API responses -abstract class Response { +/// The base interface for JSON:API responses. +abstract class JsonApiResponse { /// Converts the JSON:API response to another object, e.g. HTTP response. T convert(ResponseConverter converter); } @@ -14,7 +14,7 @@ abstract class Response { /// - https://jsonapi.org/format/#crud-updating-responses-204 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 /// - https://jsonapi.org/format/#crud-deleting-responses-204 -class NoContentResponse implements Response { +class NoContentResponse implements JsonApiResponse { @override T convert(ResponseConverter converter) => converter.noContent(); } @@ -22,11 +22,14 @@ class NoContentResponse implements Response { /// HTTP 200 OK response with a resource collection. /// /// See: https://jsonapi.org/format/#fetching-resources-responses-200 -class CollectionResponse implements Response { - CollectionResponse(this.resources, {this.included, this.total}); +class CollectionResponse implements JsonApiResponse { + CollectionResponse(Iterable resources, + {Iterable included, this.total}) + : resources = List.unmodifiable(resources), + included = included == null ? null : List.unmodifiable(included); - final Iterable resources; - final Iterable included; + final List resources; + final List included; final int total; @@ -38,7 +41,7 @@ class CollectionResponse implements Response { /// HTTP 202 Accepted response. /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing -class AcceptedResponse implements Response { +class AcceptedResponse implements JsonApiResponse { AcceptedResponse(this.resource); final Resource resource; @@ -50,10 +53,11 @@ class AcceptedResponse implements Response { /// A common error response. /// /// See: https://jsonapi.org/format/#errors -class ErrorResponse implements Response { - ErrorResponse(this.statusCode, this.errors, +class ErrorResponse implements JsonApiResponse { + ErrorResponse(this.statusCode, Iterable errors, {Map headers = const {}}) - : _headers = Map.unmodifiable(headers); + : _headers = Map.unmodifiable(headers), + errors = List.unmodifiable(errors); /// HTTP 400 Bad Request response. /// @@ -104,7 +108,7 @@ class ErrorResponse implements Response { : this(501, errors); /// Error objects to send with the response - final Iterable errors; + final List errors; /// HTTP status code final int statusCode; @@ -121,7 +125,7 @@ class ErrorResponse implements Response { /// - https://jsonapi.org/format/#crud-updating-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 /// - https://jsonapi.org/format/#crud-deleting-responses-200 -class MetaResponse implements Response { +class MetaResponse implements JsonApiResponse { MetaResponse(Map meta) : meta = Map.unmodifiable(meta); final Map meta; @@ -135,12 +139,13 @@ class MetaResponse implements Response { /// See: /// - https://jsonapi.org/format/#fetching-resources-responses-200 /// - https://jsonapi.org/format/#crud-updating-responses-200 -class ResourceResponse implements Response { - ResourceResponse(this.resource, {this.included}); +class ResourceResponse implements JsonApiResponse { + ResourceResponse(this.resource, {Iterable included}) + : included = included == null ? null : List.unmodifiable(included); final Resource resource; - final Iterable included; + final List included; @override T convert(ResponseConverter converter) => @@ -150,7 +155,7 @@ class ResourceResponse implements Response { /// HTTP 201 Created response containing a newly created resource /// /// See: https://jsonapi.org/format/#crud-creating-responses-201 -class ResourceCreatedResponse implements Response { +class ResourceCreatedResponse implements JsonApiResponse { ResourceCreatedResponse(this.resource); final Resource resource; @@ -163,7 +168,7 @@ class ResourceCreatedResponse implements Response { /// HTTP 303 See Other response. /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing -class SeeOtherResponse implements Response { +class SeeOtherResponse implements JsonApiResponse { SeeOtherResponse(this.type, this.id); /// Resource type @@ -181,13 +186,16 @@ class SeeOtherResponse implements Response { /// See: /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToManyResponse implements Response { - ToManyResponse(this.type, this.id, this.relationship, this.identifiers); +class ToManyResponse implements JsonApiResponse { + ToManyResponse( + this.type, this.id, this.relationship, Iterable identifiers) + : identifiers = + identifiers == null ? null : List.unmodifiable(identifiers); final String type; final String id; final String relationship; - final Iterable identifiers; + final List identifiers; @override T convert(ResponseConverter converter) => @@ -199,7 +207,7 @@ class ToManyResponse implements Response { /// See: /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToOneResponse implements Response { +class ToOneResponse implements JsonApiResponse { ToOneResponse(this.type, this.id, this.relationship, this.identifier); final String type; diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index a9bd3a5a..d5ca8477 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -4,9 +4,9 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/json_api_request.dart'; import 'package:json_api/src/server/request_converter.dart'; -import 'package:json_api/src/server/request_handler.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { @@ -14,12 +14,12 @@ class JsonApiServer implements HttpHandler { : _routing = routing ?? StandardRouting(); final RouteFactory _routing; - final RequestHandler> _controller; + final Controller> _controller; @override Future call(HttpRequest httpRequest) async { - Request jsonApiRequest; - Response jsonApiResponse; + JsonApiRequest jsonApiRequest; + JsonApiResponse jsonApiResponse; try { jsonApiRequest = RequestConverter().convert(httpRequest); } on FormatException catch (e) { diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index a3fcc018..57d7568b 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -41,9 +41,10 @@ abstract class Repository { /// A collection of elements (e.g. resources) returned by the server. class Collection { - Collection(this.elements, [this.total]); + Collection(Iterable elements, [this.total]) + : elements = List.unmodifiable(elements); - final Iterable elements; + final List elements; /// Total count of the elements on the server. May be null. final int total; diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 6d2907f6..7104d176 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -3,20 +3,20 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/request_handler.dart'; -import 'package:json_api/src/server/response.dart'; -/// An opinionated implementation of [RequestHandler]. It translates JSON:API +/// An opinionated implementation of [Controller]. It translates JSON:API /// requests to [Repository] methods calls. -class RepositoryController implements RequestHandler> { +class RepositoryController implements Controller> { RepositoryController(this._repo); final Repository _repo; @override - FutureOr addToRelationship(final String type, final String id, - final String relationship, final Iterable identifiers) => + FutureOr addToRelationship(String type, String id, + String relationship, Iterable identifiers) => _do(() async { final original = await _repo.get(type, id); if (!original.toMany.containsKey(relationship)) { @@ -32,14 +32,15 @@ class RepositoryController implements RequestHandler> { type, id, Resource(type, id, toMany: { - relationship: {...original.toMany[relationship], ...identifiers} + relationship: + {...original.toMany[relationship], ...identifiers}.toList() })); return ToManyResponse( type, id, relationship, updated.toMany[relationship]); }); @override - FutureOr createResource(String type, Resource resource) => + FutureOr createResource(String type, Resource resource) => _do(() async { final modified = await _repo.create(type, resource); if (modified == null) return NoContentResponse(); @@ -47,30 +48,32 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr deleteFromRelationship(final String type, final String id, - final String relationship, final Iterable identifiers) => + FutureOr deleteFromRelationship(String type, String id, + String relationship, Iterable identifiers) => _do(() async { final original = await _repo.get(type, id); final updated = await _repo.update( type, id, Resource(type, id, toMany: { - relationship: {...original.toMany[relationship]} - ..removeAll(identifiers) + relationship: ({...original.toMany[relationship]} + ..removeAll(identifiers)) + .toList() })); return ToManyResponse( type, id, relationship, updated.toMany[relationship]); }); @override - FutureOr deleteResource(String type, String id) => _do(() async { + FutureOr deleteResource(String type, String id) => + _do(() async { await _repo.delete(type, id); return NoContentResponse(); }); @override - FutureOr fetchCollection( - final String type, final Map> queryParameters) => + FutureOr fetchCollection( + String type, Map> queryParameters) => _do(() async { final c = await _repo.getCollection(type); final include = Include.fromQueryParameters(queryParameters); @@ -87,11 +90,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr fetchRelated( - final String type, - final String id, - final String relationship, - final Map> queryParameters) => + FutureOr fetchRelated(String type, String id, + String relationship, Map> queryParameters) => _do(() async { final resource = await _repo.get(type, id); if (resource.toOne.containsKey(relationship)) { @@ -109,11 +109,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr fetchRelationship( - final String type, - final String id, - final String relationship, - final Map> queryParameters) => + FutureOr fetchRelationship(String type, String id, + String relationship, Map> queryParameters) => _do(() async { final resource = await _repo.get(type, id); if (resource.toOne.containsKey(relationship)) { @@ -128,8 +125,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr fetchResource(final String type, final String id, - final Map> queryParameters) => + FutureOr fetchResource( + String type, String id, Map> queryParameters) => _do(() async { final include = Include.fromQueryParameters(queryParameters); final resource = await _repo.get(type, id); @@ -142,8 +139,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr replaceToMany(final String type, final String id, - final String relationship, final Iterable identifiers) => + FutureOr replaceToMany(String type, String id, + String relationship, Iterable identifiers) => _do(() async { await _repo.update( type, id, Resource(type, id, toMany: {relationship: identifiers})); @@ -151,7 +148,7 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr updateResource( + FutureOr updateResource( String type, String id, Resource resource) => _do(() async { final modified = await _repo.update(type, id, resource); @@ -160,8 +157,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr replaceToOne(final String type, final String id, - final String relationship, final Identifier identifier) => + FutureOr replaceToOne( + String type, String id, String relationship, Identifier identifier) => _do(() async { await _repo.update( type, id, Resource(type, id, toOne: {relationship: identifier})); @@ -169,8 +166,8 @@ class RepositoryController implements RequestHandler> { }); @override - FutureOr deleteToOne( - final String type, final String id, final String relationship) => + FutureOr deleteToOne( + String type, String id, String relationship) => replaceToOne(type, id, relationship, null); FutureOr _getByIdentifier(Identifier identifier) => @@ -204,7 +201,8 @@ class RepositoryController implements RequestHandler> { Map.fromIterable(included, key: (_) => '${_.type}:${_.id}').values; - FutureOr _do(FutureOr Function() action) async { + FutureOr _do( + FutureOr Function() action) async { try { return await action(); } on UnsupportedOperation catch (e) { @@ -234,7 +232,7 @@ class RepositoryController implements RequestHandler> { } } - Response _relationshipNotFound(String relationship) { + JsonApiResponse _relationshipNotFound(String relationship) { return ErrorResponse.notFound([ ErrorObject( status: '404', diff --git a/lib/src/server/request_converter.dart b/lib/src/server/request_converter.dart index 65ea5bb6..7c379d80 100644 --- a/lib/src/server/request_converter.dart +++ b/lib/src/server/request_converter.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/json_api_request.dart'; /// Converts HTTP requests to JSON:API requests class RequestConverter { @@ -11,8 +11,8 @@ class RequestConverter { : _matcher = routeMatcher ?? StandardRouting(); final RouteMatcher _matcher; - /// Creates a [Request] from [httpRequest] - Request convert(HttpRequest httpRequest) { + /// Creates a [JsonApiRequest] from [httpRequest] + JsonApiRequest convert(HttpRequest httpRequest) { String type; String id; String rel; @@ -97,10 +97,11 @@ class RequestFactoryException implements Exception {} /// Thrown if HTTP method is not allowed for the given route class MethodNotAllowedException implements RequestFactoryException { - MethodNotAllowedException(this.allow); + MethodNotAllowedException(Iterable allow) + : allow = List.unmodifiable(allow ?? const []); /// List of allowed methods - final Iterable allow; + final List allow; } /// Thrown if the request URI can not be matched to a target From b74d3e22b3643ef4da098f8e7c20f8652c61176c Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 22 Feb 2020 17:47:35 -0800 Subject: [PATCH 36/99] wip --- README.md | 14 +++++------ lib/src/server/in_memory_repository.dart | 31 ++++++++++++------------ lib/src/server/repository.dart | 13 +++++----- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a1d4e284..3e73893e 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@ [JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: -- The [Document library] to model for resources, relationships, identifiers, etc -- The [Client library] to make requests to JSON:API servers -- The [Server library] which is still under development -- The [HTTP library] to interact with Dart's native HTTP client and server -- The [Query library] to build and parse the query parameters (pagination, sorting, etc) -- The [URI Design library] to build and match URIs for resources, collections, and relationships +- The [Document] - the core of this package, describes the JSON:API document structure +- The [Client library] - JSON:API Client for Flutter, Web and Server-side +- The [Server library] - a framework-agnostic JSON:API server implementation +- The [HTTP library] - a thin abstraction of HTTP requests and responses +- The [Query library] - builds and parses the query parameters (pagination, sorting, filtering, etc) +- The [Routing library] - builds and matches URIs for resources, collections, and relationships ## Document model @@ -97,7 +97,7 @@ The Server is itself an implementation of [HttpHandler]. [Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html [Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html [Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html -[URI Design library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html +[Routing library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html [HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html [Resource]: https://pub.dev/documentation/json_api/latest/document/Resource-class.html diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index f4d5fe64..5904be28 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -23,7 +23,8 @@ class InMemoryRepository implements Repository { } for (final relationship in resource.toOne.values .followedBy(resource.toMany.values.expand((_) => _))) { - await get(relationship.type, relationship.id); + // Make sure the relationships exist + await get(relationship); } if (resource.id == null) { if (_nextId == null) { @@ -45,29 +46,29 @@ class InMemoryRepository implements Repository { } @override - FutureOr get(String collection, String id) async { - if (_collections.containsKey(collection)) { - final resource = _collections[collection][id]; + FutureOr get(Identifier identifier) async { + if (_collections.containsKey(identifier.type)) { + final resource = _collections[identifier.type][identifier.id]; if (resource == null) { throw ResourceNotFound( - "Resource '$id' does not exist in '$collection'"); + "Resource '${identifier.id}' does not exist in '${identifier.type}'"); } return resource; } - throw CollectionNotFound("Collection '$collection' does not exist"); + throw CollectionNotFound("Collection '${identifier.type}' does not exist"); } @override FutureOr update( - String collection, String id, Resource resource) async { - if (collection != resource.type) { - throw _invalidType(resource, collection); + Identifier identifier, Resource resource) async { + if (identifier.type != resource.type) { + throw _invalidType(resource, identifier.type); } - final original = await get(collection, id); + final original = await get(identifier); if (resource.attributes.isEmpty && resource.toOne.isEmpty && resource.toMany.isEmpty && - resource.id == id) { + resource.id == identifier.id) { return null; } final updated = Resource( @@ -77,14 +78,14 @@ class InMemoryRepository implements Repository { toOne: {...original.toOne}..addAll(resource.toOne), toMany: {...original.toMany}..addAll(resource.toMany), ); - _collections[collection][id] = updated; + _collections[identifier.type][identifier.id] = updated; return updated; } @override - FutureOr delete(String type, String id) async { - await get(type, id); - _collections[type].remove(id); + FutureOr delete(Identifier identifier) async { + await get(identifier); + _collections[identifier.type].remove(identifier.id); return null; } diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 57d7568b..7682eec1 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:json_api/document.dart'; import 'package:json_api/src/document/resource.dart'; /// The Repository translates CRUD operations on resources to actual data @@ -24,16 +25,16 @@ abstract class Repository { /// error. FutureOr create(String collection, Resource resource); - /// Returns the resource from [collection] by [id]. - FutureOr get(String collection, String id); + /// Returns the resource by [identifier]. + FutureOr get(Identifier identifier); - /// Updates the resource identified by [collection] and [id]. + /// Updates the resource identified by [identifier]. /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. - FutureOr update(String collection, String id, Resource resource); + FutureOr update(Identifier identifier, Resource resource); - /// Deletes the resource identified by [type] and [id] - FutureOr delete(String type, String id); + /// Deletes the resource identified by [identifier] + FutureOr delete(Identifier identifier); /// Returns a collection of resources FutureOr> getCollection(String collection); From 8f7f20ef19c474f615bc4fce78b7c191060faba4 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sat, 22 Feb 2020 19:19:43 -0800 Subject: [PATCH 37/99] wip --- lib/src/server/controller.dart | 32 +++--- lib/src/server/in_memory_repository.dart | 32 +++--- lib/src/server/json_api_request.dart | 126 ++++++---------------- lib/src/server/relationship_target.dart | 11 ++ lib/src/server/repository.dart | 16 +-- lib/src/server/repository_controller.dart | 121 +++++++++++---------- lib/src/server/request_converter.dart | 29 ++--- lib/src/server/resource_target.dart | 12 +++ 8 files changed, 174 insertions(+), 205 deletions(-) create mode 100644 lib/src/server/relationship_target.dart create mode 100644 lib/src/server/resource_target.dart diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 66058af4..6035b2d4 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,4 +1,6 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. @@ -10,21 +12,21 @@ abstract class Controller { /// Finds an returns a primary resource. /// See https://jsonapi.org/format/#fetching-resources T fetchResource( - String type, String id, Map> queryParameters); + ResourceTarget target, Map> queryParameters); /// Finds an returns a related resource or a collection of related resources. /// See https://jsonapi.org/format/#fetching-resources - T fetchRelated(String type, String id, String relationship, - Map> queryParameters); + T fetchRelated( + RelationshipTarget target, Map> queryParameters); /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships - T fetchRelationship(String type, String id, String relationship, - Map> queryParameters); + T fetchRelationship( + RelationshipTarget target, Map> queryParameters); /// Deletes the resource. /// See https://jsonapi.org/format/#crud-deleting - T deleteResource(String type, String id); + T deleteResource(ResourceTarget target); /// Creates a new resource in the collection. /// See https://jsonapi.org/format/#crud-creating @@ -32,29 +34,27 @@ abstract class Controller { /// Updates the resource. /// See https://jsonapi.org/format/#crud-updating - T updateResource(String type, String id, Resource resource); + T updateResource(ResourceTarget target, Resource resource); /// Replaces the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T replaceToOne( - String type, String id, String relationship, Identifier identifier); + T replaceToOne(RelationshipTarget target, Identifier identifier); /// Deletes the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T deleteToOne(String type, String id, String relationship); + T deleteToOne(RelationshipTarget target); /// Replaces the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T replaceToMany(String type, String id, String relationship, - Iterable identifiers); + T replaceToMany(RelationshipTarget target, Iterable identifiers); /// Removes the given identifiers from the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T deleteFromRelationship(String type, String id, String relationship, - Iterable identifiers); + T deleteFromRelationship( + RelationshipTarget target, Iterable identifiers); /// Adds the given identifiers to the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T addToRelationship(String type, String id, String relationship, - Iterable identifiers); + T addToRelationship( + RelationshipTarget target, Iterable identifiers); } diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 5904be28..0fa46ea8 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/repository.dart'; +import 'package:json_api/src/server/resource_target.dart'; typedef IdGenerator = String Function(); typedef TypeAttributionCriteria = bool Function(String collection, String type); @@ -24,7 +25,7 @@ class InMemoryRepository implements Repository { for (final relationship in resource.toOne.values .followedBy(resource.toMany.values.expand((_) => _))) { // Make sure the relationships exist - await get(relationship); + await get(ResourceTarget.fromIdentifier(relationship)); } if (resource.id == null) { if (_nextId == null) { @@ -46,29 +47,28 @@ class InMemoryRepository implements Repository { } @override - FutureOr get(Identifier identifier) async { - if (_collections.containsKey(identifier.type)) { - final resource = _collections[identifier.type][identifier.id]; + FutureOr get(ResourceTarget target) async { + if (_collections.containsKey(target.type)) { + final resource = _collections[target.type][target.id]; if (resource == null) { throw ResourceNotFound( - "Resource '${identifier.id}' does not exist in '${identifier.type}'"); + "Resource '${target.id}' does not exist in '${target.type}'"); } return resource; } - throw CollectionNotFound("Collection '${identifier.type}' does not exist"); + throw CollectionNotFound("Collection '${target.type}' does not exist"); } @override - FutureOr update( - Identifier identifier, Resource resource) async { - if (identifier.type != resource.type) { - throw _invalidType(resource, identifier.type); + FutureOr update(ResourceTarget target, Resource resource) async { + if (target.type != resource.type) { + throw _invalidType(resource, target.type); } - final original = await get(identifier); + final original = await get(target); if (resource.attributes.isEmpty && resource.toOne.isEmpty && resource.toMany.isEmpty && - resource.id == identifier.id) { + resource.id == target.id) { return null; } final updated = Resource( @@ -78,14 +78,14 @@ class InMemoryRepository implements Repository { toOne: {...original.toOne}..addAll(resource.toOne), toMany: {...original.toMany}..addAll(resource.toMany), ); - _collections[identifier.type][identifier.id] = updated; + _collections[target.type][target.id] = updated; return updated; } @override - FutureOr delete(Identifier identifier) async { - await get(identifier); - _collections[identifier.type].remove(identifier.id); + FutureOr delete(ResourceTarget target) async { + await get(target); + _collections[target.type].remove(target.id); return null; } diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart index 8791c9bf..fa3de875 100644 --- a/lib/src/server/json_api_request.dart +++ b/lib/src/server/json_api_request.dart @@ -1,5 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// The base interface for JSON:API requests. abstract class JsonApiRequest { @@ -45,219 +47,155 @@ class CreateResource implements JsonApiRequest { /// /// See: https://jsonapi.org/format/#crud-updating class UpdateResource implements JsonApiRequest { - UpdateResource(this.type, this.id, this.resource); + UpdateResource(this.target, this.resource); - /// Resource type - final String type; - - /// Resource id - final String id; + final ResourceTarget target; /// Resource containing fields to be updated final Resource resource; @override T handleWith(Controller controller) => - controller.updateResource(type, id, resource); + controller.updateResource(target, resource); } /// A request to delete a resource on the server /// /// See: https://jsonapi.org/format/#crud-deleting class DeleteResource implements JsonApiRequest { - DeleteResource(this.type, this.id); - - /// Resource type - final String type; + DeleteResource(this.target); - /// Resource id - final String id; + final ResourceTarget target; @override T handleWith(Controller controller) => - controller.deleteResource(type, id); + controller.deleteResource(target); } /// A request to fetch a resource /// /// See: https://jsonapi.org/format/#fetching-resources class FetchResource implements JsonApiRequest { - FetchResource(this.type, this.id, this.queryParameters); + FetchResource(this.target, this.queryParameters); - /// Resource type - final String type; - - /// Resource id - final String id; + final ResourceTarget target; /// URI query parameters final Map> queryParameters; @override T handleWith(Controller controller) => - controller.fetchResource(type, id, queryParameters); + controller.fetchResource(target, queryParameters); } /// A request to fetch a related resource or collection /// /// See: https://jsonapi.org/format/#fetching class FetchRelated implements JsonApiRequest { - FetchRelated(this.type, this.id, this.relationship, this.queryParameters); + FetchRelated(this.target, this.queryParameters); - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// URI query parameters final Map> queryParameters; @override T handleWith(Controller controller) => - controller.fetchRelated(type, id, relationship, queryParameters); + controller.fetchRelated(target, queryParameters); } /// A request to fetch a relationship /// /// See: https://jsonapi.org/format/#fetching-relationships class FetchRelationship implements JsonApiRequest { - FetchRelationship( - this.type, this.id, this.relationship, this.queryParameters); + FetchRelationship(this.target, this.queryParameters); - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// URI query parameters final Map> queryParameters; @override T handleWith(Controller controller) => - controller.fetchRelationship(type, id, relationship, queryParameters); + controller.fetchRelationship(target, queryParameters); } /// A request to delete identifiers from a relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships class DeleteFromRelationship implements JsonApiRequest { - DeleteFromRelationship( - this.type, this.id, this.relationship, Iterable identifiers) + DeleteFromRelationship(this.target, Iterable identifiers) : identifiers = List.unmodifiable(identifiers); - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// The identifiers to delete final List identifiers; @override T handleWith(Controller controller) => - controller.deleteFromRelationship(type, id, relationship, identifiers); + controller.deleteFromRelationship(target, identifiers); } /// A request to replace a to-one relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-one-relationships class ReplaceToOne implements JsonApiRequest { - ReplaceToOne(this.type, this.id, this.relationship, this.identifier); - - /// Resource type - final String type; + ReplaceToOne(this.target, this.identifier); - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// The identifier to be put instead of the existing final Identifier identifier; @override T handleWith(Controller controller) => - controller.replaceToOne(type, id, relationship, identifier); + controller.replaceToOne(target, identifier); } /// A request to delete a to-one relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-one-relationships class DeleteToOne implements JsonApiRequest { - DeleteToOne(this.type, this.id, this.relationship); - - /// Resource type - final String type; + DeleteToOne(this.target); - /// Resource id - final String id; - - final String relationship; + final RelationshipTarget target; @override T handleWith(Controller controller) => - controller.replaceToOne(type, id, relationship, null); + controller.replaceToOne(target, null); } /// A request to completely replace a to-many relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships class ReplaceToMany implements JsonApiRequest { - ReplaceToMany( - this.type, this.id, this.relationship, Iterable identifiers) + ReplaceToMany(this.target, Iterable identifiers) : identifiers = List.unmodifiable(identifiers); - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// The set of identifiers to replace the current ones final List identifiers; @override T handleWith(Controller controller) => - controller.replaceToMany(type, id, relationship, identifiers); + controller.replaceToMany(target, identifiers); } /// A request to add identifiers to a to-many relationship /// /// See: https://jsonapi.org/format/#crud-updating-to-many-relationships class AddToRelationship implements JsonApiRequest { - AddToRelationship( - this.type, this.id, this.relationship, Iterable identifiers) + AddToRelationship(this.target, Iterable identifiers) : identifiers = List.unmodifiable(identifiers); - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Relationship name - final String relationship; + final RelationshipTarget target; /// The identifiers to be added to the existing ones final List identifiers; @override T handleWith(Controller controller) => - controller.addToRelationship(type, id, relationship, identifiers); + controller.addToRelationship(target, identifiers); } diff --git a/lib/src/server/relationship_target.dart b/lib/src/server/relationship_target.dart new file mode 100644 index 00000000..c5dd0e2d --- /dev/null +++ b/lib/src/server/relationship_target.dart @@ -0,0 +1,11 @@ +import 'package:json_api/src/server/resource_target.dart'; + +class RelationshipTarget { + const RelationshipTarget(this.type, this.id, this.relationship); + + final String type; + final String id; + final String relationship; + + ResourceTarget get resource => ResourceTarget(type, id); +} diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 7682eec1..4bd1d579 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// The Repository translates CRUD operations on resources to actual data /// manipulation. @@ -21,20 +22,19 @@ abstract class Repository { /// will be converted to HTTP 403 error. /// /// Throws [InvalidType] if the [resource] - /// does not belong to the collection. This exception will be converted to HTTP 409 - /// error. + /// does not belong to the collection. FutureOr create(String collection, Resource resource); - /// Returns the resource by [identifier]. - FutureOr get(Identifier identifier); + /// Returns the resource by [target]. + FutureOr get(ResourceTarget target); - /// Updates the resource identified by [identifier]. + /// Updates the resource identified by [target]. /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. - FutureOr update(Identifier identifier, Resource resource); + FutureOr update(ResourceTarget target, Resource resource); - /// Deletes the resource identified by [identifier] - FutureOr delete(Identifier identifier); + /// Deletes the resource identified by [target] + FutureOr delete(ResourceTarget target); /// Returns a collection of resources FutureOr> getCollection(String collection); diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 7104d176..6b6d20e8 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -5,7 +5,9 @@ import 'package:json_api/query.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; +import 'package:json_api/src/server/relationship_target.dart'; import 'package:json_api/src/server/repository.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// An opinionated implementation of [Controller]. It translates JSON:API /// requests to [Repository] methods calls. @@ -15,28 +17,29 @@ class RepositoryController implements Controller> { final Repository _repo; @override - FutureOr addToRelationship(String type, String id, - String relationship, Iterable identifiers) => + FutureOr addToRelationship( + RelationshipTarget target, Iterable identifiers) => _do(() async { - final original = await _repo.get(type, id); - if (!original.toMany.containsKey(relationship)) { + final original = await _repo.get(target.resource); + if (!original.toMany.containsKey(target.relationship)) { return ErrorResponse.notFound([ ErrorObject( status: '404', title: 'Relationship not found', detail: - "There is no to-many relationship '${relationship}' in this resource") + "There is no to-many relationship '${target.relationship}' in this resource") ]); } final updated = await _repo.update( - type, - id, - Resource(type, id, toMany: { - relationship: - {...original.toMany[relationship], ...identifiers}.toList() + target.resource, + Resource(target.type, target.id, toMany: { + target.relationship: { + ...original.toMany[target.relationship], + ...identifiers + }.toList() })); - return ToManyResponse( - type, id, relationship, updated.toMany[relationship]); + return ToManyResponse(target.type, target.id, target.relationship, + updated.toMany[target.relationship]); }); @override @@ -48,26 +51,25 @@ class RepositoryController implements Controller> { }); @override - FutureOr deleteFromRelationship(String type, String id, - String relationship, Iterable identifiers) => + FutureOr deleteFromRelationship( + RelationshipTarget target, Iterable identifiers) => _do(() async { - final original = await _repo.get(type, id); + final original = await _repo.get(target.resource); final updated = await _repo.update( - type, - id, - Resource(type, id, toMany: { - relationship: ({...original.toMany[relationship]} + target.resource, + Resource(target.type, target.id, toMany: { + target.relationship: ({...original.toMany[target.relationship]} ..removeAll(identifiers)) .toList() })); - return ToManyResponse( - type, id, relationship, updated.toMany[relationship]); + return ToManyResponse(target.type, target.id, target.relationship, + updated.toMany[target.relationship]); }); @override - FutureOr deleteResource(String type, String id) => + FutureOr deleteResource(ResourceTarget target) => _do(() async { - await _repo.delete(type, id); + await _repo.delete(target); return NoContentResponse(); }); @@ -90,46 +92,47 @@ class RepositoryController implements Controller> { }); @override - FutureOr fetchRelated(String type, String id, - String relationship, Map> queryParameters) => + FutureOr fetchRelated(RelationshipTarget target, + Map> queryParameters) => _do(() async { - final resource = await _repo.get(type, id); - if (resource.toOne.containsKey(relationship)) { - return ResourceResponse( - await _getByIdentifier(resource.toOne[relationship])); + final resource = await _repo.get(target.resource); + if (resource.toOne.containsKey(target.relationship)) { + return ResourceResponse(await _repo.get(ResourceTarget.fromIdentifier( + resource.toOne[target.relationship]))); } - if (resource.toMany.containsKey(relationship)) { + if (resource.toMany.containsKey(target.relationship)) { final related = []; - for (final identifier in resource.toMany[relationship]) { - related.add(await _getByIdentifier(identifier)); + for (final identifier in resource.toMany[target.relationship]) { + related.add( + await _repo.get(ResourceTarget.fromIdentifier(identifier))); } return CollectionResponse(related); } - return _relationshipNotFound(relationship); + return _relationshipNotFound(target.relationship); }); @override - FutureOr fetchRelationship(String type, String id, - String relationship, Map> queryParameters) => + FutureOr fetchRelationship(RelationshipTarget target, + Map> queryParameters) => _do(() async { - final resource = await _repo.get(type, id); - if (resource.toOne.containsKey(relationship)) { - return ToOneResponse( - type, id, relationship, resource.toOne[relationship]); + final resource = await _repo.get(target.resource); + if (resource.toOne.containsKey(target.relationship)) { + return ToOneResponse(target.type, target.id, target.relationship, + resource.toOne[target.relationship]); } - if (resource.toMany.containsKey(relationship)) { - return ToManyResponse( - type, id, relationship, resource.toMany[relationship]); + if (resource.toMany.containsKey(target.relationship)) { + return ToManyResponse(target.type, target.id, target.relationship, + resource.toMany[target.relationship]); } - return _relationshipNotFound(relationship); + return _relationshipNotFound(target.relationship); }); @override FutureOr fetchResource( - String type, String id, Map> queryParameters) => + ResourceTarget target, Map> queryParameters) => _do(() async { final include = Include.fromQueryParameters(queryParameters); - final resource = await _repo.get(type, id); + final resource = await _repo.get(target); final resources = []; for (final path in include) { resources.addAll(await _getRelated(resource, path.split('.'))); @@ -139,39 +142,39 @@ class RepositoryController implements Controller> { }); @override - FutureOr replaceToMany(String type, String id, - String relationship, Iterable identifiers) => + FutureOr replaceToMany( + RelationshipTarget target, Iterable identifiers) => _do(() async { await _repo.update( - type, id, Resource(type, id, toMany: {relationship: identifiers})); + target.resource, + Resource(target.type, target.id, + toMany: {target.relationship: identifiers})); return NoContentResponse(); }); @override FutureOr updateResource( - String type, String id, Resource resource) => + ResourceTarget target, Resource resource) => _do(() async { - final modified = await _repo.update(type, id, resource); + final modified = await _repo.update(target, resource); if (modified == null) return NoContentResponse(); return ResourceResponse(modified); }); @override FutureOr replaceToOne( - String type, String id, String relationship, Identifier identifier) => + RelationshipTarget target, Identifier identifier) => _do(() async { await _repo.update( - type, id, Resource(type, id, toOne: {relationship: identifier})); + target.resource, + Resource(target.type, target.id, + toOne: {target.relationship: identifier})); return NoContentResponse(); }); @override - FutureOr deleteToOne( - String type, String id, String relationship) => - replaceToOne(type, id, relationship, null); - - FutureOr _getByIdentifier(Identifier identifier) => - _repo.get(identifier.type, identifier.id); + FutureOr deleteToOne(RelationshipTarget target) => + replaceToOne(target, null); Future> _getRelated( Resource resource, @@ -187,7 +190,7 @@ class RepositoryController implements Controller> { ids.addAll(resource.toMany[path.first]); } for (final id in ids) { - final r = await _getByIdentifier(id); + final r = await _repo.get(ResourceTarget.fromIdentifier(id)); if (path.length > 1) { resources.addAll(await _getRelated(r, path.skip(1))); } else { diff --git a/lib/src/server/request_converter.dart b/lib/src/server/request_converter.dart index 7c379d80..32d853ed 100644 --- a/lib/src/server/request_converter.dart +++ b/lib/src/server/request_converter.dart @@ -4,6 +4,8 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/json_api_request.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// Converts HTTP requests to JSON:API requests class RequestConverter { @@ -44,13 +46,14 @@ class RequestConverter { throw MethodNotAllowedException(['GET', 'POST']); } } else if (_matcher.matchResource(uri, setTypeId)) { + final target = ResourceTarget(type, id); switch (httpRequest.method) { case 'DELETE': - return DeleteResource(type, id); + return DeleteResource(target); case 'GET': - return FetchResource(type, id, uri.queryParametersAll); + return FetchResource(target, uri.queryParametersAll); case 'PATCH': - return UpdateResource(type, id, + return UpdateResource(target, ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH']); @@ -58,33 +61,35 @@ class RequestConverter { } else if (_matcher.matchRelated(uri, setTypeIdRel)) { switch (httpRequest.method) { case 'GET': - return FetchRelated(type, id, rel, uri.queryParametersAll); + return FetchRelated( + RelationshipTarget(type, id, rel), uri.queryParametersAll); default: throw MethodNotAllowedException(['GET']); } } else if (_matcher.matchRelationship(uri, setTypeIdRel)) { + final target = RelationshipTarget(type, id, rel); switch (httpRequest.method) { case 'DELETE': - return DeleteFromRelationship(type, id, rel, - ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); + return DeleteFromRelationship( + target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); case 'GET': - return FetchRelationship(type, id, rel, uri.queryParametersAll); + return FetchRelationship(target, uri.queryParametersAll); case 'PATCH': final r = Relationship.fromJson(jsonDecode(httpRequest.body)); if (r is ToOne) { final identifier = r.unwrap(); if (identifier != null) { - return ReplaceToOne(type, id, rel, identifier); + return ReplaceToOne(target, identifier); } - return DeleteToOne(type, id, rel); + return DeleteToOne(target); } if (r is ToMany) { - return ReplaceToMany(type, id, rel, r.unwrap()); + return ReplaceToMany(target, r.unwrap()); } throw IncompleteRelationshipException(); case 'POST': - return AddToRelationship(type, id, rel, - ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); + return AddToRelationship( + target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); default: throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH', 'POST']); } diff --git a/lib/src/server/resource_target.dart b/lib/src/server/resource_target.dart new file mode 100644 index 00000000..e4aa956d --- /dev/null +++ b/lib/src/server/resource_target.dart @@ -0,0 +1,12 @@ +import 'package:json_api/document.dart'; + +class ResourceTarget { + const ResourceTarget(this.type, this.id); + + static ResourceTarget fromIdentifier(Identifier identifier) => + ResourceTarget(identifier.type, identifier.id); + + final String type; + + final String id; +} From a97c8c63e7dbbd5e787c4d953ec40c2a08f55a88 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 23 Feb 2020 10:31:04 -0800 Subject: [PATCH 38/99] wip --- lib/src/server/document_factory.dart | 24 ++++++++++------- lib/src/server/http_response_converter.dart | 18 +++++++------ lib/src/server/json_api_response.dart | 29 ++++++++------------- lib/src/server/links/links_factory.dart | 8 +++--- lib/src/server/links/no_links.dart | 10 +++---- lib/src/server/links/standard_links.dart | 20 ++++++++------ lib/src/server/repository_controller.dart | 12 +++------ lib/src/server/response_converter.dart | 9 ++++--- 8 files changed, 66 insertions(+), 64 deletions(-) diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart index 4887c56d..89448246 100644 --- a/lib/src/server/document_factory.dart +++ b/lib/src/server/document_factory.dart @@ -3,6 +3,8 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/links/links_factory.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// The factory producing JSON:API Documents class DocumentFactory { @@ -40,26 +42,26 @@ class DocumentFactory { Document resourceCreated(Resource resource) => Document( ResourceData(_resourceObject(resource), - links: _links.createdResource(resource.type, resource.id)), + links: _links + .createdResource(ResourceTarget(resource.type, resource.id))), api: _api); - Document toMany(String type, String id, String relationship, - Iterable identifiers, + Document toMany( + RelationshipTarget target, Iterable identifiers, {Iterable included}) => Document( ToMany( identifiers.map(IdentifierObject.fromIdentifier), - links: _links.relationship(type, id, relationship), + links: _links.relationship(target), ), api: _api); - Document toOne( - Identifier identifier, String type, String id, String relationship, + Document toOne(RelationshipTarget target, Identifier identifier, {Iterable included}) => Document( ToOne( nullable(IdentifierObject.fromIdentifier)(identifier), - links: _links.relationship(type, id, relationship), + links: _links.relationship(target), ), api: _api); @@ -69,11 +71,13 @@ class DocumentFactory { ...r.toOne.map((k, v) => MapEntry( k, ToOne(nullable(IdentifierObject.fromIdentifier)(v), - links: _links.resourceRelationship(r.type, r.id, k)))), + links: _links.resourceRelationship( + RelationshipTarget(r.type, r.id, k))))), ...r.toMany.map((k, v) => MapEntry( k, ToMany(v.map(IdentifierObject.fromIdentifier), - links: _links.resourceRelationship(r.type, r.id, k)))) + links: _links.resourceRelationship( + RelationshipTarget(r.type, r.id, k))))) }, - links: _links.createdResource(r.type, r.id)); + links: _links.createdResource(ResourceTarget(r.type, r.id))); } diff --git a/lib/src/server/http_response_converter.dart b/lib/src/server/http_response_converter.dart index 217ee888..a31c67cb 100644 --- a/lib/src/server/http_response_converter.dart +++ b/lib/src/server/http_response_converter.dart @@ -5,6 +5,8 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/document_factory.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; import 'package:json_api/src/server/response_converter.dart'; /// An implementation of [ResponseConverter] converting to [HttpResponse]. @@ -49,20 +51,20 @@ class HttpResponseConverter implements ResponseConverter { }); @override - HttpResponse seeOther(String type, String id) => HttpResponse(303, - headers: {'Location': _routing.resource(type, id).toString()}); + HttpResponse seeOther(ResourceTarget target) => HttpResponse(303, headers: { + 'Location': _routing.resource(target.type, target.id).toString() + }); @override - HttpResponse toMany(String type, String id, String relationship, - Iterable identifiers, + HttpResponse toMany( + RelationshipTarget target, Iterable identifiers, {Iterable included}) => - _ok(_doc.toMany(type, id, relationship, identifiers, included: included)); + _ok(_doc.toMany(target, identifiers, included: included)); @override - HttpResponse toOne( - Identifier identifier, String type, String id, String relationship, + HttpResponse toOne(RelationshipTarget target, Identifier identifier, {Iterable included}) => - _ok(_doc.toOne(identifier, type, id, relationship, included: included)); + _ok(_doc.toOne(target, identifier, included: included)); @override HttpResponse noContent() => HttpResponse(204); diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index d63668aa..53f8d347 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,4 +1,6 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; import 'package:json_api/src/server/response_converter.dart'; /// The base interface for JSON:API responses. @@ -169,16 +171,12 @@ class ResourceCreatedResponse implements JsonApiResponse { /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing class SeeOtherResponse implements JsonApiResponse { - SeeOtherResponse(this.type, this.id); + SeeOtherResponse(this.target); - /// Resource type - final String type; - - /// Resource id - final String id; + final ResourceTarget target; @override - T convert(ResponseConverter converter) => converter.seeOther(type, id); + T convert(ResponseConverter converter) => converter.seeOther(target); } /// HTTP 200 OK response containing a to-may relationship. @@ -187,19 +185,16 @@ class SeeOtherResponse implements JsonApiResponse { /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 class ToManyResponse implements JsonApiResponse { - ToManyResponse( - this.type, this.id, this.relationship, Iterable identifiers) + ToManyResponse(this.target, Iterable identifiers) : identifiers = identifiers == null ? null : List.unmodifiable(identifiers); - final String type; - final String id; - final String relationship; + final RelationshipTarget target; final List identifiers; @override T convert(ResponseConverter converter) => - converter.toMany(type, id, relationship, identifiers); + converter.toMany(target, identifiers); } /// HTTP 200 OK response containing a to-one relationship @@ -208,15 +203,13 @@ class ToManyResponse implements JsonApiResponse { /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 class ToOneResponse implements JsonApiResponse { - ToOneResponse(this.type, this.id, this.relationship, this.identifier); + ToOneResponse(this.target, this.identifier); - final String type; - final String id; - final String relationship; + final RelationshipTarget target; final Identifier identifier; @override T convert(ResponseConverter converter) => - converter.toOne(identifier, type, id, relationship); + converter.toOne(target, identifier); } diff --git a/lib/src/server/links/links_factory.dart b/lib/src/server/links/links_factory.dart index d63bd882..885d2b00 100644 --- a/lib/src/server/links/links_factory.dart +++ b/lib/src/server/links/links_factory.dart @@ -1,5 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// Creates `links` objects for JSON:API documents abstract class LinksFactory { @@ -10,11 +12,11 @@ abstract class LinksFactory { Map collection(int total, Pagination pagination); /// Links for a newly created resource - Map createdResource(String type, String id); + Map createdResource(ResourceTarget target); /// Links for a standalone relationship - Map relationship(String type, String id, String rel); + Map relationship(RelationshipTarget target); /// Links for a relationship inside a resource - Map resourceRelationship(String type, String id, String rel); + Map resourceRelationship(RelationshipTarget target); } diff --git a/lib/src/server/links/no_links.dart b/lib/src/server/links/no_links.dart index f1cc86b7..688327bb 100644 --- a/lib/src/server/links/no_links.dart +++ b/lib/src/server/links/no_links.dart @@ -1,5 +1,7 @@ import 'package:json_api/server.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; class NoLinks implements LinksFactory { const NoLinks(); @@ -8,16 +10,14 @@ class NoLinks implements LinksFactory { Map collection(int total, Pagination pagination) => const {}; @override - Map createdResource(String type, String id) => const {}; + Map createdResource(ResourceTarget target) => const {}; @override - Map relationship(String type, String id, String rel) => - const {}; + Map relationship(RelationshipTarget target) => const {}; @override Map resource() => const {}; @override - Map resourceRelationship(String type, String id, String rel) => - const {}; + Map resourceRelationship(RelationshipTarget target) => const {}; } diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart index 8f1cb6d9..3508c204 100644 --- a/lib/src/server/links/standard_links.dart +++ b/lib/src/server/links/standard_links.dart @@ -3,6 +3,8 @@ import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/links/links_factory.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; class StandardLinks implements LinksFactory { StandardLinks(this._requested, this._route); @@ -18,20 +20,22 @@ class StandardLinks implements LinksFactory { {'self': Link(_requested), ..._navigation(total, pagination)}; @override - Map createdResource(String type, String id) => - {'self': Link(_route.resource(type, id))}; + Map createdResource(ResourceTarget target) => + {'self': Link(_route.resource(target.type, target.id))}; @override - Map relationship(String type, String id, String rel) => { + Map relationship(RelationshipTarget target) => { 'self': Link(_requested), - 'related': Link(_route.related(type, id, rel)) + 'related': + Link(_route.related(target.type, target.id, target.relationship)) }; @override - Map resourceRelationship(String type, String id, String rel) => - { - 'self': Link(_route.relationship(type, id, rel)), - 'related': Link(_route.related(type, id, rel)) + Map resourceRelationship(RelationshipTarget target) => { + 'self': Link( + _route.relationship(target.type, target.id, target.relationship)), + 'related': + Link(_route.related(target.type, target.id, target.relationship)) }; Map _navigation(int total, Pagination pagination) { diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 6b6d20e8..0e1828db 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -38,8 +38,7 @@ class RepositoryController implements Controller> { ...identifiers }.toList() })); - return ToManyResponse(target.type, target.id, target.relationship, - updated.toMany[target.relationship]); + return ToManyResponse(target, updated.toMany[target.relationship]); }); @override @@ -62,8 +61,7 @@ class RepositoryController implements Controller> { ..removeAll(identifiers)) .toList() })); - return ToManyResponse(target.type, target.id, target.relationship, - updated.toMany[target.relationship]); + return ToManyResponse(target, updated.toMany[target.relationship]); }); @override @@ -117,12 +115,10 @@ class RepositoryController implements Controller> { _do(() async { final resource = await _repo.get(target.resource); if (resource.toOne.containsKey(target.relationship)) { - return ToOneResponse(target.type, target.id, target.relationship, - resource.toOne[target.relationship]); + return ToOneResponse(target, resource.toOne[target.relationship]); } if (resource.toMany.containsKey(target.relationship)) { - return ToManyResponse(target.type, target.id, target.relationship, - resource.toMany[target.relationship]); + return ToManyResponse(target, resource.toMany[target.relationship]); } return _relationshipNotFound(target.relationship); }); diff --git a/lib/src/server/response_converter.dart b/lib/src/server/response_converter.dart index 31ad506d..e0781fce 100644 --- a/lib/src/server/response_converter.dart +++ b/lib/src/server/response_converter.dart @@ -1,5 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/server/pagination.dart'; +import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/resource_target.dart'; /// Converts JsonApi Controller responses to other responses, e.g. HTTP abstract class ResponseConverter { @@ -50,15 +52,14 @@ abstract class ResponseConverter { /// HTTP 303 See Other response. /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing - T seeOther(String type, String id); + T seeOther(ResourceTarget target); /// HTTP 200 OK response containing a to-may relationship. /// /// See: /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toMany(String type, String id, String relationship, - Iterable identifiers, + T toMany(RelationshipTarget target, Iterable identifiers, {Iterable included}); /// HTTP 200 OK response containing a to-one relationship @@ -66,7 +67,7 @@ abstract class ResponseConverter { /// See: /// - https://jsonapi.org/format/#fetching-relationships-responses-200 /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toOne(Identifier identifier, String type, String id, String relationship, + T toOne(RelationshipTarget target, Identifier identifier, {Iterable included}); /// HTTP 204 No Content response. From 0aeac240be27e3014db950b99993f23a5c3a30d2 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 23 Feb 2020 11:05:22 -0800 Subject: [PATCH 39/99] wip --- lib/src/query/fields.dart | 8 ++++++-- lib/src/query/include.dart | 4 ++-- lib/src/query/page.dart | 9 ++++++--- lib/src/query/query_parameters.dart | 3 +-- lib/src/query/sort.dart | 9 +++++---- lib/src/server/in_memory_repository.dart | 4 +++- lib/src/server/links/standard_links.dart | 2 +- lib/src/server/repository.dart | 4 +++- lib/src/server/repository_controller.dart | 14 +++++++++++--- 9 files changed, 38 insertions(+), 19 deletions(-) diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 13f2f889..e36a5cf7 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -18,8 +18,12 @@ class Fields extends QueryParameters { super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); /// Extracts the requested fields from the [uri]. - static Fields fromUri(Uri uri) => - Fields(uri.queryParametersAll.map((k, v) => MapEntry( + static Fields fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); + + /// Extracts the requested fields from [queryParameters]. + static Fields fromQueryParameters( + Map> queryParameters) => + Fields(queryParameters.map((k, v) => MapEntry( _regex.firstMatch(k)?.group(1), v.expand((_) => _.split(',')).toList())) ..removeWhere((k, v) => k == null)); diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index a8b749f6..a82273a3 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -17,8 +17,8 @@ class Include extends QueryParameters with IterableMixin { : _resources = [...resources], super({'include': resources.join(',')}); - static Include fromUri(Uri uri) => Include( - (uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? [])); + static Include fromUri(Uri uri) => + fromQueryParameters(uri.queryParametersAll); static Include fromQueryParameters(Map> parameters) => Include((parameters['include']?.expand((_) => _.split(',')) ?? [])); diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index 3196bafd..b9d9b224 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -16,9 +16,12 @@ class Page extends QueryParameters { : _parameters = {...parameters}, super(parameters.map((k, v) => MapEntry('page[${k}]', v))); - static Page fromUri(Uri uri) => Page(uri.queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v)) - ..removeWhere((k, v) => k == null)); + static Page fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); + + static Page fromQueryParameters(Map> queryParameters) => + Page(queryParameters + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.last)) + ..removeWhere((k, v) => k == null)); String operator [](String key) => _parameters[key]; diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart index 42385739..b55beced 100644 --- a/lib/src/query/query_parameters.dart +++ b/lib/src/query/query_parameters.dart @@ -3,6 +3,7 @@ class QueryParameters { QueryParameters(Map parameters) : _parameters = {...parameters}; + final Map _parameters; bool get isEmpty => _parameters.isEmpty; @@ -20,6 +21,4 @@ class QueryParameters { /// A shortcut for [merge] QueryParameters operator &(QueryParameters moreParameters) => merge(moreParameters); - - final Map _parameters; } diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 0a6ffc56..0636b1d1 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -19,15 +19,16 @@ class Sort extends QueryParameters with IterableMixin { Sort(Iterable fields) : _fields = [...fields], super({'sort': fields.join(',')}); + final List _fields; + + static Sort fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); - static Sort fromUri(Uri uri) => - Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? []) + static Sort fromQueryParameters(Map> queryParameters) => + Sort((queryParameters['sort']?.expand((_) => _.split(',')) ?? []) .map(SortField.parse)); @override Iterator get iterator => _fields.iterator; - - final List _fields; } class SortField { diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 0fa46ea8..0052993c 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/src/server/repository.dart'; import 'package:json_api/src/server/resource_target.dart'; @@ -90,7 +91,8 @@ class InMemoryRepository implements Repository { } @override - FutureOr> getCollection(String collection) async { + FutureOr> getCollection(String collection, + {int limit, int offset, List sort}) async { if (_collections.containsKey(collection)) { return Collection( _collections[collection].values, _collections[collection].length); diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart index 3508c204..de48b7f2 100644 --- a/lib/src/server/links/standard_links.dart +++ b/lib/src/server/links/standard_links.dart @@ -39,7 +39,7 @@ class StandardLinks implements LinksFactory { }; Map _navigation(int total, Pagination pagination) { - final page = Page.fromUri(_requested); + final page = Page.fromQueryParameters(_requested.queryParametersAll); return ({ 'first': pagination.first(), diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 4bd1d579..d96a77fe 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/server/resource_target.dart'; @@ -37,7 +38,8 @@ abstract class Repository { FutureOr delete(ResourceTarget target); /// Returns a collection of resources - FutureOr> getCollection(String collection); + FutureOr> getCollection(String collection, + {int limit, int offset, List sort}); } /// A collection of elements (e.g. resources) returned by the server. diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 0e1828db..f8f3e45c 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -9,12 +9,14 @@ import 'package:json_api/src/server/relationship_target.dart'; import 'package:json_api/src/server/repository.dart'; import 'package:json_api/src/server/resource_target.dart'; -/// An opinionated implementation of [Controller]. It translates JSON:API +/// An opinionated implementation of [Controller]. Translates JSON:API /// requests to [Repository] methods calls. class RepositoryController implements Controller> { - RepositoryController(this._repo); + RepositoryController(this._repo, {Pagination pagination}) + : _pagination = pagination ?? NoPagination(); final Repository _repo; + final Pagination _pagination; @override FutureOr addToRelationship( @@ -75,8 +77,14 @@ class RepositoryController implements Controller> { FutureOr fetchCollection( String type, Map> queryParameters) => _do(() async { - final c = await _repo.getCollection(type); + final sort = Sort.fromQueryParameters(queryParameters); final include = Include.fromQueryParameters(queryParameters); + final page = Page.fromQueryParameters(queryParameters); + final limit = _pagination.limit(page); + final offset = _pagination.offset(page); + + final c = await _repo.getCollection(type, + sort: sort.toList(), limit: limit, offset: offset); final resources = []; for (final resource in c.elements) { From 23adb2b23b564348eac4d5d50e1b782ecbe6c8b5 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 23 Feb 2020 19:33:39 -0800 Subject: [PATCH 40/99] wip --- README.md | 84 ++++++++++++++++++------- lib/routing.dart | 5 +- lib/src/client/routing_client.dart | 46 ++++++-------- lib/src/routing/collection_route.dart | 5 -- lib/src/routing/composite_routing.dart | 12 ++-- lib/src/routing/relationship_route.dart | 6 -- lib/src/routing/resource_route.dart | 5 -- lib/src/routing/route_matcher.dart | 15 +++++ lib/src/routing/routes.dart | 39 ++++++++++++ lib/src/routing/routing.dart | 4 -- lib/src/routing/standard_routes.dart | 22 +++++-- lib/src/routing/standard_routing.dart | 1 + pubspec.yaml | 4 +- 13 files changed, 161 insertions(+), 87 deletions(-) delete mode 100644 lib/src/routing/collection_route.dart delete mode 100644 lib/src/routing/relationship_route.dart delete mode 100644 lib/src/routing/resource_route.dart create mode 100644 lib/src/routing/routes.dart delete mode 100644 lib/src/routing/routing.dart diff --git a/README.md b/README.md index 3e73893e..7a24ee60 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,12 @@ This package consists of several libraries: - The [Client library] - JSON:API Client for Flutter, Web and Server-side - The [Server library] - a framework-agnostic JSON:API server implementation - The [HTTP library] - a thin abstraction of HTTP requests and responses -- The [Query library] - builds and parses the query parameters (pagination, sorting, filtering, etc) +- The [Query library] - builds and parses the query parameters (page, sorting, filtering, etc) - The [Routing library] - builds and matches URIs for resources, collections, and relationships - ## Document model The main concept of JSON:API model is the [Resource]. Resources are passed between the client and the server in the form of a JSON-encodable [Document]. -A resource has its `type`, `id`, and a map of `attributes`. -Resources refer to other resources with the an [Identifier] which contains a `type` and `id` of the resource being referred. -Relationship between resources may be either `toOne` (maps to a single identifier) or `toMany` (maps to a list of identifiers). ## Client [JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: @@ -34,37 +30,31 @@ For such cases you may use the [RoutingClient] which is a wrapper over the [Json The [RoutingClient] requires an instance of [RouteFactory] to be provided. [JsonApiClient] itself does not make actual HTTP calls. -To instantiate [JsonApiClient] you must provide an instance of [HttpHandler] which would act a an HTTP client. -There is an implementation of [HttpHandler] called [DartHttp] which uses the Dart's native http client. -You may use it or make your own if you prefer a different HTTP client. +Instead, it calls the underlying [HttpHandler] which acts as an HTTP client (must be passed to the constructor). +The library comes with an implementation of [HttpHandler] called [DartHttp] which uses the Dart's native http client. ## Server This is a framework-agnostic library for implementing a JSON:API server. -It may be used on its own (it has a fully functional server implementation) or as a set of independent components. +It may be used on its own (a fully functional server implementation is included) or as a set of independent components. ### Request lifecycle #### HTTP request -The server receives an incoming [HttpRequest]. -It is a thin abstraction over the underlying HTTP system. -[HttpRequest] carries the headers and the body represented as a String. +The server receives an incoming [HttpRequest] containing the HTTP headers and the body represented as a String. When this request is received, your server may decide to check for authentication or other non-JSON:API concerns -to prepare for the request processing or it may decide to fail out with an error response. +to prepare for the request processing, or it may decide to fail out with an error response. #### JSON:API request -The [RequestConverter] then used to convert it to a [JsonApiRequest] which abstracts the JSON:API specific details, -such as the request target (e.g. type, id, relationships) and the decoded body (e.g. [Resource] or [Identifier]). -At this point it is possible to determine if the request is a valid JSON:API request and to read the decoded payload. +The [RequestConverter] is then used to convert the HTTP request to a [JsonApiRequest]. +[JsonApiRequest] abstracts the JSON:API specific details, +such as the request target (a collection, a resource or a relationship) and the decoded body (e.g. [Resource] or [Identifier]). +At this point it is possible to determine whether the request is a valid JSON:API request and to read the decoded payload. You may perform some application-specific logic, e.g. check for authentication. -Each implementation of [JsonApiRequest] has the `handleWith()` to call the right method of the [Controller]. +Each implementation of [JsonApiRequest] has the `handleWith()` method to dispatch a call to the right method of the [Controller]. #### Controller The [Controller] consolidates all methods to process JSON:API requests. -This is where the actual data manipulation happens. -Every controller method must return an instance of the response. -Controllers are generic (generalized by the response type), so your implementation may decide to use its own responses. -You are also welcome to use the included [JsonApiResponse] interface and its implementers covering a wide range -of cases. -This library also comes with a particular implementation of the [Controller] called [RepositoryController]. +Every controller method must return an instance of [JsonApiResponse] (or another type, the controller is generic). +This library comes with a particular implementation of the [Controller] called [RepositoryController]. The [RepositoryController] takes care of all JSON:API specific logic (e.g. validation, filtering, resource inclusion) and translates the JSON:API requests to calls to a resource [Repository]. @@ -90,9 +80,25 @@ The [HttpHandler] interface turns an [HttpRequest] to an [HttpResponse]. The Client consumes an implementation of [HttpHandler] as a low-level HTTP client. The Server is itself an implementation of [HttpHandler]. +## URL Queries +This is a set of classes for building avd parsing some URL query parameters defined in the standard. +- [Fields] for [Sparse fieldsets] +- [Include] for [Inclusion of Related Resources] +- [Page] for [Collection Pagination] +- [Sort] for [Collection Sorting] +## Routing +Defines the logic for constructing and matching URLs for resources, collections and relationships. +The URL construction is used by both the Client (See [RoutingClient] for instance) and the Server libraries. +The [StandardRouting] implements the [Recommended URL design]. [JSON:API]: http://jsonapi.org +[Sparse fieldsets]: https://jsonapi.org/format/#fetching-sparse-fieldsets +[Inclusion of Related Resources]: https://jsonapi.org/format/#fetching-includes +[Collection Pagination]: https://jsonapi.org/format/#fetching-pagination +[Collection Sorting]: https://jsonapi.org/format/#fetching-sorting +[Recommended URL design]: https://jsonapi.org/recommendations/#urls + [Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html [Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html [Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html @@ -100,7 +106,37 @@ The Server is itself an implementation of [HttpHandler]. [Routing library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html [HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html + [Resource]: https://pub.dev/documentation/json_api/latest/document/Resource-class.html [Identifier]: https://pub.dev/documentation/json_api/latest/document/Identifier-class.html [Document]: https://pub.dev/documentation/json_api/latest/document/Document-class.html -[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html \ No newline at end of file +[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html + + +[Response]: https://pub.dev/documentation/json_api/latest/client/Response-class.html +[RoutingClient]: https://pub.dev/documentation/json_api/latest/client/RoutingClient-class.html +[DartHttp]: https://pub.dev/documentation/json_api/latest/client/DartHttp-class.html + + +[RequestConverter]: https://pub.dev/documentation/json_api/latest/server/RequestConverter-class.html +[JsonApiResponse]: https://pub.dev/documentation/json_api/latest/server/JsonApiResponse-class.html +[ResponseConverter]: https://pub.dev/documentation/json_api/latest/server/ResponseConverter-class.html +[JsonApiRequest]: https://pub.dev/documentation/json_api/latest/server/JsonApiRequest-class.html +[Controller]: https://pub.dev/documentation/json_api/latest/server/Controller-class.html +[Repository]: https://pub.dev/documentation/json_api/latest/server/Repository-class.html +[RepositoryController]: https://pub.dev/documentation/json_api/latest/server/RepositoryController-class.html + + +[HttpHandler]: https://pub.dev/documentation/json_api/latest/http/HttpHandler-class.html +[HttpRequest]: https://pub.dev/documentation/json_api/latest/http/HttpRequest-class.html +[HttpResponse]: https://pub.dev/documentation/json_api/latest/http/HttpResponse-class.html + + +[Fields]: https://pub.dev/documentation/json_api/latest/query/Fields-class.html +[Include]: https://pub.dev/documentation/json_api/latest/query/Include-class.html +[Page]: https://pub.dev/documentation/json_api/latest/query/Page-class.html +[Sort]: https://pub.dev/documentation/json_api/latest/query/Sort-class.html + + +[RouteFactory]: https://pub.dev/documentation/json_api/latest/routing/RouteFactory-class.html +[StandardRouting]: https://pub.dev/documentation/json_api/latest/routing/StandardRouting-class.html \ No newline at end of file diff --git a/lib/routing.dart b/lib/routing.dart index 17cf95b3..a440830f 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,9 +1,6 @@ -export 'package:json_api/src/routing/collection_route.dart'; export 'package:json_api/src/routing/composite_routing.dart'; -export 'package:json_api/src/routing/relationship_route.dart'; -export 'package:json_api/src/routing/resource_route.dart'; export 'package:json_api/src/routing/route_factory.dart'; export 'package:json_api/src/routing/route_matcher.dart'; -export 'package:json_api/src/routing/routing.dart'; +export 'package:json_api/src/routing/routes.dart'; export 'package:json_api/src/routing/standard_routes.dart'; export 'package:json_api/src/routing/standard_routing.dart'; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 13a21ce2..819dc283 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -8,87 +8,89 @@ import 'response.dart'; /// This is a wrapper over [JsonApiClient] capable of building the /// request URIs by itself. class RoutingClient { - RoutingClient(this._client, this._routing); + RoutingClient(this._client, this._routes); final JsonApiClient _client; - final RouteFactory _routing; + final RouteFactory _routes; /// Fetches a primary resource collection by [type]. Future> fetchCollection(String type, {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_collection(type), + _client.fetchCollectionAt(_routes.collection(type), headers: headers, parameters: parameters); /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. Future> fetchRelatedCollection( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_related(type, id, relationship), + _client.fetchCollectionAt(_routes.related(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a primary resource by [type] and [id]. Future> fetchResource(String type, String id, {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_resource(type, id), + _client.fetchResourceAt(_routes.resource(type, id), headers: headers, parameters: parameters); /// Fetches a related resource by [type], [id], [relationship]. Future> fetchRelatedResource( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_related(type, id, relationship), + _client.fetchResourceAt(_routes.related(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-one relationship by [type], [id], [relationship]. Future> fetchToOne( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToOneAt(_relationship(type, id, relationship), + _client.fetchToOneAt(_routes.relationship(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a to-many relationship by [type], [id], [relationship]. Future> fetchToMany( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchToManyAt(_relationship(type, id, relationship), + _client.fetchToManyAt(_routes.relationship(type, id, relationship), headers: headers, parameters: parameters); /// Fetches a [relationship] of [type] : [id]. Future> fetchRelationship( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _client.fetchRelationshipAt(_relationship(type, id, relationship), + _client.fetchRelationshipAt(_routes.relationship(type, id, relationship), headers: headers, parameters: parameters); /// Creates the [resource] on the server. Future> createResource(Resource resource, {Map headers}) => - _client.createResourceAt(_collection(resource.type), resource, + _client.createResourceAt(_routes.collection(resource.type), resource, headers: headers); /// Deletes the resource by [type] and [id]. Future deleteResource(String type, String id, {Map headers}) => - _client.deleteResourceAt(_resource(type, id), headers: headers); + _client.deleteResourceAt(_routes.resource(type, id), headers: headers); /// Updates the [resource]. Future> updateResource(Resource resource, {Map headers}) => - _client.updateResourceAt(_resource(resource.type, resource.id), resource, + _client.updateResourceAt( + _routes.resource(resource.type, resource.id), resource, headers: headers); /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceToOne( String type, String id, String relationship, Identifier identifier, {Map headers}) => - _client.replaceToOneAt(_relationship(type, id, relationship), identifier, + _client.replaceToOneAt( + _routes.relationship(type, id, relationship), identifier, headers: headers); /// Deletes the to-one [relationship] of [type] : [id]. Future> deleteToOne( String type, String id, String relationship, {Map headers}) => - _client.deleteToOneAt(_relationship(type, id, relationship), + _client.deleteToOneAt(_routes.relationship(type, id, relationship), headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. @@ -96,7 +98,7 @@ class RoutingClient { String relationship, Iterable identifiers, {Map headers}) => _client.deleteFromToManyAt( - _relationship(type, id, relationship), identifiers, + _routes.relationship(type, id, relationship), identifiers, headers: headers); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. @@ -104,7 +106,7 @@ class RoutingClient { String relationship, Iterable identifiers, {Map headers}) => _client.replaceToManyAt( - _relationship(type, id, relationship), identifiers, + _routes.relationship(type, id, relationship), identifiers, headers: headers); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. @@ -112,16 +114,6 @@ class RoutingClient { String relationship, Iterable identifiers, {Map headers}) => _client.addToRelationshipAt( - _relationship(type, id, relationship), identifiers, + _routes.relationship(type, id, relationship), identifiers, headers: headers); - - Uri _collection(String type) => _routing.collection(type); - - Uri _relationship(String type, String id, String relationship) => - _routing.relationship(type, id, relationship); - - Uri _resource(String type, String id) => _routing.resource(type, id); - - Uri _related(String type, String id, String relationship) => - _routing.related(type, id, relationship); } diff --git a/lib/src/routing/collection_route.dart b/lib/src/routing/collection_route.dart deleted file mode 100644 index c28a5cba..00000000 --- a/lib/src/routing/collection_route.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class CollectionRoute { - Uri uri(String type); - - bool match(Uri uri, void Function(String type) onMatch); -} diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart index 2d40b0e1..7d73d5e4 100644 --- a/lib/src/routing/composite_routing.dart +++ b/lib/src/routing/composite_routing.dart @@ -1,15 +1,15 @@ -import 'package:json_api/src/routing/collection_route.dart'; -import 'package:json_api/src/routing/relationship_route.dart'; -import 'package:json_api/src/routing/resource_route.dart'; -import 'package:json_api/src/routing/routing.dart'; +import 'package:json_api/src/routing/route_factory.dart'; +import 'package:json_api/src/routing/route_matcher.dart'; +import 'package:json_api/src/routing/routes.dart'; -class CompositeRouting implements Routing { +/// URI design composed of independent routes. +class CompositeRouting implements RouteFactory, RouteMatcher { CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, this.relationshipRoute); final CollectionRoute collectionRoute; final ResourceRoute resourceRoute; - final RelationshipRoute relatedRoute; + final RelatedRoute relatedRoute; final RelationshipRoute relationshipRoute; @override diff --git a/lib/src/routing/relationship_route.dart b/lib/src/routing/relationship_route.dart deleted file mode 100644 index 3dd4f7bf..00000000 --- a/lib/src/routing/relationship_route.dart +++ /dev/null @@ -1,6 +0,0 @@ -abstract class RelationshipRoute { - Uri uri(String type, String id, String relationship); - - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} diff --git a/lib/src/routing/resource_route.dart b/lib/src/routing/resource_route.dart deleted file mode 100644 index 127ec8d1..00000000 --- a/lib/src/routing/resource_route.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class ResourceRoute { - Uri uri(String type, String id); - - bool match(Uri uri, void Function(String type, String id) onMatch); -} diff --git a/lib/src/routing/route_matcher.dart b/lib/src/routing/route_matcher.dart index c0d2b5b6..eb8cbb2a 100644 --- a/lib/src/routing/route_matcher.dart +++ b/lib/src/routing/route_matcher.dart @@ -1,11 +1,26 @@ +/// Matches the URI with URI Design patterns. +/// +/// See https://jsonapi.org/recommendations/#urls abstract class RouteMatcher { + /// Matches the [uri] with a collection route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. bool matchCollection(Uri uri, void Function(String type) onMatch); + /// Matches the [uri] with a resource route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. bool matchResource(Uri uri, void Function(String type, String id) onMatch); + /// Matches the [uri] with a related route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. bool matchRelated(Uri uri, void Function(String type, String id, String relationship) onMatch); + /// Matches the [uri] with a relationship route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. bool matchRelationship(Uri uri, void Function(String type, String id, String relationship) onMatch); } diff --git a/lib/src/routing/routes.dart b/lib/src/routing/routes.dart new file mode 100644 index 00000000..750f1dde --- /dev/null +++ b/lib/src/routing/routes.dart @@ -0,0 +1,39 @@ +/// Primary resource collection route +abstract class CollectionRoute { + /// Returns the URI for a collection of type [type]. + Uri uri(String type); + + /// Matches the [uri] with a collection route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. + bool match(Uri uri, void Function(String type) onMatch); +} + +abstract class RelationshipRoute { + Uri uri(String type, String id, String relationship); + + /// Matches the [uri] with a relationship route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} + +abstract class RelatedRoute { + Uri uri(String type, String id, String relationship); + + /// Matches the [uri] with a related route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. + bool match(Uri uri, + void Function(String type, String id, String relationship) onMatch); +} + +abstract class ResourceRoute { + Uri uri(String type, String id); + + /// Matches the [uri] with a resource route pattern. + /// If the match is successful, calls the [onMatch] and returns true. + /// Otherwise returns false. + bool match(Uri uri, void Function(String type, String id) onMatch); +} diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart deleted file mode 100644 index 5872c213..00000000 --- a/lib/src/routing/routing.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:json_api/src/routing/route_factory.dart'; -import 'package:json_api/src/routing/route_matcher.dart'; - -abstract class Routing implements RouteFactory, RouteMatcher {} diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart index e57b32af..c8c5d38d 100644 --- a/lib/src/routing/standard_routes.dart +++ b/lib/src/routing/standard_routes.dart @@ -1,7 +1,9 @@ -import 'package:json_api/src/routing/collection_route.dart'; -import 'package:json_api/src/routing/relationship_route.dart'; -import 'package:json_api/src/routing/resource_route.dart'; +import 'package:json_api/src/routing/routes.dart'; +/// The recommended URI design for a primary resource collections. +/// Example: `/photos` +/// +/// See: https://jsonapi.org/recommendations/#urls-resource-collections class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { StandardCollectionRoute([Uri base]) : super(base); @@ -19,6 +21,10 @@ class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { Uri uri(String type) => _resolve([type]); } +/// The recommended URI design for a primary resource. +/// Example: `/photos/1` +/// +/// See: https://jsonapi.org/recommendations/#urls-individual-resources class StandardResourceRoute extends _BaseRoute implements ResourceRoute { StandardResourceRoute([Uri base]) : super(base); @@ -36,7 +42,11 @@ class StandardResourceRoute extends _BaseRoute implements ResourceRoute { Uri uri(String type, String id) => _resolve([type, id]); } -class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { +/// The recommended URI design for a related resource or collections. +/// Example: `/photos/1/comments` +/// +/// See: https://jsonapi.org/recommendations/#urls-relationships +class StandardRelatedRoute extends _BaseRoute implements RelatedRoute { StandardRelatedRoute([Uri base]) : super(base); @override @@ -55,6 +65,10 @@ class StandardRelatedRoute extends _BaseRoute implements RelationshipRoute { _resolve([type, id, relationship]); } +/// The recommended URI design for a relationship. +/// Example: `/photos/1/relationships/comments` +/// +/// See: https://jsonapi.org/recommendations/#urls-relationships class StandardRelationshipRoute extends _BaseRoute implements RelationshipRoute { StandardRelationshipRoute([Uri base]) : super(base); diff --git a/lib/src/routing/standard_routing.dart b/lib/src/routing/standard_routing.dart index bc9ea670..eeab080e 100644 --- a/lib/src/routing/standard_routing.dart +++ b/lib/src/routing/standard_routing.dart @@ -1,6 +1,7 @@ import 'package:json_api/src/routing/composite_routing.dart'; import 'package:json_api/src/routing/standard_routes.dart'; +/// The standard (recommended) URI design class StandardRouting extends CompositeRouting { StandardRouting([Uri base]) : super(StandardCollectionRoute(base), StandardResourceRoute(base), diff --git a/pubspec.yaml b/pubspec.yaml index dfc7ae09..8a2ffbc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: json_api -version: 4.0.0-rc.2 +version: 4.0.0-rc.3 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) +description: Framework-agnostic implementations of JSON:API Client (Flutter, Web and VM) and Server (VM). Supports JSON:API v1.0 (http://jsonapi.org) environment: sdk: '>=2.6.0 <3.0.0' dependencies: From 33ea533505b1887497cbcef3e6b5c9744abcf5f4 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 23 Feb 2020 19:39:36 -0800 Subject: [PATCH 41/99] wip --- .gitignore | 2 +- README.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 5ea50228..0f158446 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,5 @@ doc/api/ # Generated by test_coverage test/.test_coverage.dart -coverage/ +coverage coverage_badge.svg diff --git a/README.md b/README.md index 7a24ee60..54f4c2f5 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@ [JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: -- The [Document] - the core of this package, describes the JSON:API document structure -- The [Client library] - JSON:API Client for Flutter, Web and Server-side -- The [Server library] - a framework-agnostic JSON:API server implementation -- The [HTTP library] - a thin abstraction of HTTP requests and responses -- The [Query library] - builds and parses the query parameters (page, sorting, filtering, etc) -- The [Routing library] - builds and matches URIs for resources, collections, and relationships +- The [Document library] is the core of this package. It describes the JSON:API document structure +- The [Client library] is a JSON:API Client for Flutter, Web and Server-side +- The [Server library] is a framework-agnostic JSON:API server implementation +- The [HTTP library] is a thin abstraction of HTTP requests and responses +- The [Query library] builds and parses the query parameters (page, sorting, filtering, etc) +- The [Routing library] builds and matches URIs for resources, collections, and relationships ## Document model The main concept of JSON:API model is the [Resource]. @@ -80,7 +80,7 @@ The [HttpHandler] interface turns an [HttpRequest] to an [HttpResponse]. The Client consumes an implementation of [HttpHandler] as a low-level HTTP client. The Server is itself an implementation of [HttpHandler]. -## URL Queries +## Query This is a set of classes for building avd parsing some URL query parameters defined in the standard. - [Fields] for [Sparse fieldsets] - [Include] for [Inclusion of Related Resources] From 675a6fe2228a5fb6990c3f406bb462970e330803 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Fri, 28 Feb 2020 00:03:38 -0800 Subject: [PATCH 42/99] RC4 --- doc/README.md | 1 + doc/schema.json | 382 ++++++++++++++++++ lib/src/document/api.dart | 11 +- lib/src/document/document.dart | 67 +-- lib/src/document/error_object.dart | 91 ++--- lib/src/document/identifier.dart | 9 +- lib/src/document/identifier_object.dart | 4 +- lib/src/document/link.dart | 10 +- lib/src/document/primary_data.dart | 17 +- lib/src/document/relationship.dart | 8 +- lib/src/document/resource.dart | 9 +- .../document/resource_collection_data.dart | 2 +- lib/src/document/resource_data.dart | 5 +- lib/src/document/resource_object.dart | 34 +- lib/src/server/document_factory.dart | 2 +- pubspec.yaml | 2 +- test/functional/compound_document_test.dart | 21 +- .../crud/fetching_relationships_test.dart | 2 +- test/performance/encode_decode.dart | 94 +++++ .../unit/document/identifier_object_test.dart | 3 - test/unit/document/json_api_error_test.dart | 8 - test/unit/document/relationship_test.dart | 9 - test/unit/document/resource_data_test.dart | 10 +- test/unit/document/resource_object_test.dart | 6 - test/unit/document/to_many_test.dart | 8 - test/unit/document/to_one_test.dart | 9 - test/unit/server/json_api_server_test.dart | 2 +- 27 files changed, 637 insertions(+), 189 deletions(-) create mode 100644 doc/README.md create mode 100644 doc/schema.json create mode 100644 test/performance/encode_decode.dart diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 00000000..5276176a --- /dev/null +++ b/doc/README.md @@ -0,0 +1 @@ +The JSON schema file is downloaded from http://jsonapi.org/schema \ No newline at end of file diff --git a/doc/schema.json b/doc/schema.json new file mode 100644 index 00000000..a06f524b --- /dev/null +++ b/doc/schema.json @@ -0,0 +1,382 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "JSON:API Schema", + "description": "This is a schema for responses in the JSON:API format. For more, see http://jsonapi.org", + "oneOf": [ + { + "$ref": "#/definitions/success" + }, + { + "$ref": "#/definitions/failure" + }, + { + "$ref": "#/definitions/info" + } + ], + + "definitions": { + "success": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/data" + }, + "included": { + "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "description": "Link members related to the primary data.", + "allOf": [ + { + "$ref": "#/definitions/links" + }, + { + "$ref": "#/definitions/pagination" + } + ] + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + "failure": { + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/error" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + }, + "links": { + "$ref": "#/definitions/links" + } + }, + "additionalProperties": false + }, + "info": { + "type": "object", + "required": [ + "meta" + ], + "properties": { + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "$ref": "#/definitions/links" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + + "meta": { + "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", + "type": "object", + "additionalProperties": true + }, + "data": { + "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", + "oneOf": [ + { + "$ref": "#/definitions/resource" + }, + { + "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + { + "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", + "type": "null" + } + ] + }, + "resource": { + "description": "\"Resource objects\" appear in a JSON:API document to represent resources.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/definitions/attributes" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, + "links": { + "$ref": "#/definitions/links" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "relationshipLinks": { + "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", + "type": "object", + "properties": { + "self": { + "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", + "$ref": "#/definitions/link" + }, + "related": { + "$ref": "#/definitions/link" + } + }, + "additionalProperties": true + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/link" + } + }, + "link": { + "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", + "oneOf": [ + { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri-reference" + }, + { + "type": "object", + "required": [ + "href" + ], + "properties": { + "href": { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri-reference" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + } + ] + }, + + "attributes": { + "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", + "type": "object", + "patternProperties": { + "^(?!relationships$|links$|id$|type$)\\w[-\\w_]*$": { + "description": "Attributes may contain any valid JSON value." + } + }, + "additionalProperties": false + }, + + "relationships": { + "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", + "type": "object", + "patternProperties": { + "^(?!id$|type$)\\w[-\\w_]*$": { + "properties": { + "links": { + "$ref": "#/definitions/relationshipLinks" + }, + "data": { + "description": "Member, whose value represents \"resource linkage\".", + "oneOf": [ + { + "$ref": "#/definitions/relationshipToOne" + }, + { + "$ref": "#/definitions/relationshipToMany" + } + ] + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "anyOf": [ + {"required": ["data"]}, + {"required": ["meta"]}, + {"required": ["links"]} + ], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "relationshipToOne": { + "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", + "anyOf": [ + { + "$ref": "#/definitions/empty" + }, + { + "$ref": "#/definitions/linkage" + } + ] + }, + "relationshipToMany": { + "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", + "type": "array", + "items": { + "$ref": "#/definitions/linkage" + }, + "uniqueItems": true + }, + "empty": { + "description": "Describes an empty to-one relationship.", + "type": "null" + }, + "linkage": { + "description": "The \"type\" and \"id\" to non-empty members.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "pagination": { + "type": "object", + "properties": { + "first": { + "description": "The first page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "last": { + "description": "The last page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "prev": { + "description": "The previous page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "next": { + "description": "The next page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + } + } + }, + + "jsonapi": { + "description": "An object describing the server's implementation", + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + + "error": { + "type": "object", + "properties": { + "id": { + "description": "A unique identifier for this particular occurrence of the problem.", + "type": "string" + }, + "links": { + "$ref": "#/definitions/links" + }, + "status": { + "description": "The HTTP status code applicable to this problem, expressed as a string value.", + "type": "string" + }, + "code": { + "description": "An application-specific error code, expressed as a string value.", + "type": "string" + }, + "title": { + "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", + "type": "string" + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "pointer": { + "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", + "type": "string" + }, + "parameter": { + "description": "A string indicating which query parameter caused the error.", + "type": "string" + } + } + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + } + } +} diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index d9b85194..a4d4dc79 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -3,8 +3,9 @@ import 'package:json_api/src/document/json_encodable.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object class Api implements JsonEncodable { - Api({this.version, Map meta}) - : meta = meta == null ? null : Map.unmodifiable(meta); + Api({String version, Map meta}) + : meta = Map.unmodifiable(meta ?? const {}), + version = version ?? ''; /// The JSON:API version. May be null. final String version; @@ -12,6 +13,8 @@ class Api implements JsonEncodable { /// Meta data. May be empty or null. final Map meta; + bool get isNotEmpty => version.isEmpty && meta.isNotEmpty; + static Api fromJson(Object json) { if (json is Map) { return Api(version: json['version'], meta: json['meta']); @@ -21,7 +24,7 @@ class Api implements JsonEncodable { @override Map toJson() => { - if (version != null) ...{'version': version}, - if (meta != null) ...{'meta': meta}, + if (version.isNotEmpty) 'version': version, + if (meta.isNotEmpty) 'meta': meta, }; } diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 1312a7e7..50bab9ff 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -7,25 +7,54 @@ import 'package:json_api/src/nullable.dart'; class Document implements JsonEncodable { /// Create a document with primary data - Document(this.data, {Map meta, this.api}) - : errors = null, - meta = (meta == null) ? null : Map.unmodifiable(meta); + Document(this.data, {Map meta, Api api}) + : errors = const [], + meta = Map.unmodifiable(meta ?? const {}), + api = api ?? Api(), + isError = false, + isMeta = false { + ArgumentError.checkNotNull(data); + } /// Create a document with errors (no primary data) Document.error(Iterable errors, - {Map meta, this.api}) + {Map meta, Api api}) : data = null, - meta = (meta == null) ? null : Map.unmodifiable(meta), - errors = List.unmodifiable(errors); + meta = Map.unmodifiable(meta ?? const {}), + errors = List.unmodifiable(errors ?? const []), + api = api ?? Api(), + isError = true, + isMeta = false; /// Create an empty document (no primary data and no errors) - Document.empty(Map meta, {this.api}) + Document.empty(Map meta, {Api api}) : data = null, - meta = (meta == null) ? null : Map.unmodifiable(meta), - errors = null { + meta = Map.unmodifiable(meta ?? const {}), + errors = const [], + api = api ?? Api(), + isError = false, + isMeta = true { ArgumentError.checkNotNull(meta); } + /// The Primary Data. May be null. + final Data data; + + /// List of errors. May be empty or null. + final List errors; + + /// Meta data. May be empty. + final Map meta; + + /// The `jsonapi` object. + final Api api; + + /// True for error documents. + final bool isError; + + /// True for non-error meta-only documents. + final bool isMeta; + /// Reconstructs a document with the specified primary data static Document fromJson( Object json, Data Function(Object json) primaryData) { @@ -49,24 +78,10 @@ class Document implements JsonEncodable { static const contentType = 'application/vnd.api+json'; - /// The Primary Data - final Data data; - - /// The `jsonapi` object. May be null. - final Api api; - - /// List of errors. May be null. - final List errors; - - /// Meta data. May be empty or null. - final Map meta; - @override Map toJson() => { - if (data != null) - ...data.toJson() - else if (errors != null) ...{'errors': errors}, - if (meta != null) ...{'meta': meta}, - if (api != null) ...{'jsonapi': api}, + if (data != null) ...data.toJson() else if (isError) 'errors': errors, + if (isMeta || meta.isNotEmpty) 'meta': meta, + if (api.isNotEmpty) 'jsonapi': api, }; } diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 4caa0ada..b37e9c93 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -2,6 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/nullable.dart'; /// [ErrorObject] represents an error occurred on the server. /// @@ -12,101 +13,91 @@ class ErrorObject implements JsonEncodable { /// passed through the [about] argument takes precedence and will overwrite /// the `about` key in [links]. ErrorObject({ - this.id, - this.status, - this.code, - this.title, - this.detail, - this.parameter, - this.pointer, + String id, + String status, + String code, + String title, + String detail, Map meta, + Map source, Map links, - }) : links = (links == null) ? null : Map.unmodifiable(links), - meta = (meta == null) ? null : Map.unmodifiable(meta); + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + source = Map.unmodifiable(source ?? const {}), + links = Map.unmodifiable(links ?? const {}), + meta = Map.unmodifiable(meta ?? const {}); static ErrorObject fromJson(Object json) { if (json is Map) { - String pointer; - String parameter; - final source = json['source']; - if (source is Map) { - parameter = source['parameter']; - pointer = source['pointer']; - } - final links = json['links']; return ErrorObject( id: json['id'], status: json['status'], code: json['code'], title: json['title'], detail: json['detail'], - pointer: pointer, - parameter: parameter, + source: json['source'], meta: json['meta'], - links: (links == null) ? null : Link.mapFromJson(links)); + links: nullable(Link.mapFromJson)(json['links']) ?? const {}); } throw DocumentException('A JSON:API error must be a JSON object'); } /// A unique identifier for this particular occurrence of the problem. - /// May be null. + /// May be empty. final String id; /// A link that leads to further details about this particular occurrence of the problem. - /// May be null. - Link get about => (links ?? {})['about']; + /// May be empty. + Link get about => links['about']; /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be null. + /// May be empty. final String status; /// An application-specific error code, expressed as a string value. - /// May be null. + /// May be empty. 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. - /// May be null. + /// May be empty. final String title; /// A human-readable explanation specific to this occurrence of the problem. /// Like title, this field’s value can be localized. - /// May be null. + /// May be empty. 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]. - /// May be null. - final String pointer; - - /// A string indicating which URI query parameter caused the error. - /// May be null. - final String parameter; - /// A meta object containing non-standard meta-information about the error. - /// May be empty or null. + /// May be empty. final Map meta; /// The `links` object. - /// May be empty or null. + /// May be empty. /// https://jsonapi.org/format/#document-links final Map links; + /// The `source` object. + /// An object containing references to the source of the error, optionally including any of the following members: + /// - pointer: a JSON Pointer [RFC6901] to the associated entity in the request document, + /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. + /// - parameter: a string indicating which URI query parameter caused the error. + final Map source; + @override Map toJson() { - final source = { - if (pointer != null) ...{'pointer': pointer}, - if (parameter != null) ...{'parameter': parameter}, - }; return { - if (id != null) ...{'id': id}, - if (status != null) ...{'status': status}, - if (code != null) ...{'code': code}, - if (title != null) ...{'title': title}, - if (detail != null) ...{'detail': detail}, - if (meta != null) ...{'meta': meta}, - if (links != null) ...{'links': links}, - if (source.isNotEmpty) ...{'source': source}, + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (meta.isNotEmpty) 'meta': meta, + if (links.isNotEmpty) 'links': links, + if (source.isNotEmpty) 'source': source, }; } } diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 43641cac..68500f3e 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,5 +1,4 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; /// Resource identifier /// @@ -9,12 +8,8 @@ import 'package:json_api/src/document/document_exception.dart'; class Identifier { /// Neither [type] nor [id] can be null or empty. Identifier(this.type, this.id) { - if (id == null || id.isEmpty) { - throw DocumentException("Identifier 'id' must be not empty"); - } - if (type == null || type.isEmpty) { - throw DocumentException("Identifier 'type' must be not empty"); - } + ArgumentError.checkNotNull(type); + ArgumentError.checkNotNull(id); } static Identifier of(Resource resource) => diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index c0e6ab30..88883f5f 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -8,7 +8,7 @@ class IdentifierObject implements JsonEncodable { /// Creates an instance of [IdentifierObject]. /// [type] and [id] can not be null. IdentifierObject(this.type, this.id, {Map meta}) - : meta = (meta == null) ? null : Map.unmodifiable(meta) { + : meta = Map.unmodifiable(meta ?? const {}) { ArgumentError.checkNotNull(type); ArgumentError.checkNotNull(id); } @@ -39,6 +39,6 @@ class IdentifierObject implements JsonEncodable { Map toJson() => { 'type': type, 'id': id, - if (meta != null) ...{'meta': meta}, + if (meta.isNotEmpty) 'meta': meta, }; } diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index ebf523ea..aafcb124 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -27,8 +27,8 @@ class Link implements JsonEncodable { /// Details on the `links` member: https://jsonapi.org/format/#document-links static Map mapFromJson(Object json) { if (json is Map) { - return ({...json}..removeWhere((_, v) => v == null)) - .map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); + return Map.unmodifiable(({...json}..removeWhere((_, v) => v == null)) + .map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))); } throw DocumentException('A JSON:API links object must be a JSON object'); } @@ -43,13 +43,15 @@ class Link implements JsonEncodable { /// A JSON:API link object /// https://jsonapi.org/format/#document-links class LinkObject extends Link { - LinkObject(Uri href, {this.meta}) : super(href); + LinkObject(Uri href, {Map meta}) + : meta = Map.unmodifiable(meta ?? const {}), + super(href); final Map meta; @override Object toJson() => { 'href': uri.toString(), - if (meta != null) ...{'meta': meta} + if (meta.isNotEmpty) 'meta': meta, }; } diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 4ca5577f..16e5beb0 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -9,24 +9,25 @@ import 'package:json_api/src/document/resource_object.dart'; /// - it can not have `meta` and `jsonapi` keys abstract class PrimaryData implements JsonEncodable { PrimaryData({Iterable included, Map links}) - : included = (included == null) ? null : List.unmodifiable(included), - links = (links == null) ? null : Map.unmodifiable(links); + : isCompound = included != null, + included = List.unmodifiable(included ?? const []), + links = Map.unmodifiable(links ?? const {}); /// In a Compound document, this member contains the included resources. - /// May be empty or null, this is to distinguish between two cases: - /// - Inclusion was requested, but no resources were found (empty list) - /// - Inclusion was not requested (null) final List included; + /// True for compound documents. + final bool isCompound; + /// The top-level `links` object. May be empty or null. final Map links; /// The `self` link. May be null. - Link get self => (links ?? {})['self']; + Link get self => links['self']; @override Map toJson() => { - if (links != null) ...{'links': links}, - if (included != null) ...{'included': included} + if (links.isNotEmpty) 'links': links, + if (isCompound) 'included': included, }; } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 85dc0ff7..5925a07a 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -30,9 +30,7 @@ class Relationship extends PrimaryData { return ToMany.fromJson(json); } } - final links = json['links']; - return Relationship( - links: (links == null) ? null : Link.mapFromJson(links)); + return Relationship(links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( 'A JSON:API relationship object must be a JSON object'); @@ -48,7 +46,7 @@ class Relationship extends PrimaryData { } /// The "related" link. May be null. - Link get related => (links ?? {})['related']; + Link get related => links['related']; } /// Relationship to-one @@ -87,7 +85,7 @@ class ToOne extends Relationship { @override Map toJson() => { ...super.toJson(), - ...{'data': linkage} + 'data': linkage, }; /// Converts to [Identifier]. diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 09b989fa..60d6207f 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,4 +1,3 @@ -import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; /// Resource @@ -14,13 +13,11 @@ class Resource { {Map attributes, Map toOne, Map> toMany}) - : attributes = Map.unmodifiable(attributes ?? {}), - toOne = Map.unmodifiable(toOne ?? {}), + : attributes = Map.unmodifiable(attributes ?? const {}), + toOne = Map.unmodifiable(toOne ?? const {}), toMany = Map.unmodifiable( (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v).toList()))) { - if (type == null || type.isEmpty) { - throw DocumentException("Resource 'type' must be not empty"); - } + ArgumentError.notNull(type); } /// Resource type diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index f1634423..4b09567e 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -8,7 +8,7 @@ import 'package:json_api/src/document/resource_object.dart'; class ResourceCollectionData extends PrimaryData { ResourceCollectionData(Iterable collection, {Iterable included, Map links}) - : collection = List.unmodifiable(collection), + : collection = List.unmodifiable(collection ?? const []), super(included: included, links: links); static ResourceCollectionData fromJson(Object json) { diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index dd2e2490..6458f60e 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -10,10 +10,7 @@ class ResourceData extends PrimaryData { ResourceData(this.resourceObject, {Iterable included, Map links}) : super( - included: included, - links: (resourceObject?.links == null && links == null) - ? null - : {...?resourceObject?.links, ...?links}); + included: included, links: {...?resourceObject?.links, ...?links}); static ResourceData fromResource(Resource resource) => ResourceData(ResourceObject.fromResource(resource)); diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 83e3f65e..31e70e27 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -20,11 +20,10 @@ class ResourceObject implements JsonEncodable { Map relationships, Map meta, Map links}) - : links = (links == null) ? null : Map.unmodifiable(links), - attributes = (attributes == null) ? null : Map.unmodifiable(attributes), - meta = (meta == null) ? null : Map.unmodifiable(meta), - relationships = - (relationships == null) ? null : Map.unmodifiable(relationships); + : links = Map.unmodifiable(links ?? const {}), + attributes = Map.unmodifiable(attributes ?? const {}), + meta = Map.unmodifiable(meta ?? const {}), + relationships = Map.unmodifiable(relationships ?? const {}); static ResourceObject fromResource(Resource resource) => ResourceObject(resource.type, resource.id, @@ -37,19 +36,22 @@ class ResourceObject implements JsonEncodable { }); /// Reconstructs the `data` member of a JSON:API Document. - /// If [json] is null, returns null. static ResourceObject fromJson(Object json) { if (json is Map) { final relationships = json['relationships']; final attributes = json['attributes']; + final type = json['type']; if ((relationships == null || relationships is Map) && - (attributes == null || attributes is Map)) { + (attributes == null || attributes is Map) && + type is String && + type.isNotEmpty) { return ResourceObject(json['type'], json['id'], attributes: attributes, relationships: nullable(Relationship.mapFromJson)(relationships), links: Link.mapFromJson(json['links'] ?? {}), meta: json['meta']); } + throw DocumentException('Invalid JSON:API resource object'); } throw DocumentException('A JSON:API resource must be a JSON object'); } @@ -63,28 +65,30 @@ class ResourceObject implements JsonEncodable { /// Read-only `links` object. May be empty. final Map links; - Link get self => (links ?? {})['self']; + Link get self => links['self']; /// Returns the JSON object to be used in the `data` or `included` members /// of a JSON:API Document @override Map toJson() => { 'type': type, - if (id != null) ...{'id': id}, - if (meta != null) ...{'meta': meta}, - if (attributes != null) ...{'attributes': attributes}, - if (relationships != null) ...{'relationships': relationships}, - if (links != null) ...{'links': links}, + if (id != null) 'id': id, + if (meta.isNotEmpty) 'meta': meta, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (links.isNotEmpty) 'links': links, }; /// Extracts the [Resource] if possible. The standard allows relationships /// without `data` member. In this case the original [Resource] can not be /// recovered and this method will throw a [StateError]. + /// + /// Example of missing `data`: https://discuss.jsonapi.org/t/relationships-data-node/223 Resource unwrap() { final toOne = {}; final toMany = >{}; final incomplete = {}; - (relationships ?? {}).forEach((name, rel) { + relationships.forEach((name, rel) { if (rel is ToOne) { toOne[name] = rel.unwrap(); } else if (rel is ToMany) { @@ -96,7 +100,7 @@ class ResourceObject implements JsonEncodable { if (incomplete.isNotEmpty) { throw StateError('Can not convert to resource' - ' due to incomplete relationships data: ${incomplete.keys}'); + ' due to incomplete relationship: ${incomplete.keys}'); } return Resource(type, id, diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart index 89448246..9ad37b81 100644 --- a/lib/src/server/document_factory.dart +++ b/lib/src/server/document_factory.dart @@ -24,7 +24,7 @@ class DocumentFactory { Iterable included, Pagination pagination = const NoPagination()}) => Document( - ResourceCollectionData(collection.map(_resourceObject), + ResourceCollectionData(collection.map(_resourceObject).toList(), links: _links.collection(total, pagination), included: included?.map(_resourceObject)), api: _api); diff --git a/pubspec.yaml b/pubspec.yaml index 8a2ffbc9..ff5f6771 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 4.0.0-rc.3 +version: 4.0.0-rc.4 homepage: https://github.com/f3ath/json-api-dart description: Framework-agnostic implementations of JSON:API Client (Flutter, Web and VM) and Server (VM). Supports JSON:API v1.0 (http://jsonapi.org) environment: diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 9b6d8d16..70a2ed22 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -52,10 +52,10 @@ void main() async { }); group('Single Resouces', () { - test('included == null by default', () async { + test('not compound by default', () async { final r = await client.fetchResource('posts', '1'); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included, isNull); + expect(r.data.isCompound, isFalse); }); test('included == [] when requested but nothing to include', () async { @@ -63,6 +63,7 @@ void main() async { parameters: Include(['tags'])); expectResourcesEqual(r.data.unwrap(), post); expect(r.data.included, []); + expect(r.data.isCompound, isTrue); }); test('can include first-level relatives', () async { @@ -72,6 +73,7 @@ void main() async { expect(r.data.included.length, 2); expectResourcesEqual(r.data.included[0].unwrap(), comment1); expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expect(r.data.isCompound, isTrue); }); test('can include second-level relatives', () async { @@ -81,6 +83,7 @@ void main() async { expect(r.data.included.length, 2); expectResourcesEqual(r.data.included.first.unwrap(), bob); expectResourcesEqual(r.data.included.last.unwrap(), alice); + expect(r.data.isCompound, isTrue); }); test('can include third-level relatives', () async { @@ -89,6 +92,7 @@ void main() async { expectResourcesEqual(r.data.unwrap(), post); expect(r.data.included.length, 1); expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + expect(r.data.isCompound, isTrue); }); test('can include first- and second-level relatives', () async { @@ -100,21 +104,24 @@ void main() async { expectResourcesEqual(r.data.included[1].unwrap(), comment2); expectResourcesEqual(r.data.included[2].unwrap(), bob); expectResourcesEqual(r.data.included[3].unwrap(), alice); + expect(r.data.isCompound, isTrue); }); }); group('Resource Collection', () { - test('included == null by default', () async { + test('not compound by default', () async { final r = await client.fetchCollection('posts'); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included, isNull); + expect(r.data.isCompound, isFalse); }); - test('included == [] when requested but nothing to include', () async { + test('document is compound when requested but nothing to include', + () async { final r = await client.fetchCollection('posts', parameters: Include(['tags'])); expectResourcesEqual(r.data.unwrap().first, post); expect(r.data.included, []); + expect(r.data.isCompound, isTrue); }); test('can include first-level relatives', () async { @@ -124,6 +131,7 @@ void main() async { expect(r.data.included.length, 2); expectResourcesEqual(r.data.included[0].unwrap(), comment1); expectResourcesEqual(r.data.included[1].unwrap(), comment2); + expect(r.data.isCompound, isTrue); }); test('can include second-level relatives', () async { @@ -133,6 +141,7 @@ void main() async { expect(r.data.included.length, 2); expectResourcesEqual(r.data.included.first.unwrap(), bob); expectResourcesEqual(r.data.included.last.unwrap(), alice); + expect(r.data.isCompound, isTrue); }); test('can include third-level relatives', () async { @@ -141,6 +150,7 @@ void main() async { expectResourcesEqual(r.data.unwrap().first, post); expect(r.data.included.length, 1); expectResourcesEqual(r.data.included.first.unwrap(), wonderland); + expect(r.data.isCompound, isTrue); }); test('can include first- and second-level relatives', () async { @@ -152,6 +162,7 @@ void main() async { expectResourcesEqual(r.data.included[1].unwrap(), comment2); expectResourcesEqual(r.data.included[2].unwrap(), bob); expectResourcesEqual(r.data.included[3].unwrap(), alice); + expect(r.data.isCompound, isTrue); }); }); } diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index a5a4da6f..31d142fe 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -103,7 +103,7 @@ void main() async { }); }); - group('Generc', () { + group('Generic', () { test('200 OK to-one', () async { final r = await routingClient.fetchRelationship('books', '1', 'publisher'); diff --git a/test/performance/encode_decode.dart b/test/performance/encode_decode.dart new file mode 100644 index 00000000..1339b4a0 --- /dev/null +++ b/test/performance/encode_decode.dart @@ -0,0 +1,94 @@ +import 'package:json_api/document.dart'; + +void main() { + final meta = { + 'bool': true, + 'array': [1, 2, 3], + 'string': 'foo' + }; + final json = { + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page=2', + 'last': 'http://example.com/articles?page=10' + }, + 'meta': meta, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'meta': meta, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + { + 'type': 'comments', + 'id': '5', + 'meta': meta, + }, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ], + 'jsonapi': {'version': '1.0', 'meta': meta} + }; + + Object j; + + final count = 100000; + final start = DateTime.now().millisecondsSinceEpoch; + for (var i = 0; i < count; i++) { + j = Document.fromJson(json, ResourceCollectionData.fromJson).toJson(); + } + final stop = DateTime.now().millisecondsSinceEpoch; + print('$count iterations took ${stop - start} ms'); +} diff --git a/test/unit/document/identifier_object_test.dart b/test/unit/document/identifier_object_test.dart index 531e6c4b..f7aff941 100644 --- a/test/unit/document/identifier_object_test.dart +++ b/test/unit/document/identifier_object_test.dart @@ -3,9 +3,6 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:test/test.dart'; void main() { - test('type and id can not be null', () { - expect(() => IdentifierObject(null, null), throwsArgumentError); - }); test('throws DocumentException when can not be decoded', () { expect(() => IdentifierObject.fromJson([]), throwsA(TypeMatcher())); diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart index a64e826e..2b7b9b47 100644 --- a/test/unit/document/json_api_error_test.dart +++ b/test/unit/document/json_api_error_test.dart @@ -31,12 +31,4 @@ void main() { 'http://example.com'); }); }); - - group('fromJson()', () { - test('if no links is present, the "links" property is null', () { - final e = ErrorObject.fromJson(json.decode(json.encode((ErrorObject())))); - expect(e.links, null); - expect(e.about, null); - }); - }); } diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index e28d20a8..360c3de5 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -34,13 +34,4 @@ void main() { '/my-link'); }); }); - - group('fromJson()', () { - test('if no links is present, the "links" property is null', () { - final r = - Relationship.fromJson(json.decode(json.encode((Relationship())))); - expect(r.links, null); - expect(r.self, null); - }); - }); } diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index a788871f..6d92b3ec 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -46,7 +46,7 @@ void main() { final data = ResourceData.fromJson(json.decode(json.encode({ 'data': {'type': 'apples', 'id': '1'} }))); - expect(data.included, isNull); + expect(data.isCompound, isFalse); }); test('[] decodes to []', () { final data = ResourceData.fromJson(json.decode(json.encode({ @@ -54,17 +54,17 @@ void main() { 'included': [] }))); expect(data.included, equals([])); + expect(data.isCompound, isTrue); }); - test('non empty [] decodes to non-emoty []', () { + test('non empty [] decodes to non-empty []', () { final data = ResourceData.fromJson(json.decode(json.encode({ 'data': {'type': 'apples', 'id': '1'}, 'included': [ - { - 'data': {'type': 'oranges', 'id': '1'} - } + {'type': 'oranges', 'id': '1'} ] }))); expect(data.included, isNotEmpty); + expect(data.isCompound, isTrue); }); test('invalid value throws DocumentException', () { expect( diff --git a/test/unit/document/resource_object_test.dart b/test/unit/document/resource_object_test.dart index 9dd0a355..d1f20738 100644 --- a/test/unit/document/resource_object_test.dart +++ b/test/unit/document/resource_object_test.dart @@ -60,11 +60,5 @@ void main() { .toString(), '/my-link'); }); - - test('link shortcuts return null is not "links" is set', () { - final r = ResourceObject('apples', '1'); - expect(r.self, null); - expect(r.links, null); - }); }); } diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart index 38f18e24..de2b3f8a 100644 --- a/test/unit/document/to_many_test.dart +++ b/test/unit/document/to_many_test.dart @@ -34,12 +34,4 @@ void main() { '/my-link'); }); }); - - group('fromJson()', () { - test('if no links is present, the "links" property is null', () { - final r = Relationship.fromJson(json.decode(json.encode((ToMany([]))))); - expect(r.links, null); - expect(r.self, null); - }); - }); } diff --git a/test/unit/document/to_one_test.dart b/test/unit/document/to_one_test.dart index e484c259..e9ac2bc6 100644 --- a/test/unit/document/to_one_test.dart +++ b/test/unit/document/to_one_test.dart @@ -34,13 +34,4 @@ void main() { '/my-link'); }); }); - - group('fromJson()', () { - test('if no links is present, the "links" property is null', () { - final r = Relationship.fromJson( - json.decode(json.encode((ToOne(IdentifierObject('apples', '1')))))); - expect(r.links, null); - expect(r.self, null); - }); - }); } diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index fe715920..d1903794 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -55,7 +55,7 @@ void main() { final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); expect(error.title, 'Bad request'); - expect(error.detail, "Resource 'type' must be not empty"); + expect(error.detail, 'Invalid JSON:API resource object'); }); test('returns `not found` if URI is not recognized', () async { From 301fce8e67681eb03d48051089a89d55e0cc2841 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Fri, 28 Feb 2020 20:01:35 -0800 Subject: [PATCH 43/99] v4 --- CHANGELOG.md | 5 +++-- README.md | 6 +----- lib/src/client/json_api_client.dart | 10 +++++----- lib/src/document/error_object.dart | 6 +++--- pubspec.yaml | 4 ++-- test/performance/encode_decode.dart | 3 +++ 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64679811..d8280d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 4.0.0 +## [4.0.0] - 2020-02-29 ### Changed -- This is a major **BC-breaking** rework which affected pretty much all areas. +- Everything. This is a major **BC-breaking** rework which affected pretty much all areas. Please refer to the documentation. ## [3.2.2] - 2020-01-07 ### Fixed @@ -150,6 +150,7 @@ Most of the changes are **BC-BREAKING**. ### Added - Client: fetch resources, collections, related resources and relationships +[4.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.2..4.0.0 [3.2.2]: https://github.com/f3ath/json-api-dart/compare/3.2.1..3.2.2 [3.2.1]: https://github.com/f3ath/json-api-dart/compare/3.2.0...3.2.1 [3.2.0]: https://github.com/f3ath/json-api-dart/compare/3.1.0...3.2.0 diff --git a/README.md b/README.md index 54f4c2f5..8977d566 100644 --- a/README.md +++ b/README.md @@ -3,17 +3,13 @@ [JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: -- The [Document library] is the core of this package. It describes the JSON:API document structure - The [Client library] is a JSON:API Client for Flutter, Web and Server-side - The [Server library] is a framework-agnostic JSON:API server implementation +- The [Document library] is the core of this package. It describes the JSON:API document structure - The [HTTP library] is a thin abstraction of HTTP requests and responses - The [Query library] builds and parses the query parameters (page, sorting, filtering, etc) - The [Routing library] builds and matches URIs for resources, collections, and relationships -## Document model -The main concept of JSON:API model is the [Resource]. -Resources are passed between the client and the server in the form of a JSON-encodable [Document]. - ## Client [JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: - fetching resources and collections (both primary and related) diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index e1048ec9..798ecd61 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -140,14 +140,14 @@ class JsonApiClient { QueryParameters queryParameters) => HttpRequest('GET', (queryParameters ?? QueryParameters({})).addToUri(uri), headers: { - ...headers ?? {}, + ...?headers, 'Accept': Document.contentType, }); HttpRequest _post(Uri uri, Map headers, Document doc) => HttpRequest('POST', uri, headers: { - ...headers ?? {}, + ...?headers, 'Accept': Document.contentType, 'Content-Type': Document.contentType, }, @@ -155,7 +155,7 @@ class JsonApiClient { HttpRequest _delete(Uri uri, Map headers) => HttpRequest('DELETE', uri, headers: { - ...headers ?? {}, + ...?headers, 'Accept': Document.contentType, }); @@ -163,7 +163,7 @@ class JsonApiClient { Uri uri, Map headers, Document doc) => HttpRequest('DELETE', uri, headers: { - ...headers ?? {}, + ...?headers, 'Accept': Document.contentType, 'Content-Type': Document.contentType, }, @@ -172,7 +172,7 @@ class JsonApiClient { HttpRequest _patch(uri, Map headers, Document doc) => HttpRequest('PATCH', uri, headers: { - ...headers ?? {}, + ...?headers, 'Accept': Document.contentType, 'Content-Type': Document.contentType, }, diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index b37e9c93..bd3d80c7 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -40,7 +40,7 @@ class ErrorObject implements JsonEncodable { detail: json['detail'], source: json['source'], meta: json['meta'], - links: nullable(Link.mapFromJson)(json['links']) ?? const {}); + links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException('A JSON:API error must be a JSON object'); } @@ -50,7 +50,7 @@ class ErrorObject implements JsonEncodable { final String id; /// A link that leads to further details about this particular occurrence of the problem. - /// May be empty. + /// May be null. Link get about => links['about']; /// The HTTP status code applicable to this problem, expressed as a string value. @@ -82,7 +82,7 @@ class ErrorObject implements JsonEncodable { /// The `source` object. /// An object containing references to the source of the error, optionally including any of the following members: - /// - pointer: a JSON Pointer [RFC6901] to the associated entity in the request document, + /// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. /// - parameter: a string indicating which URI query parameter caused the error. final Map source; diff --git a/pubspec.yaml b/pubspec.yaml index ff5f6771..916e2b26 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: json_api -version: 4.0.0-rc.4 +version: 4.0.0 homepage: https://github.com/f3ath/json-api-dart -description: Framework-agnostic implementations of JSON:API Client (Flutter, Web and VM) and Server (VM). Supports JSON:API v1.0 (http://jsonapi.org) +description: Framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: sdk: '>=2.6.0 <3.0.0' dependencies: diff --git a/test/performance/encode_decode.dart b/test/performance/encode_decode.dart index 1339b4a0..b45ecde4 100644 --- a/test/performance/encode_decode.dart +++ b/test/performance/encode_decode.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; void main() { @@ -89,6 +91,7 @@ void main() { for (var i = 0; i < count; i++) { j = Document.fromJson(json, ResourceCollectionData.fromJson).toJson(); } + assert(jsonEncode(j) == jsonEncode(json)); final stop = DateTime.now().millisecondsSinceEpoch; print('$count iterations took ${stop - start} ms'); } From 346fcf448501d7829ab70216d73b1e0f51353552 Mon Sep 17 00:00:00 2001 From: Frank Treacy Date: Thu, 5 Mar 2020 14:23:57 -0300 Subject: [PATCH 44/99] Call toJson() on resourceObject PR following https://github.com/f3ath/json-api-dart/issues/83 --- lib/src/document/resource_data.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 6458f60e..5dd6059e 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -37,7 +37,7 @@ class ResourceData extends PrimaryData { @override Map toJson() => { ...super.toJson(), - 'data': resourceObject, + 'data': resourceObject.toJson(), }; Resource unwrap() => resourceObject?.unwrap(); From 6fb72d41dbea07e307bbd34e6a059dca516313fb Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 5 Apr 2020 18:39:48 -0700 Subject: [PATCH 45/99] Unified rs rq (#87) * Passing rq * Responding through request * Embedded http request * Remove response * wip * WIP * WIP Co-authored-by: f3ath --- example/server.dart | 4 +- lib/routing.dart | 1 + lib/server.dart | 7 - .../document/resource_collection_data.dart | 6 +- lib/src/document/resource_data.dart | 6 +- lib/src/http/http_request.dart | 10 + lib/src/routing/composite_routing.dart | 25 +- lib/src/routing/route_matcher.dart | 29 +-- lib/src/routing/routes.dart | 26 +-- lib/src/routing/routing.dart | 3 + lib/src/routing/standard_routes.dart | 10 +- lib/src/server/collection.dart | 10 + lib/src/server/controller.dart | 43 ++-- lib/src/server/controller_request.dart | 83 +++++++ lib/src/server/controller_response.dart | 166 ++++++++++++++ lib/src/server/document_factory.dart | 83 ------- lib/src/server/http_response_converter.dart | 77 ------- lib/src/server/in_memory_repository.dart | 44 ++-- lib/src/server/json_api_request.dart | 201 ---------------- lib/src/server/json_api_response.dart | 215 ------------------ lib/src/server/json_api_server.dart | 113 +++++---- lib/src/server/links/links_factory.dart | 22 -- lib/src/server/links/no_links.dart | 23 -- lib/src/server/links/standard_links.dart | 52 ----- lib/src/server/relationship_target.dart | 11 - lib/src/server/repository.dart | 25 +- lib/src/server/repository_controller.dart | 206 ++++++++--------- lib/src/server/request_converter.dart | 116 ---------- lib/src/server/resolvable.dart | 125 ++++++++++ lib/src/server/resource_target.dart | 12 - lib/src/server/response_converter.dart | 81 ------- lib/src/server/target.dart | 129 +++++++++++ test/e2e/browser_test.dart | 46 ++++ test/e2e/hybrid_server.dart | 14 ++ .../crud/creating_resources_test.dart | 6 + .../crud/deleting_resources_test.dart | 4 + .../crud/fetching_relationships_test.dart | 13 ++ .../crud/fetching_resources_test.dart | 14 ++ .../crud/updating_relationships_test.dart | 16 ++ .../crud/updating_resources_test.dart | 3 + test/unit/client/async_processing_test.dart | 24 +- test/unit/server/json_api_server_test.dart | 36 +-- 42 files changed, 920 insertions(+), 1220 deletions(-) create mode 100644 lib/src/routing/routing.dart create mode 100644 lib/src/server/collection.dart create mode 100644 lib/src/server/controller_request.dart create mode 100644 lib/src/server/controller_response.dart delete mode 100644 lib/src/server/document_factory.dart delete mode 100644 lib/src/server/http_response_converter.dart delete mode 100644 lib/src/server/json_api_request.dart delete mode 100644 lib/src/server/json_api_response.dart delete mode 100644 lib/src/server/links/links_factory.dart delete mode 100644 lib/src/server/links/no_links.dart delete mode 100644 lib/src/server/links/standard_links.dart delete mode 100644 lib/src/server/relationship_target.dart delete mode 100644 lib/src/server/request_converter.dart create mode 100644 lib/src/server/resolvable.dart delete mode 100644 lib/src/server/resource_target.dart delete mode 100644 lib/src/server/response_converter.dart create mode 100644 lib/src/server/target.dart create mode 100644 test/e2e/browser_test.dart create mode 100644 test/e2e/hybrid_server.dart diff --git a/example/server.dart b/example/server.dart index 8d975a9a..5f4df56f 100644 --- a/example/server.dart +++ b/example/server.dart @@ -24,8 +24,8 @@ void main() async { /// We will be logging the requests and responses to the console final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, - onRequest: (r) => print('${r.method} ${r.uri}'), - onResponse: (r) => print('${r.statusCode}')); + onRequest: (r) => print('${r.method} ${r.uri}\n${r.headers}'), + onResponse: (r) => print('${r.statusCode}\n${r.headers}')); /// The handler for the built-in HTTP server final serverHandler = DartServer(loggingJsonApiServer); diff --git a/lib/routing.dart b/lib/routing.dart index a440830f..35f7e54d 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -2,5 +2,6 @@ export 'package:json_api/src/routing/composite_routing.dart'; export 'package:json_api/src/routing/route_factory.dart'; export 'package:json_api/src/routing/route_matcher.dart'; export 'package:json_api/src/routing/routes.dart'; +export 'package:json_api/src/routing/routing.dart'; export 'package:json_api/src/routing/standard_routes.dart'; export 'package:json_api/src/routing/standard_routing.dart'; diff --git a/lib/server.dart b/lib/server.dart index 36ea172d..bf1a5d96 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,15 +1,8 @@ library server; export 'package:json_api/src/server/dart_server.dart'; -export 'package:json_api/src/server/document_factory.dart'; -export 'package:json_api/src/server/http_response_converter.dart'; export 'package:json_api/src/server/in_memory_repository.dart'; -export 'package:json_api/src/server/json_api_request.dart'; -export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/json_api_server.dart'; -export 'package:json_api/src/server/links/links_factory.dart'; -export 'package:json_api/src/server/links/no_links.dart'; -export 'package:json_api/src/server/links/standard_links.dart'; export 'package:json_api/src/server/pagination.dart'; export 'package:json_api/src/server/repository.dart'; export 'package:json_api/src/server/repository_controller.dart'; diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index 4b09567e..b6adfdbb 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -7,9 +7,9 @@ import 'package:json_api/src/document/resource_object.dart'; /// Represents a resource collection or a collection of related resources of a to-many relationship class ResourceCollectionData extends PrimaryData { ResourceCollectionData(Iterable collection, - {Iterable included, Map links}) + {Iterable include, Map links}) : collection = List.unmodifiable(collection ?? const []), - super(included: included, links: links); + super(included: include, links: links); static ResourceCollectionData fromJson(Object json) { if (json is Map) { @@ -18,7 +18,7 @@ class ResourceCollectionData extends PrimaryData { final included = json['included']; return ResourceCollectionData(data.map(ResourceObject.fromJson), links: Link.mapFromJson(json['links'] ?? {}), - included: included is List + include: included is List ? included.map(ResourceObject.fromJson) : null); } diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 5dd6059e..a318b83a 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -8,9 +8,9 @@ import 'package:json_api/src/nullable.dart'; /// Represents a single resource or a single related resource of a to-one relationship class ResourceData extends PrimaryData { ResourceData(this.resourceObject, - {Iterable included, Map links}) + {Iterable include, Map links}) : super( - included: included, links: {...?resourceObject?.links, ...?links}); + included: include, links: {...?resourceObject?.links, ...?links}); static ResourceData fromResource(Resource resource) => ResourceData(ResourceObject.fromResource(resource)); @@ -26,7 +26,7 @@ class ResourceData extends PrimaryData { } final data = nullable(ResourceObject.fromJson)(json['data']); return ResourceData(data, - links: Link.mapFromJson(json['links'] ?? {}), included: resources); + links: Link.mapFromJson(json['links'] ?? {}), include: resources); } throw DocumentException( "A JSON:API resource document must be a JSON object and contain the 'data' member"); diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 8fa85ce4..c0e05000 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -18,4 +18,14 @@ class HttpRequest { /// Request headers. Unmodifiable. Lowercase keys final Map headers; + + bool get isGet => method == 'GET'; + + bool get isPost => method == 'POST'; + + bool get isDelete => method == 'DELETE'; + + bool get isPatch => method == 'PATCH'; + + bool get isOptions => method == 'OPTIONS'; } diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart index 7d73d5e4..c747638d 100644 --- a/lib/src/routing/composite_routing.dart +++ b/lib/src/routing/composite_routing.dart @@ -1,9 +1,9 @@ -import 'package:json_api/src/routing/route_factory.dart'; import 'package:json_api/src/routing/route_matcher.dart'; import 'package:json_api/src/routing/routes.dart'; +import 'package:json_api/src/routing/routing.dart'; /// URI design composed of independent routes. -class CompositeRouting implements RouteFactory, RouteMatcher { +class CompositeRouting implements Routing { CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, this.relationshipRoute); @@ -27,20 +27,9 @@ class CompositeRouting implements RouteFactory, RouteMatcher { Uri resource(String type, String id) => resourceRoute.uri(type, id); @override - bool matchCollection(Uri uri, void Function(String type) onMatch) => - collectionRoute.match(uri, onMatch); - - @override - bool matchRelated(Uri uri, - void Function(String type, String id, String relationship) onMatch) => - relatedRoute.match(uri, onMatch); - - @override - bool matchRelationship(Uri uri, - void Function(String type, String id, String relationship) onMatch) => - relationshipRoute.match(uri, onMatch); - - @override - bool matchResource(Uri uri, void Function(String type, String id) onMatch) => - resourceRoute.match(uri, onMatch); + bool match(Uri uri, MatchHandler handler) => + collectionRoute.match(uri, handler.collection) || + resourceRoute.match(uri, handler.resource) || + relatedRoute.match(uri, handler.related) || + relationshipRoute.match(uri, handler.relationship); } diff --git a/lib/src/routing/route_matcher.dart b/lib/src/routing/route_matcher.dart index eb8cbb2a..87d781ef 100644 --- a/lib/src/routing/route_matcher.dart +++ b/lib/src/routing/route_matcher.dart @@ -2,25 +2,18 @@ /// /// See https://jsonapi.org/recommendations/#urls abstract class RouteMatcher { - /// Matches the [uri] with a collection route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchCollection(Uri uri, void Function(String type) onMatch); + /// Matches the [uri] with route patterns. + /// If there is a match, calls the corresponding method of the [handler]. + /// Returns true if match was found. + bool match(Uri uri, MatchHandler handler); +} + +abstract class MatchHandler { + void collection(String type); - /// Matches the [uri] with a resource route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchResource(Uri uri, void Function(String type, String id) onMatch); + void resource(String type, String id); - /// Matches the [uri] with a related route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchRelated(Uri uri, - void Function(String type, String id, String relationship) onMatch); + void related(String type, String id, String relationship); - /// Matches the [uri] with a relationship route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchRelationship(Uri uri, - void Function(String type, String id, String relationship) onMatch); + void relationship(String type, String id, String relationship); } diff --git a/lib/src/routing/routes.dart b/lib/src/routing/routes.dart index 750f1dde..a6592fb8 100644 --- a/lib/src/routing/routes.dart +++ b/lib/src/routing/routes.dart @@ -4,36 +4,34 @@ abstract class CollectionRoute { Uri uri(String type); /// Matches the [uri] with a collection route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, void Function(String type) onMatch); + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type) onMatch); } abstract class RelationshipRoute { Uri uri(String type, String id, String relationship); /// Matches the [uri] with a relationship route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id, String rel) onMatch); } abstract class RelatedRoute { Uri uri(String type, String id, String relationship); /// Matches the [uri] with a related route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id, String rel) onMatch); } abstract class ResourceRoute { Uri uri(String type, String id); /// Matches the [uri] with a resource route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, void Function(String type, String id) onMatch); + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id) onMatch); } diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart new file mode 100644 index 00000000..a5f429cf --- /dev/null +++ b/lib/src/routing/routing.dart @@ -0,0 +1,3 @@ +import 'package:json_api/routing.dart'; + +abstract class Routing implements RouteFactory, RouteMatcher {} diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart index c8c5d38d..608537b4 100644 --- a/lib/src/routing/standard_routes.dart +++ b/lib/src/routing/standard_routes.dart @@ -8,7 +8,7 @@ class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { StandardCollectionRoute([Uri base]) : super(base); @override - bool match(Uri uri, void Function(String type) onMatch) { + bool match(Uri uri, Function(String type) onMatch) { final seg = _segments(uri); if (seg.length == 1) { onMatch(seg.first); @@ -29,7 +29,7 @@ class StandardResourceRoute extends _BaseRoute implements ResourceRoute { StandardResourceRoute([Uri base]) : super(base); @override - bool match(Uri uri, void Function(String type, String id) onMatch) { + bool match(Uri uri, Function(String type, String id) onMatch) { final seg = _segments(uri); if (seg.length == 2) { onMatch(seg.first, seg.last); @@ -50,8 +50,7 @@ class StandardRelatedRoute extends _BaseRoute implements RelatedRoute { StandardRelatedRoute([Uri base]) : super(base); @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { + bool match(Uri uri, Function(String type, String id, String rel) onMatch) { final seg = _segments(uri); if (seg.length == 3) { onMatch(seg.first, seg[1], seg.last); @@ -74,8 +73,7 @@ class StandardRelationshipRoute extends _BaseRoute StandardRelationshipRoute([Uri base]) : super(base); @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { + bool match(Uri uri, Function(String type, String id, String rel) onMatch) { final seg = _segments(uri); if (seg.length == 4 && seg[2] == _rel) { onMatch(seg.first, seg[1], seg.last); diff --git a/lib/src/server/collection.dart b/lib/src/server/collection.dart new file mode 100644 index 00000000..c5382fed --- /dev/null +++ b/lib/src/server/collection.dart @@ -0,0 +1,10 @@ +/// A collection of elements (e.g. resources) returned by the server. +class Collection { + Collection(Iterable elements, [this.total]) + : elements = List.unmodifiable(elements); + + final List elements; + + /// Total count of the elements on the server. May be null. + final int total; +} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 6035b2d4..8c6443d8 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,60 +1,57 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; +import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/controller_response.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. -abstract class Controller { +abstract class Controller { /// Finds an returns a primary resource collection. /// See https://jsonapi.org/format/#fetching-resources - T fetchCollection(String type, Map> queryParameters); + Future fetchCollection(CollectionRequest request); /// Finds an returns a primary resource. /// See https://jsonapi.org/format/#fetching-resources - T fetchResource( - ResourceTarget target, Map> queryParameters); + Future fetchResource(ResourceRequest request); /// Finds an returns a related resource or a collection of related resources. /// See https://jsonapi.org/format/#fetching-resources - T fetchRelated( - RelationshipTarget target, Map> queryParameters); + Future fetchRelated(RelatedRequest request); /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships - T fetchRelationship( - RelationshipTarget target, Map> queryParameters); + Future fetchRelationship(RelationshipRequest request); /// Deletes the resource. /// See https://jsonapi.org/format/#crud-deleting - T deleteResource(ResourceTarget target); + Future deleteResource(ResourceRequest request); /// Creates a new resource in the collection. /// See https://jsonapi.org/format/#crud-creating - T createResource(String type, Resource resource); + Future createResource( + CollectionRequest request, Resource resource); /// Updates the resource. /// See https://jsonapi.org/format/#crud-updating - T updateResource(ResourceTarget target, Resource resource); + Future updateResource( + ResourceRequest request, Resource resource); /// Replaces the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T replaceToOne(RelationshipTarget target, Identifier identifier); - - /// Deletes the to-one relationship. - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T deleteToOne(RelationshipTarget target); + Future replaceToOne( + RelationshipRequest request, Identifier identifier); /// Replaces the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T replaceToMany(RelationshipTarget target, Iterable identifiers); + Future replaceToMany( + RelationshipRequest request, List identifiers); /// Removes the given identifiers from the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T deleteFromRelationship( - RelationshipTarget target, Iterable identifiers); + Future deleteFromRelationship( + RelationshipRequest request, List identifiers); /// Adds the given identifiers to the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T addToRelationship( - RelationshipTarget target, Iterable identifiers); + Future addToRelationship( + RelationshipRequest request, List identifiers); } diff --git a/lib/src/server/controller_request.dart b/lib/src/server/controller_request.dart new file mode 100644 index 00000000..bbd983f5 --- /dev/null +++ b/lib/src/server/controller_request.dart @@ -0,0 +1,83 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/server/collection.dart'; +import 'package:json_api/src/server/controller_response.dart'; + +class _Base { + _Base(this.request) + : sort = Sort.fromQueryParameters(request.uri.queryParametersAll), + include = Include.fromQueryParameters(request.uri.queryParametersAll), + page = Page.fromQueryParameters(request.uri.queryParametersAll); + + final HttpRequest request; + final Include include; + final Page page; + final Sort sort; + + bool get isCompound => include.isNotEmpty; +} + +class RelatedRequest extends _Base { + RelatedRequest(HttpRequest request, this.type, this.id, this.relationship) + : super(request); + + final String type; + + final String id; + + final String relationship; + + ControllerResponse resourceResponse(Resource resource, + {List include}) => + ResourceResponse(resource); + + ControllerResponse collectionResponse(Collection collection, + {List include}) => + CollectionResponse(collection); +} + +class ResourceRequest extends _Base { + ResourceRequest(HttpRequest request, this.type, this.id) : super(request); + + final String type; + + final String id; + + ControllerResponse resourceResponse(Resource resource, + {List include}) => + ResourceResponse(resource, include: include); +} + +class RelationshipRequest extends _Base { + RelationshipRequest( + HttpRequest request, this.type, this.id, this.relationship) + : super(request); + + final String type; + + final String id; + + final String relationship; + + ControllerResponse toManyResponse(List identifiers, + {List include}) => + ToManyResponse(identifiers); + + ControllerResponse toOneResponse(Identifier identifier, + {List include}) => + ToOneResponse(identifier); +} + +class CollectionRequest extends _Base { + CollectionRequest(HttpRequest request, this.type) : super(request); + + final String type; + + ControllerResponse resourceResponse(Resource modified) => + CreatedResourceResponse(modified); + + ControllerResponse collectionResponse(Collection collection, + {List include}) => + CollectionResponse(collection, include: include); +} diff --git a/lib/src/server/controller_response.dart b/lib/src/server/controller_response.dart new file mode 100644 index 00000000..c32048f9 --- /dev/null +++ b/lib/src/server/controller_response.dart @@ -0,0 +1,166 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/collection.dart'; + +abstract class ControllerResponse { + int get status; + + Map headers(RouteFactory route); + + Document document(DocumentFactory doc); +} + +class ErrorResponse implements ControllerResponse { + ErrorResponse(this.status, this.errors); + + @override + final int status; + final List errors; + + @override + Map headers(RouteFactory route) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc) => doc.error(errors); +} + +class NoContentResponse implements ControllerResponse { + NoContentResponse(); + + @override + int get status => 204; + + @override + Map headers(RouteFactory route) => {}; + + @override + Document document(DocumentFactory doc) => null; +} + +class ResourceResponse implements ControllerResponse { + ResourceResponse(this.resource, {this.include}); + + final Resource resource; + final List include; + + @override + int get status => 200; + + @override + Map headers(RouteFactory route) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc) => + doc.resource(resource, include: include); +} + +class CreatedResourceResponse implements ControllerResponse { + CreatedResourceResponse(this.resource); + + final Resource resource; + + @override + int get status => 201; + + @override + Map headers(RouteFactory route) => { + 'Content-Type': Document.contentType, + 'Location': route.resource(resource.type, resource.id).toString() + }; + + @override + Document document(DocumentFactory doc) => + doc.createdResource(resource, StandardRouting()); +} + +class CollectionResponse implements ControllerResponse { + CollectionResponse(this.collection, {this.include}); + + final Collection collection; + final List include; + + @override + int get status => 200; + + @override + Map headers(RouteFactory route) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc) => + doc.collection(collection, include: include); +} + +class ToOneResponse implements ControllerResponse { + ToOneResponse(this.identifier); + + final Identifier identifier; + + @override + int get status => 200; + + @override + Map headers(RouteFactory route) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc) => doc.toOne(identifier); +} + +class ToManyResponse implements ControllerResponse { + ToManyResponse(this.identifiers); + + final List identifiers; + + @override + int get status => 200; + + @override + Map headers(RouteFactory route) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc) => doc.toMany(identifiers); +} + +class DocumentFactory { + Document error(List errors) => Document.error(errors); + + Document collection(Collection collection, + {List include}) => + Document(ResourceCollectionData(collection.elements.map(resourceObject), + include: include?.map(resourceObject))); + + Document resource(Resource resource, + {List include}) => + Document(ResourceData(resourceObject(resource), + include: include?.map(resourceObject))); + + Document createdResource( + Resource resource, RouteFactory routeFactory) => + Document(ResourceData(resourceObject(resource, + self: Link(routeFactory.resource(resource.type, resource.id))))); + + Document toOne(Identifier identifier) => + Document(ToOne(IdentifierObject.fromIdentifier(identifier))); + + Document toMany(List identifiers) => + Document(ToMany(identifiers.map(IdentifierObject.fromIdentifier))); + + ResourceObject resourceObject(Resource resource, {Link self}) { + return ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: { + ...resource.toOne.map((k, v) => + MapEntry(k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), + ...resource.toMany.map((k, v) => + MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))), + }, + links: { + if (self != null) 'self': self + }); + } +} diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart deleted file mode 100644 index 9ad37b81..00000000 --- a/lib/src/server/document_factory.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/links/links_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// The factory producing JSON:API Documents -class DocumentFactory { - DocumentFactory({LinksFactory links = const NoLinks()}) : _links = links; - - final Api _api = Api(version: '1.0'); - - final LinksFactory _links; - - /// An error document - Document error(Iterable errors) => - Document.error(errors, api: _api); - - /// A resource collection document - Document collection(Iterable collection, - {int total, - Iterable included, - Pagination pagination = const NoPagination()}) => - Document( - ResourceCollectionData(collection.map(_resourceObject).toList(), - links: _links.collection(total, pagination), - included: included?.map(_resourceObject)), - api: _api); - - /// An empty (meta) document - Document empty(Map meta) => Document.empty(meta, api: _api); - - Document resource(Resource resource, - {Iterable included}) => - Document( - ResourceData(_resourceObject(resource), - links: _links.resource(), - included: included?.map(_resourceObject)), - api: _api); - - Document resourceCreated(Resource resource) => Document( - ResourceData(_resourceObject(resource), - links: _links - .createdResource(ResourceTarget(resource.type, resource.id))), - api: _api); - - Document toMany( - RelationshipTarget target, Iterable identifiers, - {Iterable included}) => - Document( - ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: _links.relationship(target), - ), - api: _api); - - Document toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}) => - Document( - ToOne( - nullable(IdentifierObject.fromIdentifier)(identifier), - links: _links.relationship(target), - ), - api: _api); - - ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, - attributes: r.attributes, - relationships: { - ...r.toOne.map((k, v) => MapEntry( - k, - ToOne(nullable(IdentifierObject.fromIdentifier)(v), - links: _links.resourceRelationship( - RelationshipTarget(r.type, r.id, k))))), - ...r.toMany.map((k, v) => MapEntry( - k, - ToMany(v.map(IdentifierObject.fromIdentifier), - links: _links.resourceRelationship( - RelationshipTarget(r.type, r.id, k))))) - }, - links: _links.createdResource(ResourceTarget(r.type, r.id))); -} diff --git a/lib/src/server/http_response_converter.dart b/lib/src/server/http_response_converter.dart deleted file mode 100644 index a31c67cb..00000000 --- a/lib/src/server/http_response_converter.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/document_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; -import 'package:json_api/src/server/response_converter.dart'; - -/// An implementation of [ResponseConverter] converting to [HttpResponse]. -class HttpResponseConverter implements ResponseConverter { - HttpResponseConverter(this._doc, this._routing); - - final RouteFactory _routing; - final DocumentFactory _doc; - - @override - HttpResponse error(Iterable errors, int statusCode, - Map headers) => - _ok(_doc.error(errors), status: statusCode, headers: headers); - - @override - HttpResponse collection(Iterable resources, - {int total, - Iterable included, - Pagination pagination = const NoPagination()}) { - return _ok(_doc.collection(resources, - total: total, included: included, pagination: pagination)); - } - - @override - HttpResponse accepted(Resource resource) => - _ok(_doc.resource(resource), status: 202, headers: { - 'Content-Location': - _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse meta(Map meta) => _ok(_doc.empty(meta)); - - @override - HttpResponse resource(Resource resource, {Iterable included}) => - _ok(_doc.resource(resource, included: included)); - - @override - HttpResponse resourceCreated(Resource resource) => - _ok(_doc.resourceCreated(resource), status: 201, headers: { - 'Location': _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse seeOther(ResourceTarget target) => HttpResponse(303, headers: { - 'Location': _routing.resource(target.type, target.id).toString() - }); - - @override - HttpResponse toMany( - RelationshipTarget target, Iterable identifiers, - {Iterable included}) => - _ok(_doc.toMany(target, identifiers, included: included)); - - @override - HttpResponse toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}) => - _ok(_doc.toOne(target, identifier, included: included)); - - @override - HttpResponse noContent() => HttpResponse(204); - - HttpResponse _ok(Document d, - {int status = 200, Map headers = const {}}) => - HttpResponse(status, - body: jsonEncode(d), - headers: {...headers, 'Content-Type': Document.contentType}); -} diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 0052993c..bdae0759 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/resource_target.dart'; typedef IdGenerator = String Function(); typedef TypeAttributionCriteria = bool Function(String collection, String type); @@ -16,7 +16,7 @@ class InMemoryRepository implements Repository { final IdGenerator _nextId; @override - FutureOr create(String collection, Resource resource) async { + Future create(String collection, Resource resource) async { if (!_collections.containsKey(collection)) { throw CollectionNotFound("Collection '$collection' does not exist"); } @@ -26,7 +26,7 @@ class InMemoryRepository implements Repository { for (final relationship in resource.toOne.values .followedBy(resource.toMany.values.expand((_) => _))) { // Make sure the relationships exist - await get(ResourceTarget.fromIdentifier(relationship)); + await get(relationship.type, relationship.id); } if (resource.id == null) { if (_nextId == null) { @@ -48,28 +48,27 @@ class InMemoryRepository implements Repository { } @override - FutureOr get(ResourceTarget target) async { - if (_collections.containsKey(target.type)) { - final resource = _collections[target.type][target.id]; + Future get(String type, String id) async { + if (_collections.containsKey(type)) { + final resource = _collections[type][id]; if (resource == null) { - throw ResourceNotFound( - "Resource '${target.id}' does not exist in '${target.type}'"); + throw ResourceNotFound("Resource '${id}' does not exist in '${type}'"); } return resource; } - throw CollectionNotFound("Collection '${target.type}' does not exist"); + throw CollectionNotFound("Collection '${type}' does not exist"); } @override - FutureOr update(ResourceTarget target, Resource resource) async { - if (target.type != resource.type) { - throw _invalidType(resource, target.type); + Future update(String type, String id, Resource resource) async { + if (type != resource.type) { + throw _invalidType(resource, type); } - final original = await get(target); + final original = await get(type, id); if (resource.attributes.isEmpty && resource.toOne.isEmpty && resource.toMany.isEmpty && - resource.id == target.id) { + resource.id == id) { return null; } final updated = Resource( @@ -79,25 +78,24 @@ class InMemoryRepository implements Repository { toOne: {...original.toOne}..addAll(resource.toOne), toMany: {...original.toMany}..addAll(resource.toMany), ); - _collections[target.type][target.id] = updated; + _collections[type][id] = updated; return updated; } @override - FutureOr delete(ResourceTarget target) async { - await get(target); - _collections[target.type].remove(target.id); + Future delete(String type, String id) async { + await get(type, id); + _collections[type].remove(id); return null; } @override - FutureOr> getCollection(String collection, + Future> getCollection(String type, {int limit, int offset, List sort}) async { - if (_collections.containsKey(collection)) { - return Collection( - _collections[collection].values, _collections[collection].length); + if (_collections.containsKey(type)) { + return Collection(_collections[type].values, _collections[type].length); } - throw CollectionNotFound("Collection '$collection' does not exist"); + throw CollectionNotFound("Collection '$type' does not exist"); } InvalidType _invalidType(Resource resource, String collection) { diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart deleted file mode 100644 index fa3de875..00000000 --- a/lib/src/server/json_api_request.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// The base interface for JSON:API requests. -abstract class JsonApiRequest { - /// Calls the appropriate method of [controller] and returns the response - T handleWith(Controller controller); -} - -/// A request to fetch a collection of type [type]. -/// -/// See: https://jsonapi.org/format/#fetching-resources -class FetchCollection implements JsonApiRequest { - FetchCollection(this.queryParameters, this.type); - - /// Resource type - final String type; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchCollection(type, queryParameters); -} - -/// A request to create a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-creating -class CreateResource implements JsonApiRequest { - CreateResource(this.type, this.resource); - - /// Resource type - final String type; - - /// Resource to create - final Resource resource; - - @override - T handleWith(Controller controller) => - controller.createResource(type, resource); -} - -/// A request to update a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-updating -class UpdateResource implements JsonApiRequest { - UpdateResource(this.target, this.resource); - - final ResourceTarget target; - - /// Resource containing fields to be updated - final Resource resource; - - @override - T handleWith(Controller controller) => - controller.updateResource(target, resource); -} - -/// A request to delete a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-deleting -class DeleteResource implements JsonApiRequest { - DeleteResource(this.target); - - final ResourceTarget target; - - @override - T handleWith(Controller controller) => - controller.deleteResource(target); -} - -/// A request to fetch a resource -/// -/// See: https://jsonapi.org/format/#fetching-resources -class FetchResource implements JsonApiRequest { - FetchResource(this.target, this.queryParameters); - - final ResourceTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchResource(target, queryParameters); -} - -/// A request to fetch a related resource or collection -/// -/// See: https://jsonapi.org/format/#fetching -class FetchRelated implements JsonApiRequest { - FetchRelated(this.target, this.queryParameters); - - final RelationshipTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchRelated(target, queryParameters); -} - -/// A request to fetch a relationship -/// -/// See: https://jsonapi.org/format/#fetching-relationships -class FetchRelationship implements JsonApiRequest { - FetchRelationship(this.target, this.queryParameters); - - final RelationshipTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchRelationship(target, queryParameters); -} - -/// A request to delete identifiers from a relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class DeleteFromRelationship implements JsonApiRequest { - DeleteFromRelationship(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The identifiers to delete - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.deleteFromRelationship(target, identifiers); -} - -/// A request to replace a to-one relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class ReplaceToOne implements JsonApiRequest { - ReplaceToOne(this.target, this.identifier); - - final RelationshipTarget target; - - /// The identifier to be put instead of the existing - final Identifier identifier; - - @override - T handleWith(Controller controller) => - controller.replaceToOne(target, identifier); -} - -/// A request to delete a to-one relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class DeleteToOne implements JsonApiRequest { - DeleteToOne(this.target); - - final RelationshipTarget target; - - @override - T handleWith(Controller controller) => - controller.replaceToOne(target, null); -} - -/// A request to completely replace a to-many relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class ReplaceToMany implements JsonApiRequest { - ReplaceToMany(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The set of identifiers to replace the current ones - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.replaceToMany(target, identifiers); -} - -/// A request to add identifiers to a to-many relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class AddToRelationship implements JsonApiRequest { - AddToRelationship(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The identifiers to be added to the existing ones - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.addToRelationship(target, identifiers); -} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart deleted file mode 100644 index 53f8d347..00000000 --- a/lib/src/server/json_api_response.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; -import 'package:json_api/src/server/response_converter.dart'; - -/// The base interface for JSON:API responses. -abstract class JsonApiResponse { - /// Converts the JSON:API response to another object, e.g. HTTP response. - T convert(ResponseConverter converter); -} - -/// HTTP 204 No Content response. -/// -/// See: -/// - https://jsonapi.org/format/#crud-creating-responses-204 -/// - https://jsonapi.org/format/#crud-updating-responses-204 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 -/// - https://jsonapi.org/format/#crud-deleting-responses-204 -class NoContentResponse implements JsonApiResponse { - @override - T convert(ResponseConverter converter) => converter.noContent(); -} - -/// HTTP 200 OK response with a resource collection. -/// -/// See: https://jsonapi.org/format/#fetching-resources-responses-200 -class CollectionResponse implements JsonApiResponse { - CollectionResponse(Iterable resources, - {Iterable included, this.total}) - : resources = List.unmodifiable(resources), - included = included == null ? null : List.unmodifiable(included); - - final List resources; - final List included; - - final int total; - - @override - T convert(ResponseConverter converter) => - converter.collection(resources, included: included, total: total); -} - -/// HTTP 202 Accepted response. -/// -/// See: https://jsonapi.org/recommendations/#asynchronous-processing -class AcceptedResponse implements JsonApiResponse { - AcceptedResponse(this.resource); - - final Resource resource; - - @override - T convert(ResponseConverter converter) => converter.accepted(resource); -} - -/// A common error response. -/// -/// See: https://jsonapi.org/format/#errors -class ErrorResponse implements JsonApiResponse { - ErrorResponse(this.statusCode, Iterable errors, - {Map headers = const {}}) - : _headers = Map.unmodifiable(headers), - errors = List.unmodifiable(errors); - - /// HTTP 400 Bad Request response. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-includes - /// - https://jsonapi.org/format/#fetching-sorting - /// - https://jsonapi.org/format/#query-parameters - ErrorResponse.badRequest(Iterable errors) : this(400, errors); - - /// HTTP 403 Forbidden response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-client-ids - /// - https://jsonapi.org/format/#crud-creating-responses-403 - /// - https://jsonapi.org/format/#crud-updating-resource-relationships - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-403 - ErrorResponse.forbidden(Iterable errors) : this(403, errors); - - /// HTTP 404 Not Found response. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-resources-responses-404 - /// - https://jsonapi.org/format/#fetching-relationships-responses-404 - /// - https://jsonapi.org/format/#crud-creating-responses-404 - /// - https://jsonapi.org/format/#crud-updating-responses-404 - /// - https://jsonapi.org/format/#crud-deleting-responses-404 - ErrorResponse.notFound(Iterable errors) : this(404, errors); - - /// HTTP 405 Method Not Allowed response. - /// The allowed methods can be specified in [allow] - ErrorResponse.methodNotAllowed( - Iterable errors, Iterable allow) - : this(405, errors, headers: {'Allow': allow.join(', ')}); - - /// HTTP 409 Conflict response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-responses-409 - /// - https://jsonapi.org/format/#crud-updating-responses-409 - ErrorResponse.conflict(Iterable errors) : this(409, errors); - - /// HTTP 500 Internal Server Error response. - ErrorResponse.internalServerError(Iterable errors) - : this(500, errors); - - /// HTTP 501 Not Implemented response. - ErrorResponse.notImplemented(Iterable errors) - : this(501, errors); - - /// Error objects to send with the response - final List errors; - - /// HTTP status code - final int statusCode; - final Map _headers; - - @override - T convert(ResponseConverter converter) => - converter.error(errors, statusCode, _headers); -} - -/// HTTP 200 OK response containing an empty document. -/// -/// See: -/// - https://jsonapi.org/format/#crud-updating-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -/// - https://jsonapi.org/format/#crud-deleting-responses-200 -class MetaResponse implements JsonApiResponse { - MetaResponse(Map meta) : meta = Map.unmodifiable(meta); - - final Map meta; - - @override - T convert(ResponseConverter converter) => converter.meta(meta); -} - -/// A successful response containing a resource object. -/// -/// See: -/// - https://jsonapi.org/format/#fetching-resources-responses-200 -/// - https://jsonapi.org/format/#crud-updating-responses-200 -class ResourceResponse implements JsonApiResponse { - ResourceResponse(this.resource, {Iterable included}) - : included = included == null ? null : List.unmodifiable(included); - - final Resource resource; - - final List included; - - @override - T convert(ResponseConverter converter) => - converter.resource(resource, included: included); -} - -/// HTTP 201 Created response containing a newly created resource -/// -/// See: https://jsonapi.org/format/#crud-creating-responses-201 -class ResourceCreatedResponse implements JsonApiResponse { - ResourceCreatedResponse(this.resource); - - final Resource resource; - - @override - T convert(ResponseConverter converter) => - converter.resourceCreated(resource); -} - -/// HTTP 303 See Other response. -/// -/// See: https://jsonapi.org/recommendations/#asynchronous-processing -class SeeOtherResponse implements JsonApiResponse { - SeeOtherResponse(this.target); - - final ResourceTarget target; - - @override - T convert(ResponseConverter converter) => converter.seeOther(target); -} - -/// HTTP 200 OK response containing a to-may relationship. -/// -/// See: -/// - https://jsonapi.org/format/#fetching-relationships-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToManyResponse implements JsonApiResponse { - ToManyResponse(this.target, Iterable identifiers) - : identifiers = - identifiers == null ? null : List.unmodifiable(identifiers); - - final RelationshipTarget target; - final List identifiers; - - @override - T convert(ResponseConverter converter) => - converter.toMany(target, identifiers); -} - -/// HTTP 200 OK response containing a to-one relationship -/// -/// See: -/// - https://jsonapi.org/format/#fetching-relationships-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToOneResponse implements JsonApiResponse { - ToOneResponse(this.target, this.identifier); - - final RelationshipTarget target; - - final Identifier identifier; - - @override - T convert(ResponseConverter converter) => - converter.toOne(target, identifier); -} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index d5ca8477..364c2f65 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,71 +1,90 @@ import 'dart:async'; +import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/request_converter.dart'; +import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/resolvable.dart'; +import 'package:json_api/src/server/target.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { - JsonApiServer(this._controller, {RouteFactory routing}) - : _routing = routing ?? StandardRouting(); + JsonApiServer(this._controller, + {Routing routing, DocumentFactory documentFactory}) + : _routing = routing ?? StandardRouting(), + _doc = documentFactory ?? DocumentFactory(); - final RouteFactory _routing; - final Controller> _controller; + final Routing _routing; + final Controller _controller; + final DocumentFactory _doc; @override Future call(HttpRequest httpRequest) async { - JsonApiRequest jsonApiRequest; - JsonApiResponse jsonApiResponse; + final targetFactory = TargetFactory(); + _routing.match(httpRequest.uri, targetFactory); + final target = targetFactory.target; + + if (target == null) { + return _convert(ErrorResponse(404, [ + ErrorObject( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server', + ) + ])); + } + + final allowed = (target.allowedMethods + ['OPTIONS']).join(', '); + + if (httpRequest.isOptions) { + return HttpResponse(200, headers: { + 'Access-Control-Allow-Methods': allowed, + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Max-Age': '3600', + }); + } + + if (!target.allowedMethods.contains(httpRequest.method)) { + return HttpResponse(405, headers: {'Allow': allowed}); + } + try { - jsonApiRequest = RequestConverter().convert(httpRequest); + final controllerRequest = target.convertRequest(httpRequest); + return _convert(await controllerRequest.resolveBy(_controller)); } on FormatException catch (e) { - jsonApiResponse = ErrorResponse.badRequest([ + return _convert(ErrorResponse(400, [ ErrorObject( - status: '400', - title: 'Bad request', - detail: 'Invalid JSON. ${e.message}') - ]); + status: '400', + title: 'Bad Request', + detail: 'Invalid JSON. ${e.message}', + ) + ])); } on DocumentException catch (e) { - jsonApiResponse = ErrorResponse.badRequest([ - ErrorObject(status: '400', title: 'Bad request', detail: e.message) - ]); - } on MethodNotAllowedException catch (e) { - jsonApiResponse = ErrorResponse.methodNotAllowed([ + return _convert(ErrorResponse(400, [ ErrorObject( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${e.allow.join(', ')}') - ], e.allow); - } on UnmatchedUriException { - jsonApiResponse = ErrorResponse.notFound([ - ErrorObject( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ]); + status: '400', + title: 'Bad Request', + detail: e.message, + ) + ])); } on IncompleteRelationshipException { - jsonApiResponse = ErrorResponse.badRequest([ + return _convert(ErrorResponse(400, [ ErrorObject( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ]); + status: '400', + title: 'Bad Request', + detail: 'Incomplete relationship object', + ) + ])); } - jsonApiResponse ??= await jsonApiRequest.handleWith(_controller) ?? - ErrorResponse.internalServerError([ - ErrorObject( - status: '500', - title: 'Internal Server Error', - detail: 'Controller responded with null') - ]); + } - final links = StandardLinks(httpRequest.uri, _routing); - final documentFactory = DocumentFactory(links: links); - final responseFactory = HttpResponseConverter(documentFactory, _routing); - return jsonApiResponse.convert(responseFactory); + HttpResponse _convert(ControllerResponse r) { + return HttpResponse(r.status, body: jsonEncode(r.document(_doc)), headers: { + ...r.headers(_routing), + 'Access-Control-Allow-Origin': '*', + }); } } diff --git a/lib/src/server/links/links_factory.dart b/lib/src/server/links/links_factory.dart deleted file mode 100644 index 885d2b00..00000000 --- a/lib/src/server/links/links_factory.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Creates `links` objects for JSON:API documents -abstract class LinksFactory { - /// Links for a resource object (primary or related) - Map resource(); - - /// Links for a collection (primary or related) - Map collection(int total, Pagination pagination); - - /// Links for a newly created resource - Map createdResource(ResourceTarget target); - - /// Links for a standalone relationship - Map relationship(RelationshipTarget target); - - /// Links for a relationship inside a resource - Map resourceRelationship(RelationshipTarget target); -} diff --git a/lib/src/server/links/no_links.dart b/lib/src/server/links/no_links.dart deleted file mode 100644 index 688327bb..00000000 --- a/lib/src/server/links/no_links.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -class NoLinks implements LinksFactory { - const NoLinks(); - - @override - Map collection(int total, Pagination pagination) => const {}; - - @override - Map createdResource(ResourceTarget target) => const {}; - - @override - Map relationship(RelationshipTarget target) => const {}; - - @override - Map resource() => const {}; - - @override - Map resourceRelationship(RelationshipTarget target) => const {}; -} diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart deleted file mode 100644 index de48b7f2..00000000 --- a/lib/src/server/links/standard_links.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/links/links_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -class StandardLinks implements LinksFactory { - StandardLinks(this._requested, this._route); - - final Uri _requested; - final RouteFactory _route; - - @override - Map resource() => {'self': Link(_requested)}; - - @override - Map collection(int total, Pagination pagination) => - {'self': Link(_requested), ..._navigation(total, pagination)}; - - @override - Map createdResource(ResourceTarget target) => - {'self': Link(_route.resource(target.type, target.id))}; - - @override - Map relationship(RelationshipTarget target) => { - 'self': Link(_requested), - 'related': - Link(_route.related(target.type, target.id, target.relationship)) - }; - - @override - Map resourceRelationship(RelationshipTarget target) => { - 'self': Link( - _route.relationship(target.type, target.id, target.relationship)), - 'related': - Link(_route.related(target.type, target.id, target.relationship)) - }; - - Map _navigation(int total, Pagination pagination) { - final page = Page.fromQueryParameters(_requested.queryParametersAll); - - return ({ - 'first': pagination.first(), - 'last': pagination.last(total), - 'prev': pagination.prev(page), - 'next': pagination.next(page, total) - }..removeWhere((k, v) => v == null)) - .map((k, v) => MapEntry(k, Link(v.addToUri(_requested)))); - } -} diff --git a/lib/src/server/relationship_target.dart b/lib/src/server/relationship_target.dart deleted file mode 100644 index c5dd0e2d..00000000 --- a/lib/src/server/relationship_target.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/src/server/resource_target.dart'; - -class RelationshipTarget { - const RelationshipTarget(this.type, this.id, this.relationship); - - final String type; - final String id; - final String relationship; - - ResourceTarget get resource => ResourceTarget(type, id); -} diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index d96a77fe..3c057ba2 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/server/resource_target.dart'; +import 'package:json_api/src/server/collection.dart'; /// The Repository translates CRUD operations on resources to actual data /// manipulation. @@ -24,35 +24,24 @@ abstract class Repository { /// /// Throws [InvalidType] if the [resource] /// does not belong to the collection. - FutureOr create(String collection, Resource resource); + Future create(String collection, Resource resource); - /// Returns the resource by [target]. - FutureOr get(ResourceTarget target); + /// Returns the resource by [type] and [id]. + Future get(String type, String id); /// Updates the resource identified by [target]. /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. - FutureOr update(ResourceTarget target, Resource resource); + Future update(String type, String id, Resource resource); /// Deletes the resource identified by [target] - FutureOr delete(ResourceTarget target); + Future delete(String type, String id); /// Returns a collection of resources - FutureOr> getCollection(String collection, + Future> getCollection(String collection, {int limit, int offset, List sort}); } -/// A collection of elements (e.g. resources) returned by the server. -class Collection { - Collection(Iterable elements, [this.total]) - : elements = List.unmodifiable(elements); - - final List elements; - - /// Total count of the elements on the server. May be null. - final int total; -} - /// Thrown when the requested collection does not exist /// This exception should result in HTTP 404. class CollectionNotFound implements Exception { diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index f8f3e45c..09007e96 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,17 +1,16 @@ import 'dart:async'; import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; +import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/relationship_target.dart'; +import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/resource_target.dart'; /// An opinionated implementation of [Controller]. Translates JSON:API /// requests to [Repository] methods calls. -class RepositoryController implements Controller> { +class RepositoryController implements Controller { RepositoryController(this._repo, {Pagination pagination}) : _pagination = pagination ?? NoPagination(); @@ -19,167 +18,162 @@ class RepositoryController implements Controller> { final Pagination _pagination; @override - FutureOr addToRelationship( - RelationshipTarget target, Iterable identifiers) => + Future addToRelationship( + RelationshipRequest request, List identifiers) => _do(() async { - final original = await _repo.get(target.resource); - if (!original.toMany.containsKey(target.relationship)) { - return ErrorResponse.notFound([ + final original = await _repo.get(request.type, request.id); + if (!original.toMany.containsKey(request.relationship)) { + return ErrorResponse(404, [ ErrorObject( status: '404', title: 'Relationship not found', detail: - "There is no to-many relationship '${target.relationship}' in this resource") + "There is no to-many relationship '${request.relationship}' in this resource") ]); } final updated = await _repo.update( - target.resource, - Resource(target.type, target.id, toMany: { - target.relationship: { - ...original.toMany[target.relationship], + request.type, + request.id, + Resource(request.type, request.id, toMany: { + request.relationship: { + ...original.toMany[request.relationship], ...identifiers }.toList() })); - return ToManyResponse(target, updated.toMany[target.relationship]); + return request.toManyResponse(updated.toMany[request.relationship]); }); @override - FutureOr createResource(String type, Resource resource) => + Future createResource( + CollectionRequest request, Resource resource) => _do(() async { - final modified = await _repo.create(type, resource); - if (modified == null) return NoContentResponse(); - return ResourceCreatedResponse(modified); + final modified = await _repo.create(request.type, resource); + if (modified == null) { + return NoContentResponse(); + } + return request.resourceResponse(modified); }); @override - FutureOr deleteFromRelationship( - RelationshipTarget target, Iterable identifiers) => + Future deleteFromRelationship( + RelationshipRequest request, List identifiers) => _do(() async { - final original = await _repo.get(target.resource); + final original = await _repo.get(request.type, request.id); final updated = await _repo.update( - target.resource, - Resource(target.type, target.id, toMany: { - target.relationship: ({...original.toMany[target.relationship]} + request.type, + request.id, + Resource(request.type, request.id, toMany: { + request.relationship: ({...original.toMany[request.relationship]} ..removeAll(identifiers)) .toList() })); - return ToManyResponse(target, updated.toMany[target.relationship]); + return request.toManyResponse(updated.toMany[request.relationship]); }); @override - FutureOr deleteResource(ResourceTarget target) => + Future deleteResource(ResourceRequest request) => _do(() async { - await _repo.delete(target); + await _repo.delete(request.type, request.id); return NoContentResponse(); }); @override - FutureOr fetchCollection( - String type, Map> queryParameters) => + Future fetchCollection(CollectionRequest request) => _do(() async { - final sort = Sort.fromQueryParameters(queryParameters); - final include = Include.fromQueryParameters(queryParameters); - final page = Page.fromQueryParameters(queryParameters); - final limit = _pagination.limit(page); - final offset = _pagination.offset(page); + final limit = _pagination.limit(request.page); + final offset = _pagination.offset(request.page); - final c = await _repo.getCollection(type, - sort: sort.toList(), limit: limit, offset: offset); + final collection = await _repo.getCollection(request.type, + sort: request.sort.toList(), limit: limit, offset: offset); final resources = []; - for (final resource in c.elements) { - for (final path in include) { + for (final resource in collection.elements) { + for (final path in request.include) { resources.addAll(await _getRelated(resource, path.split('.'))); } } - - return CollectionResponse(c.elements, - total: c.total, included: include.isEmpty ? null : resources); + return request.collectionResponse(collection, + include: request.isCompound ? resources : null); }); @override - FutureOr fetchRelated(RelationshipTarget target, - Map> queryParameters) => + Future fetchRelated(RelatedRequest request) => _do(() async { - final resource = await _repo.get(target.resource); - if (resource.toOne.containsKey(target.relationship)) { - return ResourceResponse(await _repo.get(ResourceTarget.fromIdentifier( - resource.toOne[target.relationship]))); + final resource = await _repo.get(request.type, request.id); + if (resource.toOne.containsKey(request.relationship)) { + final i = resource.toOne[request.relationship]; + return request.resourceResponse(await _repo.get(i.type, i.id)); } - if (resource.toMany.containsKey(target.relationship)) { + if (resource.toMany.containsKey(request.relationship)) { final related = []; - for (final identifier in resource.toMany[target.relationship]) { - related.add( - await _repo.get(ResourceTarget.fromIdentifier(identifier))); + for (final identifier in resource.toMany[request.relationship]) { + related.add(await _repo.get(identifier.type, identifier.id)); } - return CollectionResponse(related); + return request.collectionResponse(Collection(related)); } - return _relationshipNotFound(target.relationship); + return ErrorResponse(404, _relationshipNotFound(request.relationship)); }); @override - FutureOr fetchRelationship(RelationshipTarget target, - Map> queryParameters) => + Future fetchRelationship(RelationshipRequest request) => _do(() async { - final resource = await _repo.get(target.resource); - if (resource.toOne.containsKey(target.relationship)) { - return ToOneResponse(target, resource.toOne[target.relationship]); + final resource = await _repo.get(request.type, request.id); + if (resource.toOne.containsKey(request.relationship)) { + return request.toOneResponse(resource.toOne[request.relationship]); } - if (resource.toMany.containsKey(target.relationship)) { - return ToManyResponse(target, resource.toMany[target.relationship]); + if (resource.toMany.containsKey(request.relationship)) { + return request.toManyResponse(resource.toMany[request.relationship]); } - return _relationshipNotFound(target.relationship); + return ErrorResponse(404, _relationshipNotFound(request.relationship)); }); @override - FutureOr fetchResource( - ResourceTarget target, Map> queryParameters) => + Future fetchResource(ResourceRequest request) => _do(() async { - final include = Include.fromQueryParameters(queryParameters); - final resource = await _repo.get(target); + final resource = await _repo.get(request.type, request.id); final resources = []; - for (final path in include) { + for (final path in request.include) { resources.addAll(await _getRelated(resource, path.split('.'))); } - return ResourceResponse(resource, - included: include.isEmpty ? null : resources); + return request.resourceResponse(resource, + include: request.isCompound ? resources : null); }); @override - FutureOr replaceToMany( - RelationshipTarget target, Iterable identifiers) => + Future replaceToMany( + RelationshipRequest request, List identifiers) => _do(() async { await _repo.update( - target.resource, - Resource(target.type, target.id, - toMany: {target.relationship: identifiers})); + request.type, + request.id, + Resource(request.type, request.id, + toMany: {request.relationship: identifiers})); return NoContentResponse(); }); @override - FutureOr updateResource( - ResourceTarget target, Resource resource) => + Future updateResource( + ResourceRequest request, Resource resource) => _do(() async { - final modified = await _repo.update(target, resource); - if (modified == null) return NoContentResponse(); - return ResourceResponse(modified); + final modified = await _repo.update(request.type, request.id, resource); + if (modified == null) { + return NoContentResponse(); + } + return request.resourceResponse(modified); }); @override - FutureOr replaceToOne( - RelationshipTarget target, Identifier identifier) => + Future replaceToOne( + RelationshipRequest request, Identifier identifier) => _do(() async { await _repo.update( - target.resource, - Resource(target.type, target.id, - toOne: {target.relationship: identifier})); + request.type, + request.id, + Resource(request.type, request.id, + toOne: {request.relationship: identifier})); return NoContentResponse(); }); - @override - FutureOr deleteToOne(RelationshipTarget target) => - replaceToOne(target, null); - Future> _getRelated( Resource resource, Iterable path, @@ -194,7 +188,7 @@ class RepositoryController implements Controller> { ids.addAll(resource.toMany[path.first]); } for (final id in ids) { - final r = await _repo.get(ResourceTarget.fromIdentifier(id)); + final r = await _repo.get(id.type, id.id); if (path.length > 1) { resources.addAll(await _getRelated(r, path.skip(1))); } else { @@ -208,44 +202,42 @@ class RepositoryController implements Controller> { Map.fromIterable(included, key: (_) => '${_.type}:${_.id}').values; - FutureOr _do( - FutureOr Function() action) async { + Future _do( + Future Function() action) async { try { return await action(); } on UnsupportedOperation catch (e) { - return ErrorResponse.forbidden([ + return ErrorResponse(403, [ ErrorObject( status: '403', title: 'Unsupported operation', detail: e.message) ]); } on CollectionNotFound catch (e) { - return ErrorResponse.notFound([ + return ErrorResponse(404, [ ErrorObject( status: '404', title: 'Collection not found', detail: e.message) ]); } on ResourceNotFound catch (e) { - return ErrorResponse.notFound([ + return ErrorResponse(404, [ ErrorObject( status: '404', title: 'Resource not found', detail: e.message) ]); } on InvalidType catch (e) { - return ErrorResponse.conflict([ + return ErrorResponse(409, [ ErrorObject( status: '409', title: 'Invalid resource type', detail: e.message) ]); } on ResourceExists catch (e) { - return ErrorResponse.conflict([ + return ErrorResponse(409, [ ErrorObject(status: '409', title: 'Resource exists', detail: e.message) ]); } } - JsonApiResponse _relationshipNotFound(String relationship) { - return ErrorResponse.notFound([ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "Relationship '$relationship' does not exist in this resource") - ]); - } + List _relationshipNotFound(String relationship) => [ + ErrorObject( + status: '404', + title: 'Relationship not found', + detail: + "Relationship '$relationship' does not exist in this resource") + ]; } diff --git a/lib/src/server/request_converter.dart b/lib/src/server/request_converter.dart deleted file mode 100644 index 32d853ed..00000000 --- a/lib/src/server/request_converter.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Converts HTTP requests to JSON:API requests -class RequestConverter { - RequestConverter({RouteMatcher routeMatcher}) - : _matcher = routeMatcher ?? StandardRouting(); - final RouteMatcher _matcher; - - /// Creates a [JsonApiRequest] from [httpRequest] - JsonApiRequest convert(HttpRequest httpRequest) { - String type; - String id; - String rel; - - void setType(String t) { - type = t; - } - - void setTypeId(String t, String i) { - type = t; - id = i; - } - - void setTypeIdRel(String t, String i, String r) { - type = t; - id = i; - rel = r; - } - - final uri = httpRequest.uri; - if (_matcher.matchCollection(uri, setType)) { - switch (httpRequest.method) { - case 'GET': - return FetchCollection(uri.queryParametersAll, type); - case 'POST': - return CreateResource(type, - ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['GET', 'POST']); - } - } else if (_matcher.matchResource(uri, setTypeId)) { - final target = ResourceTarget(type, id); - switch (httpRequest.method) { - case 'DELETE': - return DeleteResource(target); - case 'GET': - return FetchResource(target, uri.queryParametersAll); - case 'PATCH': - return UpdateResource(target, - ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH']); - } - } else if (_matcher.matchRelated(uri, setTypeIdRel)) { - switch (httpRequest.method) { - case 'GET': - return FetchRelated( - RelationshipTarget(type, id, rel), uri.queryParametersAll); - default: - throw MethodNotAllowedException(['GET']); - } - } else if (_matcher.matchRelationship(uri, setTypeIdRel)) { - final target = RelationshipTarget(type, id, rel); - switch (httpRequest.method) { - case 'DELETE': - return DeleteFromRelationship( - target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); - case 'GET': - return FetchRelationship(target, uri.queryParametersAll); - case 'PATCH': - final r = Relationship.fromJson(jsonDecode(httpRequest.body)); - if (r is ToOne) { - final identifier = r.unwrap(); - if (identifier != null) { - return ReplaceToOne(target, identifier); - } - return DeleteToOne(target); - } - if (r is ToMany) { - return ReplaceToMany(target, r.unwrap()); - } - throw IncompleteRelationshipException(); - case 'POST': - return AddToRelationship( - target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH', 'POST']); - } - } - throw UnmatchedUriException(); - } -} - -class RequestFactoryException implements Exception {} - -/// Thrown if HTTP method is not allowed for the given route -class MethodNotAllowedException implements RequestFactoryException { - MethodNotAllowedException(Iterable allow) - : allow = List.unmodifiable(allow ?? const []); - - /// List of allowed methods - final List allow; -} - -/// Thrown if the request URI can not be matched to a target -class UnmatchedUriException implements RequestFactoryException {} - -/// Thrown if the relationship object has no data -class IncompleteRelationshipException implements RequestFactoryException {} diff --git a/lib/src/server/resolvable.dart b/lib/src/server/resolvable.dart new file mode 100644 index 00000000..8c761db1 --- /dev/null +++ b/lib/src/server/resolvable.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/controller_response.dart'; + +abstract class Resolvable { + Future resolveBy(Controller controller); +} + +class FetchCollection implements Resolvable { + FetchCollection(this.request); + + final CollectionRequest request; + + @override + Future resolveBy(Controller controller) => + controller.fetchCollection(request); +} + +class CreateResource implements Resolvable { + CreateResource(this.request); + + final CollectionRequest request; + + @override + Future resolveBy(Controller controller) => + controller.createResource(request, + ResourceData.fromJson(jsonDecode(request.request.body)).unwrap()); +} + +class FetchResource implements Resolvable { + FetchResource(this.request); + + final ResourceRequest request; + + @override + Future resolveBy(Controller controller) => + controller.fetchResource(request); +} + +class DeleteResource implements Resolvable { + DeleteResource(this.request); + + final ResourceRequest request; + + @override + Future resolveBy(Controller controller) => + controller.deleteResource(request); +} + +class UpdateResource implements Resolvable { + UpdateResource(this.request); + + final ResourceRequest request; + + @override + Future resolveBy(Controller controller) => + controller.updateResource(request, + ResourceData.fromJson(jsonDecode(request.request.body)).unwrap()); +} + +class FetchRelated implements Resolvable { + FetchRelated(this.request); + + final RelatedRequest request; + + @override + Future resolveBy(Controller controller) => + controller.fetchRelated(request); +} + +class FetchRelationship implements Resolvable { + FetchRelationship(this.request); + + final RelationshipRequest request; + + @override + Future resolveBy(Controller controller) => + controller.fetchRelationship(request); +} + +class DeleteFromRelationship implements Resolvable { + DeleteFromRelationship(this.request); + + final RelationshipRequest request; + + @override + Future resolveBy(Controller controller) => + controller.deleteFromRelationship( + request, ToMany.fromJson(jsonDecode(request.request.body)).unwrap()); +} + +class AddToRelationship implements Resolvable { + AddToRelationship(this.request); + + final RelationshipRequest request; + + @override + Future resolveBy(Controller controller) => + controller.addToRelationship( + request, ToMany.fromJson(jsonDecode(request.request.body)).unwrap()); +} + +class ReplaceRelationship implements Resolvable { + ReplaceRelationship(this.request); + + final RelationshipRequest request; + + @override + Future resolveBy(Controller controller) async { + final r = Relationship.fromJson(jsonDecode(request.request.body)); + if (r is ToOne) { + return controller.replaceToOne(request, r.unwrap()); + } + if (r is ToMany) { + return controller.replaceToMany(request, r.unwrap()); + } + throw IncompleteRelationshipException(); + } +} + +/// Thrown if the relationship object has no data +class IncompleteRelationshipException implements Exception {} diff --git a/lib/src/server/resource_target.dart b/lib/src/server/resource_target.dart deleted file mode 100644 index e4aa956d..00000000 --- a/lib/src/server/resource_target.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/document.dart'; - -class ResourceTarget { - const ResourceTarget(this.type, this.id); - - static ResourceTarget fromIdentifier(Identifier identifier) => - ResourceTarget(identifier.type, identifier.id); - - final String type; - - final String id; -} diff --git a/lib/src/server/response_converter.dart b/lib/src/server/response_converter.dart deleted file mode 100644 index e0781fce..00000000 --- a/lib/src/server/response_converter.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Converts JsonApi Controller responses to other responses, e.g. HTTP -abstract class ResponseConverter { - /// A common error response. - /// - /// See: https://jsonapi.org/format/#errors - T error(Iterable errors, int statusCode, - Map headers); - - /// HTTP 200 OK response with a resource collection. - /// - /// See: https://jsonapi.org/format/#fetching-resources-responses-200 - T collection(Iterable resources, - {int total, Iterable included, Pagination pagination}); - - /// HTTP 202 Accepted response. - /// - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - T accepted(Resource resource); - - /// HTTP 200 OK response containing an empty document. - /// - /// See: - /// - https://jsonapi.org/format/#crud-updating-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - /// - https://jsonapi.org/format/#crud-deleting-responses-200 - T meta(Map meta); - - /// A successful response containing a resource object. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-resources-responses-200 - /// - https://jsonapi.org/format/#crud-updating-responses-200 - T resource(Resource resource, {Iterable included}); - - /// HTTP 200 with a document containing a single (primary) resource which has been created - /// on the server. The difference with [resource] is that this - /// method generates the `self` link to match the `location` header. - /// - /// This is the quote from the documentation: - /// > If the resource object returned by the response contains a self key - /// > in its links member and a Location header is provided, the value of - /// > the self member MUST match the value of the Location header. - /// - /// See https://jsonapi.org/format/#crud-creating-responses-201 - T resourceCreated(Resource resource); - - /// HTTP 303 See Other response. - /// - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - T seeOther(ResourceTarget target); - - /// HTTP 200 OK response containing a to-may relationship. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-relationships-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toMany(RelationshipTarget target, Iterable identifiers, - {Iterable included}); - - /// HTTP 200 OK response containing a to-one relationship - /// - /// See: - /// - https://jsonapi.org/format/#fetching-relationships-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}); - - /// HTTP 204 No Content response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-responses-204 - /// - https://jsonapi.org/format/#crud-updating-responses-204 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 - /// - https://jsonapi.org/format/#crud-deleting-responses-204 - T noContent(); -} diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart new file mode 100644 index 00000000..2588292f --- /dev/null +++ b/lib/src/server/target.dart @@ -0,0 +1,129 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/resolvable.dart'; + +abstract class Target { + List get allowedMethods; + + Resolvable convertRequest(HttpRequest request); +} + +class CollectionTarget implements Target { + CollectionTarget(this.type); + + final String type; + + @override + final allowedMethods = ['GET', 'POST']; + + @override + Resolvable convertRequest(HttpRequest request) { + if (request.isGet) { + return FetchCollection(CollectionRequest(request, type)); + } + if (request.isPost) { + return CreateResource(CollectionRequest(request, type)); + } + throw ArgumentError(); + } +} + +class ResourceTarget implements Target { + ResourceTarget(this.type, this.id); + + final String type; + final String id; + + @override + final allowedMethods = ['DELETE', 'GET', 'PATCH']; + + @override + Resolvable convertRequest(HttpRequest request) { + if (request.isDelete) { + return DeleteResource(ResourceRequest(request, type, id)); + } + if (request.isGet) { + return FetchResource(ResourceRequest(request, type, id)); + } + if (request.isPatch) { + return UpdateResource(ResourceRequest(request, type, id)); + } + throw ArgumentError(); + } +} + +class RelatedTarget implements Target { + RelatedTarget(this.type, this.id, this.relationship); + + final String type; + final String id; + final String relationship; + + @override + final allowedMethods = ['GET']; + + @override + Resolvable convertRequest(HttpRequest request) { + if (request.isGet) { + return FetchRelated(RelatedRequest(request, type, id, relationship)); + } + throw ArgumentError(); + } +} + +class RelationshipTarget implements Target { + RelationshipTarget(this.type, this.id, this.relationship); + + final String type; + final String id; + final String relationship; + + @override + final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; + + @override + Resolvable convertRequest(HttpRequest request) { + if (request.isDelete) { + return DeleteFromRelationship( + RelationshipRequest(request, type, id, relationship)); + } + if (request.isGet) { + return FetchRelationship( + RelationshipRequest(request, type, id, relationship)); + } + if (request.isPatch) { + return ReplaceRelationship( + RelationshipRequest(request, type, id, relationship)); + } + if (request.isPost) { + return AddToRelationship( + RelationshipRequest(request, type, id, relationship)); + } + throw ArgumentError(); + } +} + +class TargetFactory implements MatchHandler { + Target target; + + @override + void collection(String type) { + target = CollectionTarget(type); + } + + @override + void related(String type, String id, String relationship) { + target = RelatedTarget(type, id, relationship); + } + + @override + void relationship(String type, String id, String relationship) { + target = RelationshipTarget(type, id, relationship); + } + + @override + void resource(String type, String id) { + target = ResourceTarget(type, id); + } +} diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart new file mode 100644 index 00000000..04d1da47 --- /dev/null +++ b/test/e2e/browser_test.dart @@ -0,0 +1,46 @@ +import 'package:http/http.dart'; +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +void main() async { + final port = 8081; + final host = 'localhost'; + final routing = StandardRouting(Uri(host: host, port: port, scheme: 'http')); + Client httpClient; + + setUp(() { + httpClient = Client(); + }); + + tearDown(() { + httpClient.close(); + }); + + test('can fetch collection', () async { + final channel = spawnHybridUri('hybrid_server.dart', message: port); + await channel.stream.first; + + final client = RoutingClient(JsonApiClient(DartHttp(httpClient)), routing); + + final writer = + Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); + final book = Resource('books', '2', attributes: {'title': 'Refactoring'}); + + await client.createResource(writer); + await client.createResource(book); + await client + .updateResource(Resource('books', '2', toMany: {'authors': []})); + await client.addToRelationship( + 'books', '2', 'authors', [Identifier('writers', '1')]); + + final response = await client.fetchResource('books', '2', + parameters: Include(['authors'])); + + expect(response.data.unwrap().attributes['title'], 'Refactoring'); + expect(response.data.included.first.unwrap().attributes['name'], + 'Martin Fowler'); + }, testOn: 'browser'); +} diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart new file mode 100644 index 00000000..b82d30cb --- /dev/null +++ b/test/e2e/hybrid_server.dart @@ -0,0 +1,14 @@ +import 'dart:io'; + +import 'package:json_api/server.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:stream_channel/stream_channel.dart'; + +void hybridMain(StreamChannel channel, Object port) async { + final repo = InMemoryRepository({'writers': {}, 'books': {}}); + final jsonApiServer = JsonApiServer(RepositoryController(repo)); + final serverHandler = DartServer(jsonApiServer); + final server = await HttpServer.bind('localhost', port); + unawaited(server.forEach(serverHandler)); + channel.sink.add('ready'); +} \ No newline at end of file diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 4c7579b8..740cf72f 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -26,6 +26,7 @@ void main() async { NewResource('people', attributes: {'name': 'Martin Fowler'}); final r = await routingClient.createResource(person); expect(r.statusCode, 201); + expect(r.headers['content-type'], Document.contentType); expect(r.location, isNotNull); expect(r.location, r.data.links['self'].uri); final created = r.data.unwrap(); @@ -89,6 +90,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -103,6 +105,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -118,6 +121,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -131,6 +135,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 409); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '409'); @@ -145,6 +150,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 409); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '409'); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 9b99fa21..ff6b76dc 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -1,4 +1,5 @@ import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -36,6 +37,7 @@ void main() async { final r1 = await routingClient.fetchResource('books', '1'); expect(r1.isSuccessful, isFalse); expect(r1.statusCode, 404); + expect(r1.headers['content-type'], Document.contentType); }); test('404 on collecton', () async { @@ -43,6 +45,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -55,6 +58,7 @@ void main() async { expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 31d142fe..1fd60b60 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -32,6 +32,7 @@ void main() async { final r = await routingClient.fetchToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().type, 'companies'); expect(r.data.unwrap().id, '1'); }); @@ -40,6 +41,7 @@ void main() async { final r = await routingClient.fetchToOne('unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -49,6 +51,7 @@ void main() async { final r = await routingClient.fetchToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); @@ -58,6 +61,7 @@ void main() async { final r = await routingClient.fetchToOne('books', '1', 'owner'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, @@ -70,6 +74,7 @@ void main() async { final r = await routingClient.fetchToMany('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.type, 'people'); }); @@ -78,6 +83,7 @@ void main() async { final r = await routingClient.fetchToMany('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -87,6 +93,7 @@ void main() async { final r = await routingClient.fetchToMany('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); @@ -96,6 +103,7 @@ void main() async { final r = await routingClient.fetchToMany('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, @@ -109,6 +117,7 @@ void main() async { await routingClient.fetchRelationship('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); final rel = r.data; if (rel is ToOne) { expect(rel.unwrap().type, 'companies'); @@ -122,6 +131,7 @@ void main() async { final r = await routingClient.fetchRelationship('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); final rel = r.data; if (rel is ToMany) { expect(rel.unwrap().length, 2); @@ -139,6 +149,7 @@ void main() async { await routingClient.fetchRelationship('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -148,6 +159,7 @@ void main() async { final r = await routingClient.fetchRelationship('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); @@ -157,6 +169,7 @@ void main() async { final r = await routingClient.fetchRelationship('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index c547d249..95b83c09 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -1,4 +1,5 @@ import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -32,6 +33,7 @@ void main() async { final r = await routingClient.fetchResource('people', '1'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().id, '1'); expect(r.data.unwrap().attributes['name'], 'Martin Fowler'); }); @@ -40,6 +42,7 @@ void main() async { final r = await routingClient.fetchResource('unicorns', '1'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -49,6 +52,7 @@ void main() async { final r = await routingClient.fetchResource('people', '42'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'people'"); @@ -60,6 +64,7 @@ void main() async { final r = await routingClient.fetchCollection('people'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 3); expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); }); @@ -68,6 +73,7 @@ void main() async { final r = await routingClient.fetchCollection('unicorns'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -80,6 +86,7 @@ void main() async { await routingClient.fetchRelatedResource('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().type, 'companies'); expect(r.data.unwrap().id, '1'); }); @@ -89,6 +96,7 @@ void main() async { 'unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -99,6 +107,7 @@ void main() async { await routingClient.fetchRelatedResource('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); @@ -108,6 +117,7 @@ void main() async { final r = await routingClient.fetchRelatedResource('books', '1', 'owner'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, @@ -121,6 +131,7 @@ void main() async { await routingClient.fetchRelatedCollection('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); }); @@ -130,6 +141,7 @@ void main() async { await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Collection not found'); expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -140,6 +152,7 @@ void main() async { await routingClient.fetchRelatedCollection('books', '42', 'authors'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Resource not found'); expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); @@ -150,6 +163,7 @@ void main() async { await routingClient.fetchRelatedCollection('books', '1', 'readers'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.errors.first.status, '404'); expect(r.errors.first.title, 'Relationship not found'); expect(r.errors.first.detail, diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index fd051102..48568edb 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -45,6 +45,7 @@ void main() async { 'unicorns', '1', 'breed', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -57,6 +58,7 @@ void main() async { 'books', '42', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -80,6 +82,7 @@ void main() async { final r = await routingClient.deleteToOne('unicorns', '1', 'breed'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -91,6 +94,7 @@ void main() async { final r = await routingClient.deleteToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -117,6 +121,7 @@ void main() async { 'unicorns', '1', 'breed', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -129,6 +134,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -143,6 +149,7 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 3); expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '3'); @@ -156,12 +163,14 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '2'); final r1 = await routingClient.fetchResource('books', '1'); expect(r1.data.unwrap().toMany['authors'].length, 2); + expect(r1.headers['content-type'], Document.contentType); }); test('404 when collection not found', () async { @@ -169,6 +178,7 @@ void main() async { 'unicorns', '1', 'breed', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -181,6 +191,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -193,6 +204,7 @@ void main() async { 'books', '1', 'sellers', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -208,6 +220,7 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 1); expect(r.data.unwrap().first.id, '2'); @@ -220,6 +233,7 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.id, '1'); expect(r.data.unwrap().last.id, '2'); @@ -233,6 +247,7 @@ void main() async { 'unicorns', '1', 'breed', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -245,6 +260,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index efaf684e..dd055d4a 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -42,6 +42,7 @@ void main() async { })); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); + expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().attributes['title'], 'Refactoring. Improving the Design of Existing Code'); expect(r.data.unwrap().attributes['pages'], 448); @@ -67,6 +68,7 @@ void main() async { final r = await routingClient.updateResource(Resource('books', '42')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 404); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '404'); @@ -79,6 +81,7 @@ void main() async { routing.resource('people', '1'), Resource('books', '1')); expect(r.isSuccessful, isFalse); expect(r.statusCode, 409); + expect(r.headers['content-type'], Document.contentType); expect(r.data, isNull); final error = r.errors.first; expect(error.status, '409'); diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart index e0b376d0..9d209620 100644 --- a/test/unit/client/async_processing_test.dart +++ b/test/unit/client/async_processing_test.dart @@ -1,7 +1,5 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/resource.dart'; import 'package:test/test.dart'; import '../../helper/test_http_handler.dart'; @@ -12,17 +10,15 @@ void main() { final routing = StandardRouting(); test('Client understands async responses', () async { - final links = StandardLinks(Uri.parse('/books'), routing); - final responseFactory = - HttpResponseConverter(DocumentFactory(links: links), routing); - handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); - - final r = await client.createResource(Resource('books', '1')); - expect(r.isAsync, true); - expect(r.isSuccessful, false); - expect(r.isFailed, false); - expect(r.asyncData.unwrap().type, 'jobs'); - expect(r.asyncData.unwrap().id, '42'); - expect(r.contentLocation.toString(), '/jobs/42'); +// final responseFactory = HttpResponseConverter(Uri.parse('/books'), routing); +// handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); +// +// final r = await client.createResource(Resource('books', '1')); +// expect(r.isAsync, true); +// expect(r.isSuccessful, false); +// expect(r.isFailed, false); +// expect(r.asyncData.unwrap().type, 'jobs'); +// expect(r.asyncData.unwrap().id, '42'); +// expect(r.contentLocation.toString(), '/jobs/42'); }); } diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index d1903794..b391a97e 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -17,9 +17,10 @@ void main() { body: '{}'); final rs = await server(rq); expect(rs.statusCode, 400); + expect(rs.headers['content-type'], Document.contentType); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); - expect(error.title, 'Bad request'); + expect(error.title, 'Bad Request'); expect(error.detail, 'Incomplete relationship object'); }); @@ -30,7 +31,7 @@ void main() { expect(rs.statusCode, 400); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); - expect(error.title, 'Bad request'); + expect(error.title, 'Bad Request'); expect(error.detail, startsWith('Invalid JSON. ')); }); @@ -40,9 +41,10 @@ void main() { HttpRequest('POST', routing.collection('books'), body: '"oops"'); final rs = await server(rq); expect(rs.statusCode, 400); + expect(rs.headers['content-type'], Document.contentType); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); - expect(error.title, 'Bad request'); + expect(error.title, 'Bad Request'); expect(error.detail, "A JSON:API resource document must be a JSON object and contain the 'data' member"); }); @@ -52,9 +54,10 @@ void main() { body: '{"data": {}}'); final rs = await server(rq); expect(rs.statusCode, 400); + expect(rs.headers['content-type'], Document.contentType); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '400'); - expect(error.title, 'Bad request'); + expect(error.title, 'Bad Request'); expect(error.detail, 'Invalid JSON:API resource object'); }); @@ -62,6 +65,7 @@ void main() { final rq = HttpRequest('GET', Uri.parse('http://localhost/a/b/c/d/e')); final rs = await server(rq); expect(rs.statusCode, 404); + expect(rs.headers['content-type'], Document.contentType); final error = Document.fromJson(json.decode(rs.body), null).errors.first; expect(error.status, '404'); expect(error.title, 'Not Found'); @@ -72,33 +76,21 @@ void main() { final rq = HttpRequest('DELETE', routing.collection('books')); final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET, POST'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: GET, POST'); + expect(rs.headers['allow'], 'GET, POST, OPTIONS'); }); test('returns `method not allowed` for resource', () async { final rq = HttpRequest('POST', routing.resource('books', '1')); final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: DELETE, GET, PATCH'); + expect(rs.headers['allow'], 'DELETE, GET, PATCH, OPTIONS'); }); test('returns `method not allowed` for related', () async { final rq = HttpRequest('POST', routing.related('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: GET'); + expect(rs.headers['allow'], 'GET, OPTIONS'); }); test('returns `method not allowed` for relationship', () async { @@ -106,11 +98,7 @@ void main() { HttpRequest('PUT', routing.relationship('books', '1', 'author')); final rs = await server(rq); expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: DELETE, GET, PATCH, POST'); + expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST, OPTIONS'); }); }); } From c3ca7c6f5c2295975f77d13ec18a7d7eceec07bd Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 5 Apr 2020 18:58:43 -0700 Subject: [PATCH 46/99] WIP --- lib/src/document/resource_data.dart | 3 +-- pubspec.yaml | 2 -- test/e2e/hybrid_server.dart | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index a318b83a..50be4a19 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -9,8 +9,7 @@ import 'package:json_api/src/nullable.dart'; class ResourceData extends PrimaryData { ResourceData(this.resourceObject, {Iterable include, Map links}) - : super( - included: include, links: {...?resourceObject?.links, ...?links}); + : super(included: include, links: {...?resourceObject?.links, ...?links}); static ResourceData fromResource(Resource resource) => ResourceData(ResourceObject.fromResource(resource)); diff --git a/pubspec.yaml b/pubspec.yaml index 916e2b26..d56e1b20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,11 +7,9 @@ environment: dependencies: http: ^0.12.0 dev_dependencies: - args: ^1.5.2 pedantic: ^1.9.0 test: ^1.9.2 json_matcher: ^0.2.3 stream_channel: ^2.0.0 uuid: ^2.0.1 test_coverage: ^0.4.0 - shelf: ^0.7.5 diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index b82d30cb..6b36df36 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -11,4 +11,4 @@ void hybridMain(StreamChannel channel, Object port) async { final server = await HttpServer.bind('localhost', port); unawaited(server.forEach(serverHandler)); channel.sink.add('ready'); -} \ No newline at end of file +} From a9fd6903cfd8d99abfa40fa4cccfe20b6c1b8d97 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 7 Apr 2020 21:57:37 -0700 Subject: [PATCH 47/99] WIP --- lib/routing.dart | 8 +- lib/src/client/routing_client.dart | 2 +- lib/src/routing/composite_routing.dart | 36 ++-- lib/src/routing/contract.dart | 77 +++++++ lib/src/routing/route_factory.dart | 18 -- lib/src/routing/route_matcher.dart | 19 -- lib/src/routing/routes.dart | 37 ---- lib/src/routing/routing.dart | 3 - .../{standard_routes.dart => standard.dart} | 28 ++- lib/src/routing/standard_routing.dart | 9 - lib/src/server/controller.dart | 2 +- lib/src/server/controller_request.dart | 83 -------- lib/src/server/controller_response.dart | 200 +++++++++++++----- lib/src/server/json_api_server.dart | 28 +-- lib/src/server/repository.dart | 4 +- lib/src/server/repository_controller.dart | 82 +++---- lib/src/server/request.dart | 99 +++++++++ ...esolvable.dart => resolvable_request.dart} | 40 ++-- lib/src/server/route.dart | 138 ++++++++++++ lib/src/server/target.dart | 130 +----------- pubspec.yaml | 1 + test/functional/compound_document_test.dart | 1 + .../crud/fetching_relationships_test.dart | 4 + .../crud/fetching_resources_test.dart | 25 ++- 24 files changed, 607 insertions(+), 467 deletions(-) create mode 100644 lib/src/routing/contract.dart delete mode 100644 lib/src/routing/route_factory.dart delete mode 100644 lib/src/routing/route_matcher.dart delete mode 100644 lib/src/routing/routes.dart delete mode 100644 lib/src/routing/routing.dart rename lib/src/routing/{standard_routes.dart => standard.dart} (72%) delete mode 100644 lib/src/routing/standard_routing.dart delete mode 100644 lib/src/server/controller_request.dart create mode 100644 lib/src/server/request.dart rename lib/src/server/{resolvable.dart => resolvable_request.dart} (70%) create mode 100644 lib/src/server/route.dart diff --git a/lib/routing.dart b/lib/routing.dart index 35f7e54d..67705cc6 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,7 +1,3 @@ export 'package:json_api/src/routing/composite_routing.dart'; -export 'package:json_api/src/routing/route_factory.dart'; -export 'package:json_api/src/routing/route_matcher.dart'; -export 'package:json_api/src/routing/routes.dart'; -export 'package:json_api/src/routing/routing.dart'; -export 'package:json_api/src/routing/standard_routes.dart'; -export 'package:json_api/src/routing/standard_routing.dart'; +export 'package:json_api/src/routing/contract.dart'; +export 'package:json_api/src/routing/standard.dart'; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 819dc283..aaab1f97 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -11,7 +11,7 @@ class RoutingClient { RoutingClient(this._client, this._routes); final JsonApiClient _client; - final RouteFactory _routes; + final UriFactory _routes; /// Fetches a primary resource collection by [type]. Future> fetchCollection(String type, diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart index c747638d..5a5b3cb4 100644 --- a/lib/src/routing/composite_routing.dart +++ b/lib/src/routing/composite_routing.dart @@ -1,35 +1,33 @@ -import 'package:json_api/src/routing/route_matcher.dart'; -import 'package:json_api/src/routing/routes.dart'; -import 'package:json_api/src/routing/routing.dart'; +import 'package:json_api/src/routing/contract.dart'; -/// URI design composed of independent routes. +/// URI design composed of independent URI patterns. class CompositeRouting implements Routing { - CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, - this.relationshipRoute); + CompositeRouting( + this._collection, this._resource, this._related, this._relationship); - final CollectionRoute collectionRoute; - final ResourceRoute resourceRoute; - final RelatedRoute relatedRoute; - final RelationshipRoute relationshipRoute; + final CollectionUriPattern _collection; + final ResourceUriPattern _resource; + final RelatedUriPattern _related; + final RelationshipUriPattern _relationship; @override - Uri collection(String type) => collectionRoute.uri(type); + Uri collection(String type) => _collection.uri(type); @override Uri related(String type, String id, String relationship) => - relatedRoute.uri(type, id, relationship); + _related.uri(type, id, relationship); @override Uri relationship(String type, String id, String relationship) => - relationshipRoute.uri(type, id, relationship); + _relationship.uri(type, id, relationship); @override - Uri resource(String type, String id) => resourceRoute.uri(type, id); + Uri resource(String type, String id) => _resource.uri(type, id); @override - bool match(Uri uri, MatchHandler handler) => - collectionRoute.match(uri, handler.collection) || - resourceRoute.match(uri, handler.resource) || - relatedRoute.match(uri, handler.related) || - relationshipRoute.match(uri, handler.relationship); + bool match(Uri uri, UriMatchHandler handler) => + _collection.match(uri, handler.collection) || + _resource.match(uri, handler.resource) || + _related.match(uri, handler.related) || + _relationship.match(uri, handler.relationship); } diff --git a/lib/src/routing/contract.dart b/lib/src/routing/contract.dart new file mode 100644 index 00000000..f4aedd9c --- /dev/null +++ b/lib/src/routing/contract.dart @@ -0,0 +1,77 @@ +/// Makes URIs for specific targets +abstract class UriFactory { + /// 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); +} + +abstract class CollectionUriPattern { + /// Returns the URI for a collection of type [type]. + Uri uri(String type); + + /// Matches the [uri] with a collection URI pattern. + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type) onMatch); +} + +abstract class RelationshipUriPattern { + Uri uri(String type, String id, String relationship); + + /// Matches the [uri] with a relationship URI pattern. + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id, String rel) onMatch); +} + +abstract class RelatedUriPattern { + Uri uri(String type, String id, String relationship); + + /// Matches the [uri] with a related URI pattern. + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id, String rel) onMatch); +} + +abstract class ResourceUriPattern { + Uri uri(String type, String id); + + /// Matches the [uri] with a resource URI pattern. + /// If the match is successful, calls [onMatch]. + /// Returns true if the match was successful. + bool match(Uri uri, Function(String type, String id) onMatch); +} + +/// Matches the URI with URI Design patterns. +/// +/// See https://jsonapi.org/recommendations/#urls +abstract class UriPatternMatcher { + /// Matches the [uri] with route patterns. + /// If there is a match, calls the corresponding method of the [handler]. + /// Returns true if match was found. + bool match(Uri uri, UriMatchHandler handler); +} + +abstract class UriMatchHandler { + void collection(String type); + + void resource(String type, String id); + + void related(String type, String id, String relationship); + + void relationship(String type, String id, String relationship); +} + +abstract class Routing implements UriFactory, UriPatternMatcher {} diff --git a/lib/src/routing/route_factory.dart b/lib/src/routing/route_factory.dart deleted file mode 100644 index 78e79f81..00000000 --- a/lib/src/routing/route_factory.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// Makes URIs for specific targets -abstract class RouteFactory { - /// 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/routing/route_matcher.dart b/lib/src/routing/route_matcher.dart deleted file mode 100644 index 87d781ef..00000000 --- a/lib/src/routing/route_matcher.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// Matches the URI with URI Design patterns. -/// -/// See https://jsonapi.org/recommendations/#urls -abstract class RouteMatcher { - /// Matches the [uri] with route patterns. - /// If there is a match, calls the corresponding method of the [handler]. - /// Returns true if match was found. - bool match(Uri uri, MatchHandler handler); -} - -abstract class MatchHandler { - void collection(String type); - - void resource(String type, String id); - - void related(String type, String id, String relationship); - - void relationship(String type, String id, String relationship); -} diff --git a/lib/src/routing/routes.dart b/lib/src/routing/routes.dart deleted file mode 100644 index a6592fb8..00000000 --- a/lib/src/routing/routes.dart +++ /dev/null @@ -1,37 +0,0 @@ -/// Primary resource collection route -abstract class CollectionRoute { - /// Returns the URI for a collection of type [type]. - Uri uri(String type); - - /// Matches the [uri] with a collection route pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type) onMatch); -} - -abstract class RelationshipRoute { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a relationship route pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id, String rel) onMatch); -} - -abstract class RelatedRoute { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a related route pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id, String rel) onMatch); -} - -abstract class ResourceRoute { - Uri uri(String type, String id); - - /// Matches the [uri] with a resource route pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id) onMatch); -} diff --git a/lib/src/routing/routing.dart b/lib/src/routing/routing.dart deleted file mode 100644 index a5f429cf..00000000 --- a/lib/src/routing/routing.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:json_api/routing.dart'; - -abstract class Routing implements RouteFactory, RouteMatcher {} diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard.dart similarity index 72% rename from lib/src/routing/standard_routes.dart rename to lib/src/routing/standard.dart index 608537b4..bf7981e0 100644 --- a/lib/src/routing/standard_routes.dart +++ b/lib/src/routing/standard.dart @@ -1,11 +1,19 @@ -import 'package:json_api/src/routing/routes.dart'; +import 'package:json_api/src/routing/contract.dart'; +import 'package:json_api/src/routing/composite_routing.dart'; + +/// The standard (recommended) URI design +class StandardRouting extends CompositeRouting { + StandardRouting([Uri base]) + : super(StandardCollection(base), StandardResource(base), + StandardRelated(base), StandardRelationship(base)); +} /// The recommended URI design for a primary resource collections. /// Example: `/photos` /// /// See: https://jsonapi.org/recommendations/#urls-resource-collections -class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { - StandardCollectionRoute([Uri base]) : super(base); +class StandardCollection extends _BaseRoute implements CollectionUriPattern { + StandardCollection([Uri base]) : super(base); @override bool match(Uri uri, Function(String type) onMatch) { @@ -25,8 +33,8 @@ class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { /// Example: `/photos/1` /// /// See: https://jsonapi.org/recommendations/#urls-individual-resources -class StandardResourceRoute extends _BaseRoute implements ResourceRoute { - StandardResourceRoute([Uri base]) : super(base); +class StandardResource extends _BaseRoute implements ResourceUriPattern { + StandardResource([Uri base]) : super(base); @override bool match(Uri uri, Function(String type, String id) onMatch) { @@ -46,8 +54,8 @@ class StandardResourceRoute extends _BaseRoute implements ResourceRoute { /// Example: `/photos/1/comments` /// /// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelatedRoute extends _BaseRoute implements RelatedRoute { - StandardRelatedRoute([Uri base]) : super(base); +class StandardRelated extends _BaseRoute implements RelatedUriPattern { + StandardRelated([Uri base]) : super(base); @override bool match(Uri uri, Function(String type, String id, String rel) onMatch) { @@ -68,9 +76,9 @@ class StandardRelatedRoute extends _BaseRoute implements RelatedRoute { /// Example: `/photos/1/relationships/comments` /// /// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelationshipRoute extends _BaseRoute - implements RelationshipRoute { - StandardRelationshipRoute([Uri base]) : super(base); +class StandardRelationship extends _BaseRoute + implements RelationshipUriPattern { + StandardRelationship([Uri base]) : super(base); @override bool match(Uri uri, Function(String type, String id, String rel) onMatch) { diff --git a/lib/src/routing/standard_routing.dart b/lib/src/routing/standard_routing.dart deleted file mode 100644 index eeab080e..00000000 --- a/lib/src/routing/standard_routing.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/src/routing/composite_routing.dart'; -import 'package:json_api/src/routing/standard_routes.dart'; - -/// The standard (recommended) URI design -class StandardRouting extends CompositeRouting { - StandardRouting([Uri base]) - : super(StandardCollectionRoute(base), StandardResourceRoute(base), - StandardRelatedRoute(base), StandardRelationshipRoute(base)); -} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 8c6443d8..8313de36 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/controller_response.dart'; /// This is a controller consolidating all possible requests a JSON:API server diff --git a/lib/src/server/controller_request.dart b/lib/src/server/controller_request.dart deleted file mode 100644 index bbd983f5..00000000 --- a/lib/src/server/controller_request.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/controller_response.dart'; - -class _Base { - _Base(this.request) - : sort = Sort.fromQueryParameters(request.uri.queryParametersAll), - include = Include.fromQueryParameters(request.uri.queryParametersAll), - page = Page.fromQueryParameters(request.uri.queryParametersAll); - - final HttpRequest request; - final Include include; - final Page page; - final Sort sort; - - bool get isCompound => include.isNotEmpty; -} - -class RelatedRequest extends _Base { - RelatedRequest(HttpRequest request, this.type, this.id, this.relationship) - : super(request); - - final String type; - - final String id; - - final String relationship; - - ControllerResponse resourceResponse(Resource resource, - {List include}) => - ResourceResponse(resource); - - ControllerResponse collectionResponse(Collection collection, - {List include}) => - CollectionResponse(collection); -} - -class ResourceRequest extends _Base { - ResourceRequest(HttpRequest request, this.type, this.id) : super(request); - - final String type; - - final String id; - - ControllerResponse resourceResponse(Resource resource, - {List include}) => - ResourceResponse(resource, include: include); -} - -class RelationshipRequest extends _Base { - RelationshipRequest( - HttpRequest request, this.type, this.id, this.relationship) - : super(request); - - final String type; - - final String id; - - final String relationship; - - ControllerResponse toManyResponse(List identifiers, - {List include}) => - ToManyResponse(identifiers); - - ControllerResponse toOneResponse(Identifier identifier, - {List include}) => - ToOneResponse(identifier); -} - -class CollectionRequest extends _Base { - CollectionRequest(HttpRequest request, this.type) : super(request); - - final String type; - - ControllerResponse resourceResponse(Resource modified) => - CreatedResourceResponse(modified); - - ControllerResponse collectionResponse(Collection collection, - {List include}) => - CollectionResponse(collection, include: include); -} diff --git a/lib/src/server/controller_response.dart b/lib/src/server/controller_response.dart index c32048f9..f43635d3 100644 --- a/lib/src/server/controller_response.dart +++ b/lib/src/server/controller_response.dart @@ -2,13 +2,14 @@ import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; +import 'package:json_api/src/server/request.dart'; abstract class ControllerResponse { int get status; - Map headers(RouteFactory route); + Map headers(UriFactory factory); - Document document(DocumentFactory doc); + Document document(DocumentFactory doc, UriFactory factory); } class ErrorResponse implements ControllerResponse { @@ -19,11 +20,12 @@ class ErrorResponse implements ControllerResponse { final List errors; @override - Map headers(RouteFactory route) => + Map headers(UriFactory factory) => {'Content-Type': Document.contentType}; @override - Document document(DocumentFactory doc) => doc.error(errors); + Document document(DocumentFactory doc, UriFactory factory) => + doc.error(errors); } class NoContentResponse implements ControllerResponse { @@ -33,15 +35,37 @@ class NoContentResponse implements ControllerResponse { int get status => 204; @override - Map headers(RouteFactory route) => {}; + Map headers(UriFactory factory) => {}; @override - Document document(DocumentFactory doc) => null; + Document document(DocumentFactory doc, UriFactory factory) => null; } -class ResourceResponse implements ControllerResponse { - ResourceResponse(this.resource, {this.include}); +class PrimaryResourceResponse implements ControllerResponse { + PrimaryResourceResponse(this.request, this.resource, {this.include}); + final ResourceRequest request; + + final Resource resource; + final List include; + + @override + int get status => 200; + + @override + Map headers(UriFactory factory) => + {'Content-Type': Document.contentType}; + + @override + Document document(DocumentFactory doc, UriFactory factory) => + doc.resource(factory, resource, + include: include, self: request.generateSelfUri(factory)); +} + +class RelatedResourceResponse implements ControllerResponse { + RelatedResourceResponse(this.request, this.resource, {this.include}); + + final RelatedRequest request; final Resource resource; final List include; @@ -49,12 +73,13 @@ class ResourceResponse implements ControllerResponse { int get status => 200; @override - Map headers(RouteFactory route) => + Map headers(UriFactory factory) => {'Content-Type': Document.contentType}; @override - Document document(DocumentFactory doc) => - doc.resource(resource, include: include); + Document document(DocumentFactory doc, UriFactory factory) => + doc.resource(factory, resource, + include: include, self: request.generateSelfUri(factory)); } class CreatedResourceResponse implements ControllerResponse { @@ -66,19 +91,41 @@ class CreatedResourceResponse implements ControllerResponse { int get status => 201; @override - Map headers(RouteFactory route) => { + Map headers(UriFactory factory) => { 'Content-Type': Document.contentType, - 'Location': route.resource(resource.type, resource.id).toString() + 'Location': factory.resource(resource.type, resource.id).toString() }; @override - Document document(DocumentFactory doc) => - doc.createdResource(resource, StandardRouting()); + Document document(DocumentFactory doc, UriFactory factory) => + doc.resource(factory, resource); +} + +class PrimaryCollectionResponse implements ControllerResponse { + PrimaryCollectionResponse(this.request, this.collection, {this.include}); + + final CollectionRequest request; + final Collection collection; + final List include; + + @override + int get status => 200; + + @override + Map headers(UriFactory factory) => + {'Content-Type': Document.contentType}; + + @override + Document document( + DocumentFactory doc, UriFactory factory) => + doc.collection(factory, collection, + include: include, self: request.generateSelfUri(factory)); } -class CollectionResponse implements ControllerResponse { - CollectionResponse(this.collection, {this.include}); +class RelatedCollectionResponse implements ControllerResponse { + RelatedCollectionResponse(this.request, this.collection, {this.include}); + final RelatedRequest request; final Collection collection; final List include; @@ -86,16 +133,20 @@ class CollectionResponse implements ControllerResponse { int get status => 200; @override - Map headers(RouteFactory route) => + Map headers(UriFactory factory) => {'Content-Type': Document.contentType}; @override - Document document(DocumentFactory doc) => - doc.collection(collection, include: include); + Document document( + DocumentFactory doc, UriFactory factory) => + doc.collection(factory, collection, + include: include, self: request.generateSelfUri(factory)); } class ToOneResponse implements ControllerResponse { - ToOneResponse(this.identifier); + ToOneResponse(this.request, this.identifier); + + final RelationshipRequest request; final Identifier identifier; @@ -103,15 +154,21 @@ class ToOneResponse implements ControllerResponse { int get status => 200; @override - Map headers(RouteFactory route) => + Map headers(UriFactory factory) => {'Content-Type': Document.contentType}; @override - Document document(DocumentFactory doc) => doc.toOne(identifier); + Document document(DocumentFactory doc, UriFactory factory) => + doc.toOne(identifier, + self: request.generateSelfUri(factory), + related: factory.related(request.target.type, request.target.id, + request.target.relationship)); } class ToManyResponse implements ControllerResponse { - ToManyResponse(this.identifiers); + ToManyResponse(this.request, this.identifiers); + + final RelationshipRequest request; final List identifiers; @@ -119,48 +176,75 @@ class ToManyResponse implements ControllerResponse { int get status => 200; @override - Map headers(RouteFactory route) => + Map headers(UriFactory factory) => {'Content-Type': Document.contentType}; @override - Document document(DocumentFactory doc) => doc.toMany(identifiers); + Document document(DocumentFactory doc, UriFactory factory) => + doc.toMany(identifiers, + self: request.generateSelfUri(factory), + related: factory.related(request.target.type, request.target.id, + request.target.relationship)); } class DocumentFactory { Document error(List errors) => Document.error(errors); - Document collection(Collection collection, - {List include}) => - Document(ResourceCollectionData(collection.elements.map(resourceObject), - include: include?.map(resourceObject))); - - Document resource(Resource resource, - {List include}) => - Document(ResourceData(resourceObject(resource), - include: include?.map(resourceObject))); - - Document createdResource( - Resource resource, RouteFactory routeFactory) => - Document(ResourceData(resourceObject(resource, - self: Link(routeFactory.resource(resource.type, resource.id))))); - - Document toOne(Identifier identifier) => - Document(ToOne(IdentifierObject.fromIdentifier(identifier))); - - Document toMany(List identifiers) => - Document(ToMany(identifiers.map(IdentifierObject.fromIdentifier))); - - ResourceObject resourceObject(Resource resource, {Link self}) { - return ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: { - ...resource.toOne.map((k, v) => - MapEntry(k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), - ...resource.toMany.map((k, v) => - MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))), + Document collection( + UriFactory factory, Collection collection, + {List include, Uri self}) => + Document(ResourceCollectionData( + collection.elements.map((_) => _resource(factory, _)), + links: {if (self != null) 'self': Link(self)}, + include: include?.map((_) => _resource(factory, _)))); + + Document resource(UriFactory factory, Resource resource, + {List include, Uri self}) => + Document(ResourceData(_resource(factory, resource), + links: {if (self != null) 'self': Link(self)}, + include: include?.map((_) => _resource(factory, _)))); + + Document toOne(Identifier identifier, {Uri self, Uri related}) => + Document(ToOne( + IdentifierObject.fromIdentifier(identifier), + links: { + if (self != null) 'self': Link(self), + if (related != null) 'related': Link(related), }, + )); + + Document toMany(List identifiers, + {Uri self, Uri related}) => + Document(ToMany( + identifiers.map(IdentifierObject.fromIdentifier), links: { - if (self != null) 'self': self - }); - } + if (self != null) 'self': Link(self), + if (related != null) 'related': Link(related), + }, + )); + + ResourceObject _resource(UriFactory factory, Resource resource) => + ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: { + ...resource.toOne.map((k, v) => MapEntry( + k, + ToOne(nullable(IdentifierObject.fromIdentifier)(v), links: { + 'self': + Link(factory.relationship(resource.type, resource.id, k)), + 'related': + Link(factory.related(resource.type, resource.id, k)), + }))), + ...resource.toMany.map((k, v) => MapEntry( + k, + ToMany(v.map(IdentifierObject.fromIdentifier), links: { + 'self': + Link(factory.relationship(resource.type, resource.id, k)), + 'related': + Link(factory.related(resource.type, resource.id, k)), + }))), + }, + links: { + 'self': Link(factory.resource(resource.type, resource.id)) + }); } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 364c2f65..71981b39 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -6,8 +6,8 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/controller_response.dart'; -import 'package:json_api/src/server/resolvable.dart'; -import 'package:json_api/src/server/target.dart'; +import 'package:json_api/src/server/resolvable_request.dart'; +import 'package:json_api/src/server/route.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { @@ -22,11 +22,11 @@ class JsonApiServer implements HttpHandler { @override Future call(HttpRequest httpRequest) async { - final targetFactory = TargetFactory(); - _routing.match(httpRequest.uri, targetFactory); - final target = targetFactory.target; + final routeFactory = RouteFactory(); + _routing.match(httpRequest.uri, routeFactory); + final route = routeFactory.route; - if (target == null) { + if (route == null) { return _convert(ErrorResponse(404, [ ErrorObject( status: '404', @@ -36,7 +36,7 @@ class JsonApiServer implements HttpHandler { ])); } - final allowed = (target.allowedMethods + ['OPTIONS']).join(', '); + final allowed = (route.allowedMethods + ['OPTIONS']).join(', '); if (httpRequest.isOptions) { return HttpResponse(200, headers: { @@ -47,12 +47,12 @@ class JsonApiServer implements HttpHandler { }); } - if (!target.allowedMethods.contains(httpRequest.method)) { + if (!route.allowedMethods.contains(httpRequest.method)) { return HttpResponse(405, headers: {'Allow': allowed}); } try { - final controllerRequest = target.convertRequest(httpRequest); + final controllerRequest = route.convertRequest(httpRequest); return _convert(await controllerRequest.resolveBy(_controller)); } on FormatException catch (e) { return _convert(ErrorResponse(400, [ @@ -82,9 +82,11 @@ class JsonApiServer implements HttpHandler { } HttpResponse _convert(ControllerResponse r) { - return HttpResponse(r.status, body: jsonEncode(r.document(_doc)), headers: { - ...r.headers(_routing), - 'Access-Control-Allow-Origin': '*', - }); + return HttpResponse(r.status, + body: jsonEncode(r.document(_doc, _routing)), + headers: { + ...r.headers(_routing), + 'Access-Control-Allow-Origin': '*', + }); } } diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 3c057ba2..2907aad6 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -29,12 +29,12 @@ abstract class Repository { /// Returns the resource by [type] and [id]. Future get(String type, String id); - /// Updates the resource identified by [target]. + /// Updates the resource identified by [route]. /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. Future update(String type, String id, Resource resource); - /// Deletes the resource identified by [target] + /// Deletes the resource identified by [route] Future delete(String type, String id); /// Returns a collection of resources diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 09007e96..c3284425 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/controller_request.dart'; +import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/controller_response.dart'; import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/repository.dart'; @@ -21,33 +21,33 @@ class RepositoryController implements Controller { Future addToRelationship( RelationshipRequest request, List identifiers) => _do(() async { - final original = await _repo.get(request.type, request.id); - if (!original.toMany.containsKey(request.relationship)) { + final original = await _repo.get(request.target.type, request.target.id); + if (!original.toMany.containsKey(request.target.relationship)) { return ErrorResponse(404, [ ErrorObject( status: '404', title: 'Relationship not found', detail: - "There is no to-many relationship '${request.relationship}' in this resource") + "There is no to-many relationship '${request.target.relationship}' in this resource") ]); } final updated = await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, toMany: { - request.relationship: { - ...original.toMany[request.relationship], + request.target.type, + request.target.id, + Resource(request.target.type, request.target.id, toMany: { + request.target.relationship: { + ...original.toMany[request.target.relationship], ...identifiers }.toList() })); - return request.toManyResponse(updated.toMany[request.relationship]); + return request.toManyResponse(updated.toMany[request.target.relationship]); }); @override Future createResource( CollectionRequest request, Resource resource) => _do(() async { - final modified = await _repo.create(request.type, resource); + final modified = await _repo.create(request.target.type, resource); if (modified == null) { return NoContentResponse(); } @@ -58,22 +58,22 @@ class RepositoryController implements Controller { Future deleteFromRelationship( RelationshipRequest request, List identifiers) => _do(() async { - final original = await _repo.get(request.type, request.id); + final original = await _repo.get(request.target.type, request.target.id); final updated = await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, toMany: { - request.relationship: ({...original.toMany[request.relationship]} + request.target.type, + request.target.id, + Resource(request.target.type, request.target.id, toMany: { + request.target.relationship: ({...original.toMany[request.target.relationship]} ..removeAll(identifiers)) .toList() })); - return request.toManyResponse(updated.toMany[request.relationship]); + return request.toManyResponse(updated.toMany[request.target.relationship]); }); @override Future deleteResource(ResourceRequest request) => _do(() async { - await _repo.delete(request.type, request.id); + await _repo.delete(request.target.type, request.target.id); return NoContentResponse(); }); @@ -83,7 +83,7 @@ class RepositoryController implements Controller { final limit = _pagination.limit(request.page); final offset = _pagination.offset(request.page); - final collection = await _repo.getCollection(request.type, + final collection = await _repo.getCollection(request.target.type, sort: request.sort.toList(), limit: limit, offset: offset); final resources = []; @@ -99,38 +99,38 @@ class RepositoryController implements Controller { @override Future fetchRelated(RelatedRequest request) => _do(() async { - final resource = await _repo.get(request.type, request.id); - if (resource.toOne.containsKey(request.relationship)) { - final i = resource.toOne[request.relationship]; + final resource = await _repo.get(request.target.type, request.target.id); + if (resource.toOne.containsKey(request.target.relationship)) { + final i = resource.toOne[request.target.relationship]; return request.resourceResponse(await _repo.get(i.type, i.id)); } - if (resource.toMany.containsKey(request.relationship)) { + if (resource.toMany.containsKey(request.target.relationship)) { final related = []; - for (final identifier in resource.toMany[request.relationship]) { + for (final identifier in resource.toMany[request.target.relationship]) { related.add(await _repo.get(identifier.type, identifier.id)); } return request.collectionResponse(Collection(related)); } - return ErrorResponse(404, _relationshipNotFound(request.relationship)); + return ErrorResponse(404, _relationshipNotFound(request.target.relationship)); }); @override Future fetchRelationship(RelationshipRequest request) => _do(() async { - final resource = await _repo.get(request.type, request.id); - if (resource.toOne.containsKey(request.relationship)) { - return request.toOneResponse(resource.toOne[request.relationship]); + final resource = await _repo.get(request.target.type, request.target.id); + if (resource.toOne.containsKey(request.target.relationship)) { + return request.toOneResponse(resource.toOne[request.target.relationship]); } - if (resource.toMany.containsKey(request.relationship)) { - return request.toManyResponse(resource.toMany[request.relationship]); + if (resource.toMany.containsKey(request.target.relationship)) { + return request.toManyResponse(resource.toMany[request.target.relationship]); } - return ErrorResponse(404, _relationshipNotFound(request.relationship)); + return ErrorResponse(404, _relationshipNotFound(request.target.relationship)); }); @override Future fetchResource(ResourceRequest request) => _do(() async { - final resource = await _repo.get(request.type, request.id); + final resource = await _repo.get(request.target.type, request.target.id); final resources = []; for (final path in request.include) { resources.addAll(await _getRelated(resource, path.split('.'))); @@ -144,10 +144,10 @@ class RepositoryController implements Controller { RelationshipRequest request, List identifiers) => _do(() async { await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, - toMany: {request.relationship: identifiers})); + request.target.type, + request.target.id, + Resource(request.target.type, request.target.id, + toMany: {request.target.relationship: identifiers})); return NoContentResponse(); }); @@ -155,7 +155,7 @@ class RepositoryController implements Controller { Future updateResource( ResourceRequest request, Resource resource) => _do(() async { - final modified = await _repo.update(request.type, request.id, resource); + final modified = await _repo.update(request.target.type, request.target.id, resource); if (modified == null) { return NoContentResponse(); } @@ -167,10 +167,10 @@ class RepositoryController implements Controller { RelationshipRequest request, Identifier identifier) => _do(() async { await _repo.update( - request.type, - request.id, - Resource(request.type, request.id, - toOne: {request.relationship: identifier})); + request.target.type, + request.target.id, + Resource(request.target.type, request.target.id, + toOne: {request.target.relationship: identifier})); return NoContentResponse(); }); diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart new file mode 100644 index 00000000..3f011047 --- /dev/null +++ b/lib/src/server/request.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/collection.dart'; +import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/target.dart'; + +abstract class JsonApiRequest { + JsonApiRequest(this._request, this.target) + : sort = Sort.fromQueryParameters(_request.uri.queryParametersAll), + include = Include.fromQueryParameters(_request.uri.queryParametersAll), + page = Page.fromQueryParameters(_request.uri.queryParametersAll); + + final HttpRequest _request; + final Include include; + final Page page; + final Sort sort; + final T target; + + Uri get uri => _request.uri; + + Map get headers => _request.headers; + + Object decodePayload() => jsonDecode(_request.body); + + bool get isCompound => include.isNotEmpty; + + /// Generates the 'self' link preserving original query parameters + Uri generateSelfUri(UriFactory factory) => _request + .uri.queryParameters.isNotEmpty + ? _self(factory).replace(queryParameters: _request.uri.queryParametersAll) + : _self(factory); + + Uri _self(UriFactory factory); +} + +class RelatedRequest extends JsonApiRequest { + RelatedRequest(HttpRequest request, RelationshipTarget target) + : super(request, target); + + ControllerResponse resourceResponse(Resource resource, + {List include}) => + RelatedResourceResponse(this, resource); + + ControllerResponse collectionResponse(Collection collection, + {List include}) => + RelatedCollectionResponse(this, collection); + + @override + Uri _self(UriFactory factory) => + factory.related(target.type, target.id, target.relationship); +} + +class ResourceRequest extends JsonApiRequest { + ResourceRequest(HttpRequest request, ResourceTarget target) + : super(request, target); + + ControllerResponse resourceResponse(Resource resource, + {List include}) => + PrimaryResourceResponse(this, resource, include: include); + + @override + Uri _self(UriFactory factory) => factory.resource(target.type, target.id); +} + +class RelationshipRequest extends JsonApiRequest { + RelationshipRequest(HttpRequest request, RelationshipTarget target) + : super(request, target); + + ControllerResponse toManyResponse(List identifiers, + {List include}) => + ToManyResponse(this, identifiers); + + ControllerResponse toOneResponse(Identifier identifier, + {List include}) => + ToOneResponse(this, identifier); + + @override + Uri _self(UriFactory factory) => + factory.relationship(target.type, target.id, target.relationship); +} + +class CollectionRequest extends JsonApiRequest { + CollectionRequest(HttpRequest request, CollectionTarget target) + : super(request, target); + + ControllerResponse resourceResponse(Resource modified) => + CreatedResourceResponse(modified); + + ControllerResponse collectionResponse(Collection collection, + {List include}) => + PrimaryCollectionResponse(this, collection, include: include); + + @override + Uri _self(UriFactory factory) => factory.collection(target.type); +} diff --git a/lib/src/server/resolvable.dart b/lib/src/server/resolvable_request.dart similarity index 70% rename from lib/src/server/resolvable.dart rename to lib/src/server/resolvable_request.dart index 8c761db1..8187f5f5 100644 --- a/lib/src/server/resolvable.dart +++ b/lib/src/server/resolvable_request.dart @@ -1,15 +1,13 @@ -import 'dart:convert'; - import 'package:json_api/document.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/controller_request.dart'; import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/request.dart'; -abstract class Resolvable { +abstract class ResolvableRequest { Future resolveBy(Controller controller); } -class FetchCollection implements Resolvable { +class FetchCollection implements ResolvableRequest { FetchCollection(this.request); final CollectionRequest request; @@ -19,18 +17,18 @@ class FetchCollection implements Resolvable { controller.fetchCollection(request); } -class CreateResource implements Resolvable { +class CreateResource implements ResolvableRequest { CreateResource(this.request); final CollectionRequest request; @override Future resolveBy(Controller controller) => - controller.createResource(request, - ResourceData.fromJson(jsonDecode(request.request.body)).unwrap()); + controller.createResource( + request, ResourceData.fromJson(request.decodePayload()).unwrap()); } -class FetchResource implements Resolvable { +class FetchResource implements ResolvableRequest { FetchResource(this.request); final ResourceRequest request; @@ -40,7 +38,7 @@ class FetchResource implements Resolvable { controller.fetchResource(request); } -class DeleteResource implements Resolvable { +class DeleteResource implements ResolvableRequest { DeleteResource(this.request); final ResourceRequest request; @@ -50,18 +48,18 @@ class DeleteResource implements Resolvable { controller.deleteResource(request); } -class UpdateResource implements Resolvable { +class UpdateResource implements ResolvableRequest { UpdateResource(this.request); final ResourceRequest request; @override Future resolveBy(Controller controller) => - controller.updateResource(request, - ResourceData.fromJson(jsonDecode(request.request.body)).unwrap()); + controller.updateResource( + request, ResourceData.fromJson(request.decodePayload()).unwrap()); } -class FetchRelated implements Resolvable { +class FetchRelated implements ResolvableRequest { FetchRelated(this.request); final RelatedRequest request; @@ -71,7 +69,7 @@ class FetchRelated implements Resolvable { controller.fetchRelated(request); } -class FetchRelationship implements Resolvable { +class FetchRelationship implements ResolvableRequest { FetchRelationship(this.request); final RelationshipRequest request; @@ -81,7 +79,7 @@ class FetchRelationship implements Resolvable { controller.fetchRelationship(request); } -class DeleteFromRelationship implements Resolvable { +class DeleteFromRelationship implements ResolvableRequest { DeleteFromRelationship(this.request); final RelationshipRequest request; @@ -89,10 +87,10 @@ class DeleteFromRelationship implements Resolvable { @override Future resolveBy(Controller controller) => controller.deleteFromRelationship( - request, ToMany.fromJson(jsonDecode(request.request.body)).unwrap()); + request, ToMany.fromJson(request.decodePayload()).unwrap()); } -class AddToRelationship implements Resolvable { +class AddToRelationship implements ResolvableRequest { AddToRelationship(this.request); final RelationshipRequest request; @@ -100,17 +98,17 @@ class AddToRelationship implements Resolvable { @override Future resolveBy(Controller controller) => controller.addToRelationship( - request, ToMany.fromJson(jsonDecode(request.request.body)).unwrap()); + request, ToMany.fromJson(request.decodePayload()).unwrap()); } -class ReplaceRelationship implements Resolvable { +class ReplaceRelationship implements ResolvableRequest { ReplaceRelationship(this.request); final RelationshipRequest request; @override Future resolveBy(Controller controller) async { - final r = Relationship.fromJson(jsonDecode(request.request.body)); + final r = Relationship.fromJson(request.decodePayload()); if (r is ToOne) { return controller.replaceToOne(request, r.unwrap()); } diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart new file mode 100644 index 00000000..74e0f5b4 --- /dev/null +++ b/lib/src/server/route.dart @@ -0,0 +1,138 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/resolvable_request.dart'; +import 'package:json_api/src/server/target.dart'; + +abstract class Route { + List get allowedMethods; + + ResolvableRequest convertRequest(HttpRequest request); +} + +class RouteFactory implements UriMatchHandler { + Route route; + + @override + void collection(String type) { + route = CollectionRoute(type); + } + + @override + void related(String type, String id, String relationship) { + route = RelatedRoute(type, id, relationship); + } + + @override + void relationship(String type, String id, String relationship) { + route = RelationshipRoute(type, id, relationship); + } + + @override + void resource(String type, String id) { + route = ResourceRoute(type, id); + } +} + +class CollectionRoute implements Route, CollectionTarget { + CollectionRoute(this.type); + + @override + final String type; + + @override + final allowedMethods = ['GET', 'POST']; + + @override + ResolvableRequest convertRequest(HttpRequest request) { + final r = CollectionRequest(request, this); + if (request.isGet) { + return FetchCollection(r); + } + if (request.isPost) { + return CreateResource(r); + } + throw ArgumentError(); + } +} + +class ResourceRoute implements Route, ResourceTarget { + ResourceRoute(this.type, this.id); + + @override + final String type; + @override + final String id; + + @override + final allowedMethods = ['DELETE', 'GET', 'PATCH']; + + @override + ResolvableRequest convertRequest(HttpRequest request) { + final r = ResourceRequest(request, this); + if (request.isDelete) { + return DeleteResource(r); + } + if (request.isGet) { + return FetchResource(r); + } + if (request.isPatch) { + return UpdateResource(r); + } + throw ArgumentError(); + } +} + +class RelatedRoute implements Route, RelationshipTarget { + RelatedRoute(this.type, this.id, this.relationship); + + @override + final String type; + @override + final String id; + @override + final String relationship; + + @override + final allowedMethods = ['GET']; + + @override + ResolvableRequest convertRequest(HttpRequest request) { + if (request.isGet) { + return FetchRelated(RelatedRequest(request, this)); + } + throw ArgumentError(); + } +} + +class RelationshipRoute implements Route, RelationshipTarget { + RelationshipRoute(this.type, this.id, this.relationship); + + @override + final String type; + @override + final String id; + @override + final String relationship; + + @override + final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; + + @override + ResolvableRequest convertRequest(HttpRequest request) { + final r = RelationshipRequest(request, this); + if (request.isDelete) { + return DeleteFromRelationship(r); + } + if (request.isGet) { + return FetchRelationship(r); + } + if (request.isPatch) { + return ReplaceRelationship(r); + } + if (request.isPost) { + return AddToRelationship(r); + } + throw ArgumentError(); + } +} diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index 2588292f..cccd0688 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -1,129 +1,11 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/controller_request.dart'; -import 'package:json_api/src/server/resolvable.dart'; - -abstract class Target { - List get allowedMethods; - - Resolvable convertRequest(HttpRequest request); -} - -class CollectionTarget implements Target { - CollectionTarget(this.type); - - final String type; - - @override - final allowedMethods = ['GET', 'POST']; - - @override - Resolvable convertRequest(HttpRequest request) { - if (request.isGet) { - return FetchCollection(CollectionRequest(request, type)); - } - if (request.isPost) { - return CreateResource(CollectionRequest(request, type)); - } - throw ArgumentError(); - } -} - -class ResourceTarget implements Target { - ResourceTarget(this.type, this.id); - - final String type; - final String id; - - @override - final allowedMethods = ['DELETE', 'GET', 'PATCH']; - - @override - Resolvable convertRequest(HttpRequest request) { - if (request.isDelete) { - return DeleteResource(ResourceRequest(request, type, id)); - } - if (request.isGet) { - return FetchResource(ResourceRequest(request, type, id)); - } - if (request.isPatch) { - return UpdateResource(ResourceRequest(request, type, id)); - } - throw ArgumentError(); - } +abstract class CollectionTarget { + String get type; } -class RelatedTarget implements Target { - RelatedTarget(this.type, this.id, this.relationship); - - final String type; - final String id; - final String relationship; - - @override - final allowedMethods = ['GET']; - - @override - Resolvable convertRequest(HttpRequest request) { - if (request.isGet) { - return FetchRelated(RelatedRequest(request, type, id, relationship)); - } - throw ArgumentError(); - } -} - -class RelationshipTarget implements Target { - RelationshipTarget(this.type, this.id, this.relationship); - - final String type; - final String id; - final String relationship; - - @override - final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; - - @override - Resolvable convertRequest(HttpRequest request) { - if (request.isDelete) { - return DeleteFromRelationship( - RelationshipRequest(request, type, id, relationship)); - } - if (request.isGet) { - return FetchRelationship( - RelationshipRequest(request, type, id, relationship)); - } - if (request.isPatch) { - return ReplaceRelationship( - RelationshipRequest(request, type, id, relationship)); - } - if (request.isPost) { - return AddToRelationship( - RelationshipRequest(request, type, id, relationship)); - } - throw ArgumentError(); - } +abstract class ResourceTarget implements CollectionTarget { + String get id; } -class TargetFactory implements MatchHandler { - Target target; - - @override - void collection(String type) { - target = CollectionTarget(type); - } - - @override - void related(String type, String id, String relationship) { - target = RelatedTarget(type, id, relationship); - } - - @override - void relationship(String type, String id, String relationship) { - target = RelationshipTarget(type, id, relationship); - } - - @override - void resource(String type, String id) { - target = ResourceTarget(type, id); - } +abstract class RelationshipTarget implements ResourceTarget { + String get relationship; } diff --git a/pubspec.yaml b/pubspec.yaml index d56e1b20..7bce48ec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: dependencies: http: ^0.12.0 dev_dependencies: + html: ^0.14.0 pedantic: ^1.9.0 test: ^1.9.2 json_matcher: ^0.2.3 diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 70a2ed22..58d8d618 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -64,6 +64,7 @@ void main() async { expectResourcesEqual(r.data.unwrap(), post); expect(r.data.included, []); expect(r.data.isCompound, isTrue); + expect(r.data.self.toString(), '/posts/1?include=tags'); }); test('can include first-level relatives', () async { diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 1fd60b60..82bfb1f5 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -33,6 +33,8 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.headers['content-type'], Document.contentType); + expect(r.data.self.uri.toString(), '/books/1/relationships/publisher'); + expect(r.data.related.uri.toString(), '/books/1/publisher'); expect(r.data.unwrap().type, 'companies'); expect(r.data.unwrap().id, '1'); }); @@ -77,6 +79,8 @@ void main() async { expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.type, 'people'); + expect(r.data.self.uri.toString(), '/books/1/relationships/authors'); + expect(r.data.related.uri.toString(), '/books/1/authors'); }); test('404 on collection', () async { diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 95b83c09..ac50a9f9 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -30,12 +30,20 @@ void main() async { group('Primary Resource', () { test('200 OK', () async { - final r = await routingClient.fetchResource('people', '1'); + final r = await routingClient.fetchResource('books', '1'); expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().id, '1'); - expect(r.data.unwrap().attributes['name'], 'Martin Fowler'); + expect(r.data.unwrap().attributes['title'], 'Refactoring'); + expect(r.data.self.uri.toString(), '/books/1'); + expect(r.data.resourceObject.links['self'].uri.toString(), '/books/1'); + final authors = r.data.resourceObject.relationships['authors']; + expect(authors.self.toString(), '/books/1/relationships/authors'); + expect(authors.related.toString(), '/books/1/authors'); + final publisher = r.data.resourceObject.relationships['publisher']; + expect(publisher.self.toString(), '/books/1/relationships/publisher'); + expect(publisher.related.toString(), '/books/1/publisher'); }); test('404 on collection', () async { @@ -65,8 +73,13 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.headers['content-type'], Document.contentType); + expect(r.data.links['self'].uri.toString(), '/people'); + expect(r.data.collection.length, 3); + expect(r.data.collection.first.self.uri.toString(), '/people/1'); + expect(r.data.collection.last.self.uri.toString(), '/people/3'); expect(r.data.unwrap().length, 3); expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); + expect(r.data.unwrap().last.attributes['name'], 'Robert Martin'); }); test('404', () async { @@ -89,6 +102,9 @@ void main() async { expect(r.headers['content-type'], Document.contentType); expect(r.data.unwrap().type, 'companies'); expect(r.data.unwrap().id, '1'); + expect(r.data.links['self'].uri.toString(), '/books/1/publisher'); + expect( + r.data.resourceObject.links['self'].uri.toString(), '/companies/1'); }); test('404 on collection', () async { @@ -132,8 +148,13 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.statusCode, 200); expect(r.headers['content-type'], Document.contentType); + expect(r.data.links['self'].uri.toString(), '/books/1/authors'); + expect(r.data.collection.length, 2); + expect(r.data.collection.first.self.uri.toString(), '/people/1'); + expect(r.data.collection.last.self.uri.toString(), '/people/2'); expect(r.data.unwrap().length, 2); expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); + expect(r.data.unwrap().last.attributes['name'], 'Kent Beck'); }); test('404 on collection', () async { From 34112e6e38913b58c0f83a229fa9bd7fccb8519e Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 14 Apr 2020 20:34:31 -0700 Subject: [PATCH 48/99] Api fix --- lib/src/document/api.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index a4d4dc79..bb1fbed1 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -13,7 +13,7 @@ class Api implements JsonEncodable { /// Meta data. May be empty or null. final Map meta; - bool get isNotEmpty => version.isEmpty && meta.isNotEmpty; + bool get isNotEmpty => version.isNotEmpty || meta.isNotEmpty; static Api fromJson(Object json) { if (json is Map) { From e4dcf7062d3950566e44409698ada6d7e9301b8b Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 15 Apr 2020 09:40:12 -0700 Subject: [PATCH 49/99] wip --- lib/src/routing/standard.dart | 2 +- lib/src/server/controller.dart | 38 ++--- lib/src/server/json_api_server.dart | 50 +++---- lib/src/server/repository_controller.dart | 103 +++++++------ lib/src/server/request.dart | 97 ++---------- lib/src/server/request_context.dart | 19 +++ lib/src/server/resolvable_request.dart | 123 --------------- ...controller_response.dart => response.dart} | 52 +++++-- lib/src/server/route.dart | 141 +++++++++++------- lib/src/server/route_matcher.dart | 27 ++++ lib/src/server/target.dart | 27 +++- test/e2e/browser_test.dart | 2 +- test/unit/server/json_api_server_test.dart | 13 ++ 13 files changed, 318 insertions(+), 376 deletions(-) create mode 100644 lib/src/server/request_context.dart delete mode 100644 lib/src/server/resolvable_request.dart rename lib/src/server/{controller_response.dart => response.dart} (84%) create mode 100644 lib/src/server/route_matcher.dart diff --git a/lib/src/routing/standard.dart b/lib/src/routing/standard.dart index bf7981e0..e7a6d8fd 100644 --- a/lib/src/routing/standard.dart +++ b/lib/src/routing/standard.dart @@ -1,5 +1,5 @@ -import 'package:json_api/src/routing/contract.dart'; import 'package:json_api/src/routing/composite_routing.dart'; +import 'package:json_api/src/routing/contract.dart'; /// The standard (recommended) URI design class StandardRouting extends CompositeRouting { diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 8313de36..ae8500cd 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,57 +1,59 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/target.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. abstract class Controller { /// Finds an returns a primary resource collection. /// See https://jsonapi.org/format/#fetching-resources - Future fetchCollection(CollectionRequest request); + Future fetchCollection(Request request); /// Finds an returns a primary resource. /// See https://jsonapi.org/format/#fetching-resources - Future fetchResource(ResourceRequest request); + Future fetchResource(Request request); /// Finds an returns a related resource or a collection of related resources. /// See https://jsonapi.org/format/#fetching-resources - Future fetchRelated(RelatedRequest request); + Future fetchRelated(Request request); /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships - Future fetchRelationship(RelationshipRequest request); + Future fetchRelationship( + Request request); /// Deletes the resource. /// See https://jsonapi.org/format/#crud-deleting - Future deleteResource(ResourceRequest request); + Future deleteResource(Request request); /// Creates a new resource in the collection. /// See https://jsonapi.org/format/#crud-creating - Future createResource( - CollectionRequest request, Resource resource); + Future createResource( + Request request, Resource resource); /// Updates the resource. /// See https://jsonapi.org/format/#crud-updating - Future updateResource( - ResourceRequest request, Resource resource); + Future updateResource( + Request request, Resource resource); /// Replaces the to-one relationship. /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - Future replaceToOne( - RelationshipRequest request, Identifier identifier); + Future replaceToOne( + Request request, Identifier identifier); /// Replaces the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future replaceToMany( - RelationshipRequest request, List identifiers); + Future replaceToMany( + Request request, List identifiers); /// Removes the given identifiers from the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future deleteFromRelationship( - RelationshipRequest request, List identifiers); + Future deleteFromRelationship( + Request request, List identifiers); /// Adds the given identifiers to the to-many relationship. /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future addToRelationship( - RelationshipRequest request, List identifiers); + Future addToRelationship( + Request request, List identifiers); } diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 71981b39..b8b411d3 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,13 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/controller_response.dart'; -import 'package:json_api/src/server/resolvable_request.dart'; +import 'package:json_api/src/server/request_context.dart'; +import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/route.dart'; +import 'package:json_api/src/server/route_matcher.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { @@ -22,12 +22,14 @@ class JsonApiServer implements HttpHandler { @override Future call(HttpRequest httpRequest) async { - final routeFactory = RouteFactory(); - _routing.match(httpRequest.uri, routeFactory); - final route = routeFactory.route; + final context = RequestContext(_doc, _routing); + + final matcher = RouteMatcher(); + _routing.match(httpRequest.uri, matcher); + final route = matcher.route; if (route == null) { - return _convert(ErrorResponse(404, [ + return context.convert(ErrorResponse(404, [ ErrorObject( status: '404', title: 'Not Found', @@ -36,26 +38,17 @@ class JsonApiServer implements HttpHandler { ])); } - final allowed = (route.allowedMethods + ['OPTIONS']).join(', '); - - if (httpRequest.isOptions) { - return HttpResponse(200, headers: { - 'Access-Control-Allow-Methods': allowed, - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Max-Age': '3600', - }); - } + final route2 = CorsEnabled(route); - if (!route.allowedMethods.contains(httpRequest.method)) { - return HttpResponse(405, headers: {'Allow': allowed}); + if (!route2.allowedMethods.contains(httpRequest.method)) { + return context.convert(ExtraHeaders( + ErrorResponse(405, []), {'Allow': route2.allowedMethods.join(', ')})); } try { - final controllerRequest = route.convertRequest(httpRequest); - return _convert(await controllerRequest.resolveBy(_controller)); + return context.convert(await route2.dispatch(httpRequest, _controller)); } on FormatException catch (e) { - return _convert(ErrorResponse(400, [ + return context.convert(ErrorResponse(400, [ ErrorObject( status: '400', title: 'Bad Request', @@ -63,7 +56,7 @@ class JsonApiServer implements HttpHandler { ) ])); } on DocumentException catch (e) { - return _convert(ErrorResponse(400, [ + return context.convert(ErrorResponse(400, [ ErrorObject( status: '400', title: 'Bad Request', @@ -71,7 +64,7 @@ class JsonApiServer implements HttpHandler { ) ])); } on IncompleteRelationshipException { - return _convert(ErrorResponse(400, [ + return context.convert(ErrorResponse(400, [ ErrorObject( status: '400', title: 'Bad Request', @@ -80,13 +73,4 @@ class JsonApiServer implements HttpHandler { ])); } } - - HttpResponse _convert(ControllerResponse r) { - return HttpResponse(r.status, - body: jsonEncode(r.document(_doc, _routing)), - headers: { - ...r.headers(_routing), - 'Access-Control-Allow-Origin': '*', - }); - } } diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index c3284425..0d11cf78 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -3,10 +3,11 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/repository.dart'; +import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/target.dart'; /// An opinionated implementation of [Controller]. Translates JSON:API /// requests to [Repository] methods calls. @@ -18,10 +19,11 @@ class RepositoryController implements Controller { final Pagination _pagination; @override - Future addToRelationship( - RelationshipRequest request, List identifiers) => + Future addToRelationship( + Request request, List identifiers) => _do(() async { - final original = await _repo.get(request.target.type, request.target.id); + final original = + await _repo.get(request.target.type, request.target.id); if (!original.toMany.containsKey(request.target.relationship)) { return ErrorResponse(404, [ ErrorObject( @@ -40,45 +42,50 @@ class RepositoryController implements Controller { ...identifiers }.toList() })); - return request.toManyResponse(updated.toMany[request.target.relationship]); + return ToManyResponse( + request, updated.toMany[request.target.relationship]); }); @override - Future createResource( - CollectionRequest request, Resource resource) => + Future createResource( + Request request, Resource resource) => _do(() async { final modified = await _repo.create(request.target.type, resource); if (modified == null) { return NoContentResponse(); } - return request.resourceResponse(modified); + return CreatedResourceResponse(modified); }); @override - Future deleteFromRelationship( - RelationshipRequest request, List identifiers) => + Future deleteFromRelationship( + Request request, List identifiers) => _do(() async { - final original = await _repo.get(request.target.type, request.target.id); + final original = + await _repo.get(request.target.type, request.target.id); final updated = await _repo.update( request.target.type, request.target.id, Resource(request.target.type, request.target.id, toMany: { - request.target.relationship: ({...original.toMany[request.target.relationship]} - ..removeAll(identifiers)) + request.target.relationship: ({ + ...original.toMany[request.target.relationship] + }..removeAll(identifiers)) .toList() })); - return request.toManyResponse(updated.toMany[request.target.relationship]); + return ToManyResponse( + request, updated.toMany[request.target.relationship]); }); @override - Future deleteResource(ResourceRequest request) => + Future deleteResource(Request request) => _do(() async { await _repo.delete(request.target.type, request.target.id); return NoContentResponse(); }); @override - Future fetchCollection(CollectionRequest request) => + Future fetchCollection( + Request request) => _do(() async { final limit = _pagination.limit(request.page); final offset = _pagination.offset(request.page); @@ -92,56 +99,67 @@ class RepositoryController implements Controller { resources.addAll(await _getRelated(resource, path.split('.'))); } } - return request.collectionResponse(collection, + return PrimaryCollectionResponse(request, collection, include: request.isCompound ? resources : null); }); @override - Future fetchRelated(RelatedRequest request) => + Future fetchRelated( + Request request) => _do(() async { - final resource = await _repo.get(request.target.type, request.target.id); + final resource = + await _repo.get(request.target.type, request.target.id); if (resource.toOne.containsKey(request.target.relationship)) { final i = resource.toOne[request.target.relationship]; - return request.resourceResponse(await _repo.get(i.type, i.id)); + return RelatedResourceResponse( + request, await _repo.get(i.type, i.id)); } if (resource.toMany.containsKey(request.target.relationship)) { final related = []; - for (final identifier in resource.toMany[request.target.relationship]) { + for (final identifier + in resource.toMany[request.target.relationship]) { related.add(await _repo.get(identifier.type, identifier.id)); } - return request.collectionResponse(Collection(related)); + return RelatedCollectionResponse(request, Collection(related)); } - return ErrorResponse(404, _relationshipNotFound(request.target.relationship)); + return ErrorResponse( + 404, _relationshipNotFound(request.target.relationship)); }); @override - Future fetchRelationship(RelationshipRequest request) => + Future fetchRelationship( + Request request) => _do(() async { - final resource = await _repo.get(request.target.type, request.target.id); + final resource = + await _repo.get(request.target.type, request.target.id); if (resource.toOne.containsKey(request.target.relationship)) { - return request.toOneResponse(resource.toOne[request.target.relationship]); + return ToOneResponse( + request, resource.toOne[request.target.relationship]); } if (resource.toMany.containsKey(request.target.relationship)) { - return request.toManyResponse(resource.toMany[request.target.relationship]); + return ToManyResponse( + request, resource.toMany[request.target.relationship]); } - return ErrorResponse(404, _relationshipNotFound(request.target.relationship)); + return ErrorResponse( + 404, _relationshipNotFound(request.target.relationship)); }); @override - Future fetchResource(ResourceRequest request) => + Future fetchResource(Request request) => _do(() async { - final resource = await _repo.get(request.target.type, request.target.id); + final resource = + await _repo.get(request.target.type, request.target.id); final resources = []; for (final path in request.include) { resources.addAll(await _getRelated(resource, path.split('.'))); } - return request.resourceResponse(resource, + return PrimaryResourceResponse(request, resource, include: request.isCompound ? resources : null); }); @override - Future replaceToMany( - RelationshipRequest request, List identifiers) => + Future replaceToMany( + Request request, List identifiers) => _do(() async { await _repo.update( request.target.type, @@ -152,19 +170,20 @@ class RepositoryController implements Controller { }); @override - Future updateResource( - ResourceRequest request, Resource resource) => + Future updateResource( + Request request, Resource resource) => _do(() async { - final modified = await _repo.update(request.target.type, request.target.id, resource); + final modified = await _repo.update( + request.target.type, request.target.id, resource); if (modified == null) { return NoContentResponse(); } - return request.resourceResponse(modified); + return PrimaryResourceResponse(request, modified, include: null); }); @override - Future replaceToOne( - RelationshipRequest request, Identifier identifier) => + Future replaceToOne( + Request request, Identifier identifier) => _do(() async { await _repo.update( request.target.type, @@ -202,8 +221,8 @@ class RepositoryController implements Controller { Map.fromIterable(included, key: (_) => '${_.type}:${_.id}').values; - Future _do( - Future Function() action) async { + Future _do( + Future Function() action) async { try { return await action(); } on UnsupportedOperation catch (e) { diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 3f011047..9697a45c 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -1,99 +1,34 @@ import 'dart:convert'; -import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/controller_response.dart'; +import 'package:json_api/src/server/route.dart'; import 'package:json_api/src/server/target.dart'; -abstract class JsonApiRequest { - JsonApiRequest(this._request, this.target) - : sort = Sort.fromQueryParameters(_request.uri.queryParametersAll), - include = Include.fromQueryParameters(_request.uri.queryParametersAll), - page = Page.fromQueryParameters(_request.uri.queryParametersAll); +class Request { + Request(this.httpRequest, this.route) + : sort = Sort.fromUri(httpRequest.uri), + include = Include.fromUri(httpRequest.uri), + page = Page.fromUri(httpRequest.uri); - final HttpRequest _request; + final HttpRequest httpRequest; final Include include; final Page page; final Sort sort; - final T target; + final Route route; - Uri get uri => _request.uri; + T get target => route.target; - Map get headers => _request.headers; - - Object decodePayload() => jsonDecode(_request.body); + Object decodePayload() => jsonDecode(httpRequest.body); bool get isCompound => include.isNotEmpty; /// Generates the 'self' link preserving original query parameters - Uri generateSelfUri(UriFactory factory) => _request - .uri.queryParameters.isNotEmpty - ? _self(factory).replace(queryParameters: _request.uri.queryParametersAll) - : _self(factory); - - Uri _self(UriFactory factory); -} - -class RelatedRequest extends JsonApiRequest { - RelatedRequest(HttpRequest request, RelationshipTarget target) - : super(request, target); - - ControllerResponse resourceResponse(Resource resource, - {List include}) => - RelatedResourceResponse(this, resource); - - ControllerResponse collectionResponse(Collection collection, - {List include}) => - RelatedCollectionResponse(this, collection); - - @override - Uri _self(UriFactory factory) => - factory.related(target.type, target.id, target.relationship); -} - -class ResourceRequest extends JsonApiRequest { - ResourceRequest(HttpRequest request, ResourceTarget target) - : super(request, target); - - ControllerResponse resourceResponse(Resource resource, - {List include}) => - PrimaryResourceResponse(this, resource, include: include); - - @override - Uri _self(UriFactory factory) => factory.resource(target.type, target.id); -} - -class RelationshipRequest extends JsonApiRequest { - RelationshipRequest(HttpRequest request, RelationshipTarget target) - : super(request, target); - - ControllerResponse toManyResponse(List identifiers, - {List include}) => - ToManyResponse(this, identifiers); - - ControllerResponse toOneResponse(Identifier identifier, - {List include}) => - ToOneResponse(this, identifier); - - @override - Uri _self(UriFactory factory) => - factory.relationship(target.type, target.id, target.relationship); -} - -class CollectionRequest extends JsonApiRequest { - CollectionRequest(HttpRequest request, CollectionTarget target) - : super(request, target); - - ControllerResponse resourceResponse(Resource modified) => - CreatedResourceResponse(modified); - - ControllerResponse collectionResponse(Collection collection, - {List include}) => - PrimaryCollectionResponse(this, collection, include: include); - - @override - Uri _self(UriFactory factory) => factory.collection(target.type); + Uri generateSelfUri(UriFactory factory) => + httpRequest.uri.queryParameters.isNotEmpty + ? route + .self(factory) + .replace(queryParameters: httpRequest.uri.queryParametersAll) + : route.self(factory); } diff --git a/lib/src/server/request_context.dart b/lib/src/server/request_context.dart new file mode 100644 index 00000000..042e960b --- /dev/null +++ b/lib/src/server/request_context.dart @@ -0,0 +1,19 @@ +import 'dart:convert'; + +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/response.dart'; + +class RequestContext { + RequestContext(this._doc, this._uri); + + final DocumentFactory _doc; + final UriFactory _uri; + + HttpResponse convert(Response r) { + final document = r.document(_doc, _uri); + return HttpResponse(r.status, + body: document == null ? '' : jsonEncode(document), + headers: r.headers(_uri)); + } +} diff --git a/lib/src/server/resolvable_request.dart b/lib/src/server/resolvable_request.dart deleted file mode 100644 index 8187f5f5..00000000 --- a/lib/src/server/resolvable_request.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/controller_response.dart'; -import 'package:json_api/src/server/request.dart'; - -abstract class ResolvableRequest { - Future resolveBy(Controller controller); -} - -class FetchCollection implements ResolvableRequest { - FetchCollection(this.request); - - final CollectionRequest request; - - @override - Future resolveBy(Controller controller) => - controller.fetchCollection(request); -} - -class CreateResource implements ResolvableRequest { - CreateResource(this.request); - - final CollectionRequest request; - - @override - Future resolveBy(Controller controller) => - controller.createResource( - request, ResourceData.fromJson(request.decodePayload()).unwrap()); -} - -class FetchResource implements ResolvableRequest { - FetchResource(this.request); - - final ResourceRequest request; - - @override - Future resolveBy(Controller controller) => - controller.fetchResource(request); -} - -class DeleteResource implements ResolvableRequest { - DeleteResource(this.request); - - final ResourceRequest request; - - @override - Future resolveBy(Controller controller) => - controller.deleteResource(request); -} - -class UpdateResource implements ResolvableRequest { - UpdateResource(this.request); - - final ResourceRequest request; - - @override - Future resolveBy(Controller controller) => - controller.updateResource( - request, ResourceData.fromJson(request.decodePayload()).unwrap()); -} - -class FetchRelated implements ResolvableRequest { - FetchRelated(this.request); - - final RelatedRequest request; - - @override - Future resolveBy(Controller controller) => - controller.fetchRelated(request); -} - -class FetchRelationship implements ResolvableRequest { - FetchRelationship(this.request); - - final RelationshipRequest request; - - @override - Future resolveBy(Controller controller) => - controller.fetchRelationship(request); -} - -class DeleteFromRelationship implements ResolvableRequest { - DeleteFromRelationship(this.request); - - final RelationshipRequest request; - - @override - Future resolveBy(Controller controller) => - controller.deleteFromRelationship( - request, ToMany.fromJson(request.decodePayload()).unwrap()); -} - -class AddToRelationship implements ResolvableRequest { - AddToRelationship(this.request); - - final RelationshipRequest request; - - @override - Future resolveBy(Controller controller) => - controller.addToRelationship( - request, ToMany.fromJson(request.decodePayload()).unwrap()); -} - -class ReplaceRelationship implements ResolvableRequest { - ReplaceRelationship(this.request); - - final RelationshipRequest request; - - @override - Future resolveBy(Controller controller) async { - final r = Relationship.fromJson(request.decodePayload()); - if (r is ToOne) { - return controller.replaceToOne(request, r.unwrap()); - } - if (r is ToMany) { - return controller.replaceToMany(request, r.unwrap()); - } - throw IncompleteRelationshipException(); - } -} - -/// Thrown if the relationship object has no data -class IncompleteRelationshipException implements Exception {} diff --git a/lib/src/server/controller_response.dart b/lib/src/server/response.dart similarity index 84% rename from lib/src/server/controller_response.dart rename to lib/src/server/response.dart index f43635d3..efc87567 100644 --- a/lib/src/server/controller_response.dart +++ b/lib/src/server/response.dart @@ -3,8 +3,9 @@ import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/target.dart'; -abstract class ControllerResponse { +abstract class Response { int get status; Map headers(UriFactory factory); @@ -12,7 +13,7 @@ abstract class ControllerResponse { Document document(DocumentFactory doc, UriFactory factory); } -class ErrorResponse implements ControllerResponse { +class ErrorResponse implements Response { ErrorResponse(this.status, this.errors); @override @@ -28,7 +29,26 @@ class ErrorResponse implements ControllerResponse { doc.error(errors); } -class NoContentResponse implements ControllerResponse { +class ExtraHeaders implements Response { + ExtraHeaders(this._response, this._headers); + + final Response _response; + + final Map _headers; + + @override + Document document(DocumentFactory doc, UriFactory factory) => + _response.document(doc, factory); + + @override + Map headers(UriFactory factory) => + {..._response.headers(factory), ..._headers}; + + @override + int get status => _response.status; +} + +class NoContentResponse implements Response { NoContentResponse(); @override @@ -41,10 +61,10 @@ class NoContentResponse implements ControllerResponse { Document document(DocumentFactory doc, UriFactory factory) => null; } -class PrimaryResourceResponse implements ControllerResponse { +class PrimaryResourceResponse implements Response { PrimaryResourceResponse(this.request, this.resource, {this.include}); - final ResourceRequest request; + final Request request; final Resource resource; final List include; @@ -62,10 +82,10 @@ class PrimaryResourceResponse implements ControllerResponse { include: include, self: request.generateSelfUri(factory)); } -class RelatedResourceResponse implements ControllerResponse { +class RelatedResourceResponse implements Response { RelatedResourceResponse(this.request, this.resource, {this.include}); - final RelatedRequest request; + final Request request; final Resource resource; final List include; @@ -82,7 +102,7 @@ class RelatedResourceResponse implements ControllerResponse { include: include, self: request.generateSelfUri(factory)); } -class CreatedResourceResponse implements ControllerResponse { +class CreatedResourceResponse implements Response { CreatedResourceResponse(this.resource); final Resource resource; @@ -101,10 +121,10 @@ class CreatedResourceResponse implements ControllerResponse { doc.resource(factory, resource); } -class PrimaryCollectionResponse implements ControllerResponse { +class PrimaryCollectionResponse implements Response { PrimaryCollectionResponse(this.request, this.collection, {this.include}); - final CollectionRequest request; + final Request request; final Collection collection; final List include; @@ -122,10 +142,10 @@ class PrimaryCollectionResponse implements ControllerResponse { include: include, self: request.generateSelfUri(factory)); } -class RelatedCollectionResponse implements ControllerResponse { +class RelatedCollectionResponse implements Response { RelatedCollectionResponse(this.request, this.collection, {this.include}); - final RelatedRequest request; + final Request request; final Collection collection; final List include; @@ -143,10 +163,10 @@ class RelatedCollectionResponse implements ControllerResponse { include: include, self: request.generateSelfUri(factory)); } -class ToOneResponse implements ControllerResponse { +class ToOneResponse implements Response { ToOneResponse(this.request, this.identifier); - final RelationshipRequest request; + final Request request; final Identifier identifier; @@ -165,10 +185,10 @@ class ToOneResponse implements ControllerResponse { request.target.relationship)); } -class ToManyResponse implements ControllerResponse { +class ToManyResponse implements Response { ToManyResponse(this.request, this.identifiers); - final RelationshipRequest request; + final Request request; final List identifiers; diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart index 74e0f5b4..8ddfafee 100644 --- a/lib/src/server/route.dart +++ b/lib/src/server/route.dart @@ -1,138 +1,169 @@ +import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/resolvable_request.dart'; +import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/target.dart'; -abstract class Route { +abstract class Route { List get allowedMethods; - ResolvableRequest convertRequest(HttpRequest request); + T get target; + + Future dispatch(HttpRequest request, Controller controller); + + Uri self(UriFactory uriFactory); } -class RouteFactory implements UriMatchHandler { - Route route; + +class CorsEnabled implements Route { + CorsEnabled(this._route); + + final Route _route; @override - void collection(String type) { - route = CollectionRoute(type); - } + List get allowedMethods => _route.allowedMethods + ['OPTIONS']; @override - void related(String type, String id, String relationship) { - route = RelatedRoute(type, id, relationship); + Future dispatch(HttpRequest request, Controller controller) async { + if (request.isOptions) { + return ExtraHeaders(NoContentResponse(), { + 'Access-Control-Allow-Methods': allowedMethods.join(', '), + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Max-Age': '3600', + }); + } + return ExtraHeaders(await _route.dispatch(request, controller), + {'Access-Control-Allow-Origin': '*'}); } @override - void relationship(String type, String id, String relationship) { - route = RelationshipRoute(type, id, relationship); - } + Uri self(UriFactory uriFactory) => _route.self(uriFactory); @override - void resource(String type, String id) { - route = ResourceRoute(type, id); - } + T get target => _route.target; } -class CollectionRoute implements Route, CollectionTarget { - CollectionRoute(this.type); +class CollectionRoute implements Route { + CollectionRoute(this.target); @override - final String type; + final CollectionTarget target; @override final allowedMethods = ['GET', 'POST']; @override - ResolvableRequest convertRequest(HttpRequest request) { - final r = CollectionRequest(request, this); + Future dispatch(HttpRequest request, Controller controller) { + final r = Request(request, this); if (request.isGet) { - return FetchCollection(r); + return controller.fetchCollection(r); } if (request.isPost) { - return CreateResource(r); + return controller.createResource( + r, ResourceData.fromJson(r.decodePayload()).unwrap()); } throw ArgumentError(); } + + @override + Uri self(UriFactory uriFactory) => uriFactory.collection(target.type); } -class ResourceRoute implements Route, ResourceTarget { - ResourceRoute(this.type, this.id); +class ResourceRoute implements Route { + ResourceRoute(this.target); @override - final String type; - @override - final String id; + final ResourceTarget target; @override final allowedMethods = ['DELETE', 'GET', 'PATCH']; @override - ResolvableRequest convertRequest(HttpRequest request) { - final r = ResourceRequest(request, this); + Future dispatch(HttpRequest request, Controller controller) { + final r = Request(request, this); if (request.isDelete) { - return DeleteResource(r); + return controller.deleteResource(r); } if (request.isGet) { - return FetchResource(r); + return controller.fetchResource(r); } if (request.isPatch) { - return UpdateResource(r); + return controller.updateResource( + r, ResourceData.fromJson(r.decodePayload()).unwrap()); } throw ArgumentError(); } + + @override + Uri self(UriFactory uriFactory) => + uriFactory.resource(target.type, target.id); } -class RelatedRoute implements Route, RelationshipTarget { - RelatedRoute(this.type, this.id, this.relationship); +class RelatedRoute implements Route { + RelatedRoute(this.target); @override - final String type; - @override - final String id; - @override - final String relationship; + final RelationshipTarget target; @override final allowedMethods = ['GET']; @override - ResolvableRequest convertRequest(HttpRequest request) { + Future dispatch(HttpRequest request, Controller controller) { if (request.isGet) { - return FetchRelated(RelatedRequest(request, this)); + return controller.fetchRelated(Request(request, this)); } throw ArgumentError(); } + + @override + Uri self(UriFactory uriFactory) => + uriFactory.related(target.type, target.id, target.relationship); } -class RelationshipRoute implements Route, RelationshipTarget { - RelationshipRoute(this.type, this.id, this.relationship); +class RelationshipRoute implements Route { + RelationshipRoute(this.target); @override - final String type; - @override - final String id; - @override - final String relationship; + final RelationshipTarget target; @override final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; @override - ResolvableRequest convertRequest(HttpRequest request) { - final r = RelationshipRequest(request, this); + Future dispatch(HttpRequest request, Controller controller) { + final r = Request(request, this); if (request.isDelete) { - return DeleteFromRelationship(r); + return controller.deleteFromRelationship( + r, ToMany.fromJson(r.decodePayload()).unwrap()); } if (request.isGet) { - return FetchRelationship(r); + return controller.fetchRelationship(r); } if (request.isPatch) { - return ReplaceRelationship(r); + final rel = Relationship.fromJson(r.decodePayload()); + if (rel is ToOne) { + return controller.replaceToOne(r, rel.unwrap()); + } + if (rel is ToMany) { + return controller.replaceToMany(r, rel.unwrap()); + } + throw IncompleteRelationshipException(); } if (request.isPost) { - return AddToRelationship(r); + return controller.addToRelationship( + r, ToMany.fromJson(r.decodePayload()).unwrap()); } throw ArgumentError(); } + + @override + Uri self(UriFactory uriFactory) => + uriFactory.relationship(target.type, target.id, target.relationship); } + +/// Thrown if the relationship object has no data +class IncompleteRelationshipException implements Exception {} diff --git a/lib/src/server/route_matcher.dart b/lib/src/server/route_matcher.dart new file mode 100644 index 00000000..08cfd584 --- /dev/null +++ b/lib/src/server/route_matcher.dart @@ -0,0 +1,27 @@ +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/route.dart'; +import 'package:json_api/src/server/target.dart'; + +class RouteMatcher implements UriMatchHandler { + Route route; + + @override + void collection(String type) { + route = CollectionRoute(CollectionTarget(type)); + } + + @override + void related(String type, String id, String relationship) { + route = RelatedRoute(RelationshipTarget(type, id, relationship)); + } + + @override + void relationship(String type, String id, String relationship) { + route = RelationshipRoute(RelationshipTarget(type, id, relationship)); + } + + @override + void resource(String type, String id) { + route = ResourceRoute(ResourceTarget(type, id)); + } +} diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart index cccd0688..df7804ae 100644 --- a/lib/src/server/target.dart +++ b/lib/src/server/target.dart @@ -1,11 +1,26 @@ -abstract class CollectionTarget { - String get type; +class CollectionTarget { + CollectionTarget(this.type); + + final String type; } -abstract class ResourceTarget implements CollectionTarget { - String get id; +class ResourceTarget implements CollectionTarget { + ResourceTarget(this.type, this.id); + + @override + final String type; + + final String id; } -abstract class RelationshipTarget implements ResourceTarget { - String get relationship; +class RelationshipTarget implements ResourceTarget { + RelationshipTarget(this.type, this.id, this.relationship); + + @override + final String type; + + @override + final String id; + + final String relationship; } diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 04d1da47..453fc5c3 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -19,7 +19,7 @@ void main() async { httpClient.close(); }); - test('can fetch collection', () async { + test('can create and fetch', () async { final channel = spawnHybridUri('hybrid_server.dart', message: port); await channel.stream.first; diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart index b391a97e..1ae27cb8 100644 --- a/test/unit/server/json_api_server_test.dart +++ b/test/unit/server/json_api_server_test.dart @@ -100,5 +100,18 @@ void main() { expect(rs.statusCode, 405); expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST, OPTIONS'); }); + + test('options request contains no body', () async { + final rq = + HttpRequest('OPTIONS', routing.relationship('books', '1', 'author')); + final rs = await server(rq); + expect(rs.headers['access-control-allow-methods'], + 'DELETE, GET, PATCH, POST, OPTIONS'); + expect(rs.headers['access-control-allow-headers'], 'Content-Type'); + expect(rs.headers['access-control-allow-origin'], '*'); + expect(rs.headers['access-control-allow-max-age'], '3600'); + expect(rs.statusCode, 204); + expect(rs.body, ''); + }); }); } From 9b1f85871c490a66a9f90bcc53e0fc2548ebb8af Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 18 Apr 2020 17:49:55 -0700 Subject: [PATCH 50/99] WIP --- lib/src/server/json_api_server.dart | 63 +------ lib/src/server/repository.dart | 4 +- lib/src/server/repository_controller.dart | 16 +- lib/src/server/request.dart | 14 +- lib/src/server/request_context.dart | 19 -- lib/src/server/response.dart | 218 ++++------------------ lib/src/server/response_factory.dart | 178 ++++++++++++++++++ lib/src/server/route.dart | 88 ++++++++- lib/src/server/route_matcher.dart | 12 +- 9 files changed, 327 insertions(+), 285 deletions(-) delete mode 100644 lib/src/server/request_context.dart create mode 100644 lib/src/server/response_factory.dart diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index b8b411d3..92184dd7 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -1,76 +1,31 @@ import 'dart:async'; -import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/request_context.dart'; -import 'package:json_api/src/server/response.dart'; +import 'package:json_api/src/server/response_factory.dart'; import 'package:json_api/src/server/route.dart'; import 'package:json_api/src/server/route_matcher.dart'; /// A simple implementation of JSON:API server class JsonApiServer implements HttpHandler { JsonApiServer(this._controller, - {Routing routing, DocumentFactory documentFactory}) + {Routing routing, ResponseFactory responseFactory}) : _routing = routing ?? StandardRouting(), - _doc = documentFactory ?? DocumentFactory(); + _rf = responseFactory ?? + HttpResponseFactory(routing ?? StandardRouting()); final Routing _routing; + final ResponseFactory _rf; final Controller _controller; - final DocumentFactory _doc; @override Future call(HttpRequest httpRequest) async { - final context = RequestContext(_doc, _routing); - final matcher = RouteMatcher(); _routing.match(httpRequest.uri, matcher); - final route = matcher.route; - - if (route == null) { - return context.convert(ErrorResponse(404, [ - ErrorObject( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server', - ) - ])); - } - - final route2 = CorsEnabled(route); - - if (!route2.allowedMethods.contains(httpRequest.method)) { - return context.convert(ExtraHeaders( - ErrorResponse(405, []), {'Allow': route2.allowedMethods.join(', ')})); - } - - try { - return context.convert(await route2.dispatch(httpRequest, _controller)); - } on FormatException catch (e) { - return context.convert(ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: 'Invalid JSON. ${e.message}', - ) - ])); - } on DocumentException catch (e) { - return context.convert(ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: e.message, - ) - ])); - } on IncompleteRelationshipException { - return context.convert(ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: 'Incomplete relationship object', - ) - ])); - } + return (await ErrorHandling(CorsEnabled(matcher.getMatchedRouteOrElse( + () => UnmatchedRoute(allowedMethods: [httpRequest.method])))) + .dispatch(httpRequest, _controller)) + .convert(_rf); } } diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart index 2907aad6..41673861 100644 --- a/lib/src/server/repository.dart +++ b/lib/src/server/repository.dart @@ -29,12 +29,12 @@ abstract class Repository { /// Returns the resource by [type] and [id]. Future get(String type, String id); - /// Updates the resource identified by [route]. + /// Updates the resource identified by [type] and [id]. /// If the resource was modified during update, returns the modified resource. /// Otherwise returns null. Future update(String type, String id, Resource resource); - /// Deletes the resource identified by [route] + /// Deletes the resource identified by [type] and [id] Future delete(String type, String id); /// Returns a collection of resources diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 0d11cf78..4dfd3e76 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -3,10 +3,10 @@ import 'dart:async'; import 'package:json_api/document.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/repository.dart'; import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/target.dart'; /// An opinionated implementation of [Controller]. Translates JSON:API @@ -54,7 +54,7 @@ class RepositoryController implements Controller { if (modified == null) { return NoContentResponse(); } - return CreatedResourceResponse(modified); + return CreatedResourceResponse(request, modified); }); @override @@ -84,8 +84,7 @@ class RepositoryController implements Controller { }); @override - Future fetchCollection( - Request request) => + Future fetchCollection(Request request) => _do(() async { final limit = _pagination.limit(request.page); final offset = _pagination.offset(request.page); @@ -104,8 +103,7 @@ class RepositoryController implements Controller { }); @override - Future fetchRelated( - Request request) => + Future fetchRelated(Request request) => _do(() async { final resource = await _repo.get(request.target.type, request.target.id); @@ -127,8 +125,7 @@ class RepositoryController implements Controller { }); @override - Future fetchRelationship( - Request request) => + Future fetchRelationship(Request request) => _do(() async { final resource = await _repo.get(request.target.type, request.target.id); @@ -221,8 +218,7 @@ class RepositoryController implements Controller { Map.fromIterable(included, key: (_) => '${_.type}:${_.id}').values; - Future _do( - Future Function() action) async { + Future _do(Future Function() action) async { try { return await action(); } on UnsupportedOperation catch (e) { diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 9697a45c..98858c67 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -3,11 +3,10 @@ import 'dart:convert'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/route.dart'; import 'package:json_api/src/server/target.dart'; class Request { - Request(this.httpRequest, this.route) + Request(this.httpRequest, this.target, this.self) : sort = Sort.fromUri(httpRequest.uri), include = Include.fromUri(httpRequest.uri), page = Page.fromUri(httpRequest.uri); @@ -16,19 +15,18 @@ class Request { final Include include; final Page page; final Sort sort; - final Route route; - - T get target => route.target; + final T target; Object decodePayload() => jsonDecode(httpRequest.body); bool get isCompound => include.isNotEmpty; + final Uri Function(UriFactory) self; + /// Generates the 'self' link preserving original query parameters Uri generateSelfUri(UriFactory factory) => httpRequest.uri.queryParameters.isNotEmpty - ? route - .self(factory) + ? self(factory) .replace(queryParameters: httpRequest.uri.queryParametersAll) - : route.self(factory); + : self(factory); } diff --git a/lib/src/server/request_context.dart b/lib/src/server/request_context.dart deleted file mode 100644 index 042e960b..00000000 --- a/lib/src/server/request_context.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/response.dart'; - -class RequestContext { - RequestContext(this._doc, this._uri); - - final DocumentFactory _doc; - final UriFactory _uri; - - HttpResponse convert(Response r) { - final document = r.document(_doc, _uri); - return HttpResponse(r.status, - body: document == null ? '' : jsonEncode(document), - headers: r.headers(_uri)); - } -} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index efc87567..231a24c5 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,85 +1,55 @@ import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/response_factory.dart'; import 'package:json_api/src/server/target.dart'; abstract class Response { - int get status; - - Map headers(UriFactory factory); - - Document document(DocumentFactory doc, UriFactory factory); -} - -class ErrorResponse implements Response { - ErrorResponse(this.status, this.errors); - - @override - final int status; - final List errors; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; - - @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.error(errors); + HttpResponse convert(ResponseFactory f); } class ExtraHeaders implements Response { ExtraHeaders(this._response, this._headers); final Response _response; - final Map _headers; @override - Document document(DocumentFactory doc, UriFactory factory) => - _response.document(doc, factory); + HttpResponse convert(ResponseFactory f) { + final r = _response.convert(f); + return HttpResponse(r.statusCode, + body: r.body, headers: {...r.headers, ..._headers}); + } +} - @override - Map headers(UriFactory factory) => - {..._response.headers(factory), ..._headers}; +class ErrorResponse implements Response { + ErrorResponse(this.status, this.errors); + + final int status; + final Iterable errors; @override - int get status => _response.status; + HttpResponse convert(ResponseFactory f) => f.error(status, errors: errors); } class NoContentResponse implements Response { NoContentResponse(); @override - int get status => 204; - - @override - Map headers(UriFactory factory) => {}; - - @override - Document document(DocumentFactory doc, UriFactory factory) => null; + HttpResponse convert(ResponseFactory f) => f.noContent(); } class PrimaryResourceResponse implements Response { PrimaryResourceResponse(this.request, this.resource, {this.include}); final Request request; - final Resource resource; final List include; @override - int get status => 200; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; - - @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.resource(factory, resource, - include: include, self: request.generateSelfUri(factory)); + HttpResponse convert(ResponseFactory f) => + f.primaryResource(request, resource, include: include); } class RelatedResourceResponse implements Response { @@ -87,38 +57,22 @@ class RelatedResourceResponse implements Response { final Request request; final Resource resource; - final List include; - - @override - int get status => 200; + final Iterable include; @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; - - @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.resource(factory, resource, - include: include, self: request.generateSelfUri(factory)); + HttpResponse convert(ResponseFactory f) => + f.relatedResource(request, resource, include: include); } class CreatedResourceResponse implements Response { - CreatedResourceResponse(this.resource); + CreatedResourceResponse(this.request, this.resource); + final Request request; final Resource resource; @override - int get status => 201; - - @override - Map headers(UriFactory factory) => { - 'Content-Type': Document.contentType, - 'Location': factory.resource(resource.type, resource.id).toString() - }; - - @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.resource(factory, resource); + HttpResponse convert(ResponseFactory f) => + f.createdResource(request, resource); } class PrimaryCollectionResponse implements Response { @@ -126,20 +80,11 @@ class PrimaryCollectionResponse implements Response { final Request request; final Collection collection; - final List include; - - @override - int get status => 200; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; + final Iterable include; @override - Document document( - DocumentFactory doc, UriFactory factory) => - doc.collection(factory, collection, - include: include, self: request.generateSelfUri(factory)); + HttpResponse convert(ResponseFactory f) => + f.primaryCollection(request, collection, include: include); } class RelatedCollectionResponse implements Response { @@ -150,121 +95,28 @@ class RelatedCollectionResponse implements Response { final List include; @override - int get status => 200; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; - - @override - Document document( - DocumentFactory doc, UriFactory factory) => - doc.collection(factory, collection, - include: include, self: request.generateSelfUri(factory)); + HttpResponse convert(ResponseFactory f) => + f.relatedCollection(request, collection, include: include); } class ToOneResponse implements Response { ToOneResponse(this.request, this.identifier); final Request request; - final Identifier identifier; @override - int get status => 200; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; - - @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.toOne(identifier, - self: request.generateSelfUri(factory), - related: factory.related(request.target.type, request.target.id, - request.target.relationship)); + HttpResponse convert(ResponseFactory f) => + f.relationshipToOne(request, identifier); } class ToManyResponse implements Response { ToManyResponse(this.request, this.identifiers); final Request request; - - final List identifiers; - - @override - int get status => 200; - - @override - Map headers(UriFactory factory) => - {'Content-Type': Document.contentType}; + final Iterable identifiers; @override - Document document(DocumentFactory doc, UriFactory factory) => - doc.toMany(identifiers, - self: request.generateSelfUri(factory), - related: factory.related(request.target.type, request.target.id, - request.target.relationship)); -} - -class DocumentFactory { - Document error(List errors) => Document.error(errors); - - Document collection( - UriFactory factory, Collection collection, - {List include, Uri self}) => - Document(ResourceCollectionData( - collection.elements.map((_) => _resource(factory, _)), - links: {if (self != null) 'self': Link(self)}, - include: include?.map((_) => _resource(factory, _)))); - - Document resource(UriFactory factory, Resource resource, - {List include, Uri self}) => - Document(ResourceData(_resource(factory, resource), - links: {if (self != null) 'self': Link(self)}, - include: include?.map((_) => _resource(factory, _)))); - - Document toOne(Identifier identifier, {Uri self, Uri related}) => - Document(ToOne( - IdentifierObject.fromIdentifier(identifier), - links: { - if (self != null) 'self': Link(self), - if (related != null) 'related': Link(related), - }, - )); - - Document toMany(List identifiers, - {Uri self, Uri related}) => - Document(ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: { - if (self != null) 'self': Link(self), - if (related != null) 'related': Link(related), - }, - )); - - ResourceObject _resource(UriFactory factory, Resource resource) => - ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: { - ...resource.toOne.map((k, v) => MapEntry( - k, - ToOne(nullable(IdentifierObject.fromIdentifier)(v), links: { - 'self': - Link(factory.relationship(resource.type, resource.id, k)), - 'related': - Link(factory.related(resource.type, resource.id, k)), - }))), - ...resource.toMany.map((k, v) => MapEntry( - k, - ToMany(v.map(IdentifierObject.fromIdentifier), links: { - 'self': - Link(factory.relationship(resource.type, resource.id, k)), - 'related': - Link(factory.related(resource.type, resource.id, k)), - }))), - }, - links: { - 'self': Link(factory.resource(resource.type, resource.id)) - }); + HttpResponse convert(ResponseFactory f) => + f.relationshipToMany(request, identifiers); } diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart new file mode 100644 index 00000000..0e4baaa3 --- /dev/null +++ b/lib/src/server/response_factory.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/collection.dart'; +import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/target.dart'; + +abstract class ResponseFactory { + HttpResponse error(int status, + {Iterable errors, Map headers}); + + HttpResponse noContent(); + + HttpResponse primaryResource( + Request request, Resource resource, + {Iterable include}); + + HttpResponse relatedResource( + Request request, Resource resource, + {Iterable include}); + + HttpResponse createdResource( + Request request, Resource resource); + + HttpResponse primaryCollection( + Request request, Collection collection, + {Iterable include}); + + HttpResponse relatedCollection( + Request request, Collection collection, + {List include}); + + HttpResponse relationshipToOne( + Request request, Identifier identifier); + + HttpResponse relationshipToMany( + Request request, Iterable identifiers); +} + +class HttpResponseFactory implements ResponseFactory { + HttpResponseFactory(this._uri); + + final UriFactory _uri; + + @override + HttpResponse error(int status, + {Iterable errors, Map headers}) => + HttpResponse(status, + body: jsonEncode(Document.error(errors ?? [])), + headers: {...(headers ?? {}), 'Content-Type': Document.contentType}); + + @override + HttpResponse noContent() => HttpResponse(204); + + @override + HttpResponse primaryResource( + Request request, Resource resource, + {Iterable include}) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ResourceData(_resource(resource), + links: {'self': Link(request.generateSelfUri(_uri))}, + include: request.isCompound + ? (include ?? []).map(_resource) + : null)))); + + @override + HttpResponse createdResource( + Request request, Resource resource) => + HttpResponse(201, + headers: { + 'Content-Type': Document.contentType, + 'Location': _uri.resource(resource.type, resource.id).toString() + }, + body: jsonEncode(Document(ResourceData(_resource(resource), links: { + 'self': Link(_uri.resource(resource.type, resource.id)) + })))); + + @override + HttpResponse primaryCollection( + Request request, Collection collection, + {Iterable include}) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ResourceCollectionData( + collection.elements.map(_resource), + links: {'self': Link(request.generateSelfUri(_uri))}, + include: request.isCompound + ? (include ?? []).map(_resource) + : null)))); + + @override + HttpResponse relatedCollection( + Request request, Collection collection, + {List include}) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ResourceCollectionData( + collection.elements.map(_resource), + links: { + 'self': Link(request.generateSelfUri(_uri)), + 'related': Link(_uri.related(request.target.type, + request.target.id, request.target.relationship)) + }, + include: request.isCompound + ? (include ?? []).map(_resource) + : null)))); + + @override + HttpResponse relatedResource( + Request request, Resource resource, + {Iterable include}) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ResourceData(_resource(resource), + links: { + 'self': Link(request.generateSelfUri(_uri)), + 'related': Link(_uri.related(request.target.type, + request.target.id, request.target.relationship)) + }, + include: request.isCompound + ? (include ?? []).map(_resource) + : null)))); + + @override + HttpResponse relationshipToMany(Request request, + Iterable identifiers) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ToMany( + identifiers.map(IdentifierObject.fromIdentifier), + links: { + 'self': Link(request.generateSelfUri(_uri)), + 'related': Link(_uri.related(request.target.type, + request.target.id, request.target.relationship)) + }, + )))); + + @override + HttpResponse relationshipToOne( + Request request, Identifier identifier) => + HttpResponse(200, + headers: {'Content-Type': Document.contentType}, + body: jsonEncode(Document(ToOne( + IdentifierObject.fromIdentifier(identifier), + links: { + 'self': Link(request.generateSelfUri(_uri)), + 'related': Link(_uri.related(request.target.type, + request.target.id, request.target.relationship)) + }, + )))); + + ResourceObject _resource(Resource resource) => + ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: { + ...resource.toOne.map((k, v) => MapEntry( + k, + ToOne(nullable(IdentifierObject.fromIdentifier)(v), links: { + 'self': + Link(_uri.relationship(resource.type, resource.id, k)), + 'related': Link(_uri.related(resource.type, resource.id, k)), + }))), + ...resource.toMany.map((k, v) => MapEntry( + k, + ToMany(v.map(IdentifierObject.fromIdentifier), links: { + 'self': + Link(_uri.relationship(resource.type, resource.id, k)), + 'related': Link(_uri.related(resource.type, resource.id, k)), + }))), + }, + links: { + 'self': Link(_uri.resource(resource.type, resource.id)) + }); +} diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart index 8ddfafee..16141b5d 100644 --- a/lib/src/server/route.dart +++ b/lib/src/server/route.dart @@ -16,6 +16,83 @@ abstract class Route { Uri self(UriFactory uriFactory); } +class UnmatchedRoute implements Route { + UnmatchedRoute({this.allowedMethods = const []}); + + @override + final allowedMethods; + + @override + Future dispatch(HttpRequest request, Controller controller) async => + ErrorResponse(404, [ + ErrorObject( + status: '404', + title: 'Not Found', + detail: 'The requested URL does exist on the server', + ) + ]); + + @override + Uri self(UriFactory uriFactory) { + // TODO: Remove this method + throw StateError('Fixme'); + } + + @override + // TODO: implement target + CollectionTarget get target => null; +} + +class ErrorHandling implements Route { + ErrorHandling(this._route); + + final Route _route; + + @override + List get allowedMethods => _route.allowedMethods; + + @override + Future dispatch(HttpRequest request, Controller controller) async { + if (!_route.allowedMethods.contains(request.method)) { + return ExtraHeaders( + ErrorResponse(405, []), {'Allow': _route.allowedMethods.join(', ')}); + } + try { + return await _route.dispatch(request, controller); + } on FormatException catch (e) { + return ErrorResponse(400, [ + ErrorObject( + status: '400', + title: 'Bad Request', + detail: 'Invalid JSON. ${e.message}', + ) + ]); + } on DocumentException catch (e) { + return ErrorResponse(400, [ + ErrorObject( + status: '400', + title: 'Bad Request', + detail: e.message, + ) + ]); + } on IncompleteRelationshipException { + return ErrorResponse(400, [ + ErrorObject( + status: '400', + title: 'Bad Request', + detail: 'Incomplete relationship object', + ) + ]); + } + } + + @override + Uri self(UriFactory uriFactory) => _route.self(uriFactory); + + @override +// TODO: implement target + CollectionTarget get target => null; +} class CorsEnabled implements Route { CorsEnabled(this._route); @@ -57,7 +134,7 @@ class CollectionRoute implements Route { @override Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request, this); + final r = Request(request, target, (_) => _.collection(target.type)); if (request.isGet) { return controller.fetchCollection(r); } @@ -83,7 +160,8 @@ class ResourceRoute implements Route { @override Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request, this); + final r = + Request(request, target, (_) => _.resource(target.type, target.id)); if (request.isDelete) { return controller.deleteResource(r); } @@ -114,7 +192,8 @@ class RelatedRoute implements Route { @override Future dispatch(HttpRequest request, Controller controller) { if (request.isGet) { - return controller.fetchRelated(Request(request, this)); + return controller.fetchRelated(Request(request, target, + (_) => _.related(target.type, target.id, target.relationship))); } throw ArgumentError(); } @@ -135,7 +214,8 @@ class RelationshipRoute implements Route { @override Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request, this); + final r = Request(request, target, + (_) => _.relationship(target.type, target.id, target.relationship)); if (request.isDelete) { return controller.deleteFromRelationship( r, ToMany.fromJson(r.decodePayload()).unwrap()); diff --git a/lib/src/server/route_matcher.dart b/lib/src/server/route_matcher.dart index 08cfd584..ad64bd2f 100644 --- a/lib/src/server/route_matcher.dart +++ b/lib/src/server/route_matcher.dart @@ -3,25 +3,27 @@ import 'package:json_api/src/server/route.dart'; import 'package:json_api/src/server/target.dart'; class RouteMatcher implements UriMatchHandler { - Route route; + Route _match; @override void collection(String type) { - route = CollectionRoute(CollectionTarget(type)); + _match = CollectionRoute(CollectionTarget(type)); } @override void related(String type, String id, String relationship) { - route = RelatedRoute(RelationshipTarget(type, id, relationship)); + _match = RelatedRoute(RelationshipTarget(type, id, relationship)); } @override void relationship(String type, String id, String relationship) { - route = RelationshipRoute(RelationshipTarget(type, id, relationship)); + _match = RelationshipRoute(RelationshipTarget(type, id, relationship)); } @override void resource(String type, String id) { - route = ResourceRoute(ResourceTarget(type, id)); + _match = ResourceRoute(ResourceTarget(type, id)); } + + Route getMatchedRouteOrElse(Route Function() orElse) => _match ?? orElse(); } From d3f28608a12fd41020e19a5ee7098702c6fb7104 Mon Sep 17 00:00:00 2001 From: Mark Shropshire Date: Sun, 19 Apr 2020 13:46:36 -0400 Subject: [PATCH 51/99] Corrected spelling and punctuation (#90) --- example/client.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/example/client.dart b/example/client.dart index 0533b5e7..35a0defd 100644 --- a/example/client.dart +++ b/example/client.dart @@ -15,7 +15,7 @@ void main() async { /// Do not forget to call [Client.close] when you're done using it. final httpClient = Client(); - /// We'll use a logging handler to how the requests and responses + /// We'll use a logging handler to show the requests and responses. final httpHandler = LoggingHttpHandler(DartHttp(httpClient), onRequest: (r) => print('${r.method} ${r.uri}'), onResponse: (r) => print('${r.statusCode}')); @@ -23,25 +23,25 @@ void main() async { /// The JSON:API client final client = RoutingClient(JsonApiClient(httpHandler), routing); - /// Create the first resource + /// Create the first resource. await client.createResource( Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); - /// Create the second resource + /// Create the second resource. await client.createResource(Resource('books', '2', attributes: { 'title': 'Refactoring' }, toMany: { 'authors': [Identifier('writers', '1')] })); - /// Fetch the book, including its authors + /// Fetch the book, including its authors. final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); - /// Extract the primary resource + /// Extract the primary resource. final book = response.data.unwrap(); - /// Extract the included resource + /// Extract the included resource. final author = response.data.included.first.unwrap(); print('Book: $book'); From 3726b4f739f602b18877f289fee173b7ab7e9e74 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 19 Apr 2020 17:11:01 -0700 Subject: [PATCH 52/99] wip --- lib/routing.dart | 1 + lib/src/client/response.dart | 6 +- lib/src/document/document.dart | 65 ++++++++++-- lib/src/document/primary_data.dart | 14 +-- lib/src/document/relationship.dart | 17 +-- .../document/resource_collection_data.dart | 10 +- lib/src/document/resource_data.dart | 17 +-- lib/src/routing/target.dart | 51 +++++++++ lib/src/server/controller.dart | 4 +- lib/src/server/repository_controller.dart | 4 +- lib/src/server/request.dart | 26 ++--- lib/src/server/response.dart | 6 +- lib/src/server/response_factory.dart | 72 +++++++------ lib/src/server/route.dart | 100 +++++------------- lib/src/server/route_matcher.dart | 3 +- lib/src/server/target.dart | 26 ----- test/e2e/browser_test.dart | 2 +- test/e2e/client_server_interaction_test.dart | 2 +- test/functional/compound_document_test.dart | 80 +++++++------- test/unit/document/resource_data_test.dart | 35 ------ 20 files changed, 247 insertions(+), 294 deletions(-) create mode 100644 lib/src/routing/target.dart delete mode 100644 lib/src/server/target.dart diff --git a/lib/routing.dart b/lib/routing.dart index 67705cc6..2857daec 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,3 +1,4 @@ export 'package:json_api/src/routing/composite_routing.dart'; export 'package:json_api/src/routing/contract.dart'; export 'package:json_api/src/routing/standard.dart'; +export 'package:json_api/src/routing/target.dart'; diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 8fb34cff..6b33c9e3 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -3,7 +3,7 @@ import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/nullable.dart'; /// A response returned by JSON:API client -class Response { +class Response { const Response(this.statusCode, this.headers, {this.document, this.asyncDocument}); @@ -12,7 +12,7 @@ class Response { /// Document parsed from the response body. /// May be null. - final Document document; + final Document document; /// The document received with `202 Accepted` response (if any) /// https://jsonapi.org/recommendations/#asynchronous-processing @@ -23,7 +23,7 @@ class Response { /// Primary Data from the document (if any). For unsuccessful operations /// this property will be null, the error details may be found in [Document.errors]. - Data get data => document?.data; + D get data => document?.data; /// List of errors (if any) returned by the server in case of an unsuccessful /// operation. May be empty. Will be null if the operation was successful. diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 50bab9ff..bd84201d 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -1,3 +1,4 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/document/api.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/error_object.dart'; @@ -5,13 +6,16 @@ import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/nullable.dart'; -class Document implements JsonEncodable { +class Document implements JsonEncodable { /// Create a document with primary data - Document(this.data, {Map meta, Api api}) + Document(this.data, + {Map meta, Api api, Iterable included}) : errors = const [], + included = List.unmodifiable(included ?? []), meta = Map.unmodifiable(meta ?? const {}), api = api ?? Api(), isError = false, + isCompound = included != null, isMeta = false { ArgumentError.checkNotNull(data); } @@ -20,9 +24,11 @@ class Document implements JsonEncodable { Document.error(Iterable errors, {Map meta, Api api}) : data = null, + included = const [], meta = Map.unmodifiable(meta ?? const {}), errors = List.unmodifiable(errors ?? const []), api = api ?? Api(), + isCompound = false, isError = true, isMeta = false; @@ -30,15 +36,22 @@ class Document implements JsonEncodable { Document.empty(Map meta, {Api api}) : data = null, meta = Map.unmodifiable(meta ?? const {}), + included = const [], errors = const [], api = api ?? Api(), isError = false, + isCompound = false, isMeta = true { ArgumentError.checkNotNull(meta); } /// The Primary Data. May be null. - final Data data; + final D data; + + /// Included objects in a compound document + final List included; + + final bool isCompound; /// List of errors. May be empty or null. final List errors; @@ -60,16 +73,22 @@ class Document implements JsonEncodable { Object json, Data Function(Object json) primaryData) { if (json is Map) { final api = nullable(Api.fromJson)(json['jsonapi']); + final meta = json['meta']; if (json.containsKey('errors')) { final errors = json['errors']; if (errors is List) { return Document.error(errors.map(ErrorObject.fromJson), - meta: json['meta'], api: api); + meta: meta, api: api); } } else if (json.containsKey('data')) { - return Document(primaryData(json), meta: json['meta'], api: api); + final included = json['included']; + final doc = Document(primaryData(json), meta: meta, api: api); + if (included is List) { + return CompoundDocument(doc, included.map(ResourceObject.fromJson)); + } + return doc; } else if (json['meta'] != null) { - return Document.empty(json['meta'], api: api); + return Document.empty(meta, api: api); } throw DocumentException('Unrecognized JSON:API document structure'); } @@ -83,5 +102,39 @@ class Document implements JsonEncodable { if (data != null) ...data.toJson() else if (isError) 'errors': errors, if (isMeta || meta.isNotEmpty) 'meta': meta, if (api.isNotEmpty) 'jsonapi': api, + if (isCompound) 'included': included, }; } + +class CompoundDocument implements Document { + CompoundDocument(this._document, Iterable included) + : included = List.unmodifiable(included); + + final Document _document; + @override + final List included; + + @override + Api get api => _document.api; + + @override + D get data => _document.data; + + @override + List get errors => _document.errors; + + @override + bool get isCompound => true; + + @override + bool get isError => false; + + @override + bool get isMeta => false; + + @override + Map get meta => _document.meta; + + @override + Map toJson() => {..._document.toJson(), 'included': included}; +} diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 16e5beb0..85f76a48 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -1,6 +1,5 @@ import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_object.dart'; /// The top-level Primary Data. This is the essentials of the JSON:API Document. /// @@ -8,16 +7,8 @@ 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 implements JsonEncodable { - PrimaryData({Iterable included, Map links}) - : isCompound = included != null, - included = List.unmodifiable(included ?? const []), - links = Map.unmodifiable(links ?? const {}); - - /// In a Compound document, this member contains the included resources. - final List included; - - /// True for compound documents. - final bool isCompound; + PrimaryData({Map links}) + : links = Map.unmodifiable(links ?? const {}); /// The top-level `links` object. May be empty or null. final Map links; @@ -28,6 +19,5 @@ abstract class PrimaryData implements JsonEncodable { @override Map toJson() => { if (links.isNotEmpty) 'links': links, - if (isCompound) 'included': included, }; } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 5925a07a..3d7628d2 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -15,8 +15,7 @@ import 'package:json_api/src/nullable.dart'; /// /// More on this: https://jsonapi.org/format/#document-resource-object-relationships class Relationship extends PrimaryData { - Relationship({Iterable included, Map links}) - : super(included: included, links: links); + Relationship({Map links}) : super(links: links); /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. static Relationship fromJson(Object json) { @@ -51,9 +50,7 @@ class Relationship extends PrimaryData { /// Relationship to-one class ToOne extends Relationship { - ToOne(this.linkage, - {Iterable included, Map links}) - : super(included: included, links: links); + ToOne(this.linkage, {Map links}) : super(links: links); ToOne.empty({Link self, Map links}) : linkage = null, @@ -64,12 +61,9 @@ class ToOne extends Relationship { static ToOne fromJson(Object json) { if (json is Map && json.containsKey('data')) { - final included = json['included']; final links = json['links']; return ToOne(nullable(IdentifierObject.fromJson)(json['data']), - links: (links == null) ? null : Link.mapFromJson(links), - included: - included is List ? included.map(ResourceObject.fromJson) : null); + links: (links == null) ? null : Link.mapFromJson(links)); } throw DocumentException( "A to-one relationship must be a JSON object and contain the 'data' member"); @@ -98,10 +92,9 @@ class ToOne extends Relationship { /// Relationship to-many class ToMany extends Relationship { - ToMany(Iterable linkage, - {Iterable included, Map links}) + ToMany(Iterable linkage, {Map links}) : linkage = List.unmodifiable(linkage), - super(included: included, links: links); + super(links: links); static ToMany fromIdentifiers(Iterable identifiers) => ToMany(identifiers.map(IdentifierObject.fromIdentifier)); diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index b6adfdbb..c47c4a0f 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -7,20 +7,16 @@ import 'package:json_api/src/document/resource_object.dart'; /// Represents a resource collection or a collection of related resources of a to-many relationship class ResourceCollectionData extends PrimaryData { ResourceCollectionData(Iterable collection, - {Iterable include, Map links}) + {Map links}) : collection = List.unmodifiable(collection ?? const []), - super(included: include, links: links); + super(links: links); static ResourceCollectionData fromJson(Object json) { if (json is Map) { final data = json['data']; if (data is List) { - final included = json['included']; return ResourceCollectionData(data.map(ResourceObject.fromJson), - links: Link.mapFromJson(json['links'] ?? {}), - include: included is List - ? included.map(ResourceObject.fromJson) - : null); + links: Link.mapFromJson(json['links'] ?? {})); } } throw DocumentException( diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 50be4a19..b87e4706 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -7,25 +7,16 @@ import 'package:json_api/src/nullable.dart'; /// Represents a single resource or a single related resource of a to-one relationship class ResourceData extends PrimaryData { - ResourceData(this.resourceObject, - {Iterable include, Map links}) - : super(included: include, links: {...?resourceObject?.links, ...?links}); + ResourceData(this.resourceObject, {Map links}) + : super(links: {...?resourceObject?.links, ...?links}); static ResourceData fromResource(Resource resource) => ResourceData(ResourceObject.fromResource(resource)); static ResourceData fromJson(Object json) { if (json is Map) { - Iterable resources; - final included = json['included']; - if (included is List) { - resources = included.map(ResourceObject.fromJson); - } else if (included != null) { - throw DocumentException("The 'included' value must be a JSON array"); - } - final data = nullable(ResourceObject.fromJson)(json['data']); - return ResourceData(data, - links: Link.mapFromJson(json['links'] ?? {}), include: resources); + return ResourceData(nullable(ResourceObject.fromJson)(json['data']), + links: Link.mapFromJson(json['links'] ?? {})); } throw DocumentException( "A JSON:API resource document must be a JSON object and contain the 'data' member"); diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart new file mode 100644 index 00000000..9e9b4986 --- /dev/null +++ b/lib/src/routing/target.dart @@ -0,0 +1,51 @@ +import 'package:json_api/routing.dart'; + +class CollectionTarget { + CollectionTarget(this.type); + + final String type; + + Uri link(UriFactory factory) => factory.collection(type); +} + +class ResourceTarget implements CollectionTarget { + ResourceTarget(this.type, this.id); + + @override + final String type; + + final String id; + + @override + Uri link(UriFactory factory) => factory.resource(type, id); +} + +class RelationshipTarget implements ResourceTarget { + RelationshipTarget(this.type, this.id, this.relationship); + + @override + final String type; + + @override + final String id; + + final String relationship; + + @override + Uri link(UriFactory factory) => factory.relationship(type, id, relationship); +} + +class RelatedTarget implements ResourceTarget { + RelatedTarget(this.type, this.id, this.relationship); + + @override + final String type; + + @override + final String id; + + final String relationship; + + @override + Uri link(UriFactory factory) => factory.related(type, id, relationship); +} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index ae8500cd..5dfa2cef 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/target.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. @@ -16,7 +16,7 @@ abstract class Controller { /// Finds an returns a related resource or a collection of related resources. /// See https://jsonapi.org/format/#fetching-resources - Future fetchRelated(Request request); + Future fetchRelated(Request request); /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 4dfd3e76..713e478c 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -1,13 +1,13 @@ import 'dart:async'; import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/pagination.dart'; import 'package:json_api/src/server/repository.dart'; import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/response.dart'; -import 'package:json_api/src/server/target.dart'; /// An opinionated implementation of [Controller]. Translates JSON:API /// requests to [Repository] methods calls. @@ -103,7 +103,7 @@ class RepositoryController implements Controller { }); @override - Future fetchRelated(Request request) => + Future fetchRelated(Request request) => _do(() async { final resource = await _repo.get(request.target.type, request.target.id); diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 98858c67..430622eb 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -1,32 +1,18 @@ -import 'dart:convert'; - -import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/target.dart'; +import 'package:json_api/src/routing/target.dart'; class Request { - Request(this.httpRequest, this.target, this.self) - : sort = Sort.fromUri(httpRequest.uri), - include = Include.fromUri(httpRequest.uri), - page = Page.fromUri(httpRequest.uri); + Request(this.uri, this.target) + : sort = Sort.fromUri(uri), + include = Include.fromUri(uri), + page = Page.fromUri(uri); - final HttpRequest httpRequest; + final Uri uri; final Include include; final Page page; final Sort sort; final T target; - Object decodePayload() => jsonDecode(httpRequest.body); - bool get isCompound => include.isNotEmpty; - - final Uri Function(UriFactory) self; - - /// Generates the 'self' link preserving original query parameters - Uri generateSelfUri(UriFactory factory) => - httpRequest.uri.queryParameters.isNotEmpty - ? self(factory) - .replace(queryParameters: httpRequest.uri.queryParametersAll) - : self(factory); } diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 231a24c5..e72e7694 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -3,7 +3,7 @@ import 'package:json_api/http.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/response_factory.dart'; -import 'package:json_api/src/server/target.dart'; +import 'package:json_api/src/routing/target.dart'; abstract class Response { HttpResponse convert(ResponseFactory f); @@ -55,7 +55,7 @@ class PrimaryResourceResponse implements Response { class RelatedResourceResponse implements Response { RelatedResourceResponse(this.request, this.resource, {this.include}); - final Request request; + final Request request; final Resource resource; final Iterable include; @@ -90,7 +90,7 @@ class PrimaryCollectionResponse implements Response { class RelatedCollectionResponse implements Response { RelatedCollectionResponse(this.request, this.collection, {this.include}); - final Request request; + final Request request; final Collection collection; final List include; diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index 0e4baaa3..d2f3d26f 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -6,7 +6,6 @@ import 'package:json_api/routing.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/target.dart'; abstract class ResponseFactory { HttpResponse error(int status, @@ -19,7 +18,7 @@ abstract class ResponseFactory { {Iterable include}); HttpResponse relatedResource( - Request request, Resource resource, + Request request, Resource resource, {Iterable include}); HttpResponse createdResource( @@ -30,7 +29,7 @@ abstract class ResponseFactory { {Iterable include}); HttpResponse relatedCollection( - Request request, Collection collection, + Request request, Collection collection, {List include}); HttpResponse relationshipToOne( @@ -61,11 +60,11 @@ class HttpResponseFactory implements ResponseFactory { {Iterable include}) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ResourceData(_resource(resource), - links: {'self': Link(request.generateSelfUri(_uri))}, - include: request.isCompound - ? (include ?? []).map(_resource) - : null)))); + body: jsonEncode(Document( + ResourceData(_resource(resource), + links: {'self': Link(_self(request))}), + included: + request.isCompound ? (include ?? []).map(_resource) : null))); @override HttpResponse createdResource( @@ -85,45 +84,44 @@ class HttpResponseFactory implements ResponseFactory { {Iterable include}) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ResourceCollectionData( - collection.elements.map(_resource), - links: {'self': Link(request.generateSelfUri(_uri))}, - include: request.isCompound - ? (include ?? []).map(_resource) - : null)))); + body: jsonEncode(Document( + ResourceCollectionData( + collection.elements.map(_resource), + links: {'self': Link(_self(request))}, + ), + included: + request.isCompound ? (include ?? []).map(_resource) : null))); @override HttpResponse relatedCollection( - Request request, Collection collection, + Request request, Collection collection, {List include}) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ResourceCollectionData( - collection.elements.map(_resource), - links: { - 'self': Link(request.generateSelfUri(_uri)), - 'related': Link(_uri.related(request.target.type, - request.target.id, request.target.relationship)) - }, - include: request.isCompound - ? (include ?? []).map(_resource) - : null)))); + body: jsonEncode(Document( + ResourceCollectionData(collection.elements.map(_resource), + links: { + 'self': Link(_self(request)), + 'related': Link(_uri.related(request.target.type, + request.target.id, request.target.relationship)) + }), + included: + request.isCompound ? (include ?? []).map(_resource) : null))); @override HttpResponse relatedResource( - Request request, Resource resource, + Request request, Resource resource, {Iterable include}) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ResourceData(_resource(resource), - links: { - 'self': Link(request.generateSelfUri(_uri)), + body: jsonEncode(Document( + ResourceData(_resource(resource), links: { + 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, request.target.id, request.target.relationship)) - }, - include: request.isCompound - ? (include ?? []).map(_resource) - : null)))); + }), + included: + request.isCompound ? (include ?? []).map(_resource) : null))); @override HttpResponse relationshipToMany(Request request, @@ -133,7 +131,7 @@ class HttpResponseFactory implements ResponseFactory { body: jsonEncode(Document(ToMany( identifiers.map(IdentifierObject.fromIdentifier), links: { - 'self': Link(request.generateSelfUri(_uri)), + 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, request.target.id, request.target.relationship)) }, @@ -147,7 +145,7 @@ class HttpResponseFactory implements ResponseFactory { body: jsonEncode(Document(ToOne( IdentifierObject.fromIdentifier(identifier), links: { - 'self': Link(request.generateSelfUri(_uri)), + 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, request.target.id, request.target.relationship)) }, @@ -175,4 +173,8 @@ class HttpResponseFactory implements ResponseFactory { links: { 'self': Link(_uri.resource(resource.type, resource.id)) }); + + Uri _self(Request r) => r.uri.queryParametersAll.isNotEmpty + ? r.target.link(_uri).replace(queryParameters: r.uri.queryParametersAll) + : r.target.link(_uri); } diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart index 16141b5d..f377ef3f 100644 --- a/lib/src/server/route.dart +++ b/lib/src/server/route.dart @@ -1,19 +1,16 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/response.dart'; -import 'package:json_api/src/server/target.dart'; -abstract class Route { +abstract class Route { List get allowedMethods; - T get target; - Future dispatch(HttpRequest request, Controller controller); - - Uri self(UriFactory uriFactory); } class UnmatchedRoute implements Route { @@ -31,16 +28,6 @@ class UnmatchedRoute implements Route { detail: 'The requested URL does exist on the server', ) ]); - - @override - Uri self(UriFactory uriFactory) { - // TODO: Remove this method - throw StateError('Fixme'); - } - - @override - // TODO: implement target - CollectionTarget get target => null; } class ErrorHandling implements Route { @@ -85,19 +72,12 @@ class ErrorHandling implements Route { ]); } } - - @override - Uri self(UriFactory uriFactory) => _route.self(uriFactory); - - @override -// TODO: implement target - CollectionTarget get target => null; } -class CorsEnabled implements Route { +class CorsEnabled implements Route { CorsEnabled(this._route); - final Route _route; + final Route _route; @override List get allowedMethods => _route.allowedMethods + ['OPTIONS']; @@ -115,53 +95,41 @@ class CorsEnabled implements Route { return ExtraHeaders(await _route.dispatch(request, controller), {'Access-Control-Allow-Origin': '*'}); } - - @override - Uri self(UriFactory uriFactory) => _route.self(uriFactory); - - @override - T get target => _route.target; } -class CollectionRoute implements Route { - CollectionRoute(this.target); +class CollectionRoute implements Route { + CollectionRoute(this._target); - @override - final CollectionTarget target; + final CollectionTarget _target; @override final allowedMethods = ['GET', 'POST']; @override Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request, target, (_) => _.collection(target.type)); + final r = Request(request.uri, _target); if (request.isGet) { return controller.fetchCollection(r); } if (request.isPost) { return controller.createResource( - r, ResourceData.fromJson(r.decodePayload()).unwrap()); + r, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); } throw ArgumentError(); } - - @override - Uri self(UriFactory uriFactory) => uriFactory.collection(target.type); } -class ResourceRoute implements Route { - ResourceRoute(this.target); +class ResourceRoute implements Route { + ResourceRoute(this._target); - @override - final ResourceTarget target; + final ResourceTarget _target; @override final allowedMethods = ['DELETE', 'GET', 'PATCH']; @override Future dispatch(HttpRequest request, Controller controller) { - final r = - Request(request, target, (_) => _.resource(target.type, target.id)); + final r = Request(request.uri, _target); if (request.isDelete) { return controller.deleteResource(r); } @@ -170,21 +138,16 @@ class ResourceRoute implements Route { } if (request.isPatch) { return controller.updateResource( - r, ResourceData.fromJson(r.decodePayload()).unwrap()); + r, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); } throw ArgumentError(); } - - @override - Uri self(UriFactory uriFactory) => - uriFactory.resource(target.type, target.id); } -class RelatedRoute implements Route { - RelatedRoute(this.target); +class RelatedRoute implements Route { + RelatedRoute(this._target); - @override - final RelationshipTarget target; + final RelatedTarget _target; @override final allowedMethods = ['GET']; @@ -192,39 +155,32 @@ class RelatedRoute implements Route { @override Future dispatch(HttpRequest request, Controller controller) { if (request.isGet) { - return controller.fetchRelated(Request(request, target, - (_) => _.related(target.type, target.id, target.relationship))); + return controller.fetchRelated(Request(request.uri, _target)); } throw ArgumentError(); } - - @override - Uri self(UriFactory uriFactory) => - uriFactory.related(target.type, target.id, target.relationship); } -class RelationshipRoute implements Route { - RelationshipRoute(this.target); +class RelationshipRoute implements Route { + RelationshipRoute(this._target); - @override - final RelationshipTarget target; + final RelationshipTarget _target; @override final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; @override Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request, target, - (_) => _.relationship(target.type, target.id, target.relationship)); + final r = Request(request.uri, _target); if (request.isDelete) { return controller.deleteFromRelationship( - r, ToMany.fromJson(r.decodePayload()).unwrap()); + r, ToMany.fromJson(jsonDecode(request.body)).unwrap()); } if (request.isGet) { return controller.fetchRelationship(r); } if (request.isPatch) { - final rel = Relationship.fromJson(r.decodePayload()); + final rel = Relationship.fromJson(jsonDecode(request.body)); if (rel is ToOne) { return controller.replaceToOne(r, rel.unwrap()); } @@ -235,14 +191,10 @@ class RelationshipRoute implements Route { } if (request.isPost) { return controller.addToRelationship( - r, ToMany.fromJson(r.decodePayload()).unwrap()); + r, ToMany.fromJson(jsonDecode(request.body)).unwrap()); } throw ArgumentError(); } - - @override - Uri self(UriFactory uriFactory) => - uriFactory.relationship(target.type, target.id, target.relationship); } /// Thrown if the relationship object has no data diff --git a/lib/src/server/route_matcher.dart b/lib/src/server/route_matcher.dart index ad64bd2f..ef991fe9 100644 --- a/lib/src/server/route_matcher.dart +++ b/lib/src/server/route_matcher.dart @@ -1,6 +1,5 @@ import 'package:json_api/routing.dart'; import 'package:json_api/src/server/route.dart'; -import 'package:json_api/src/server/target.dart'; class RouteMatcher implements UriMatchHandler { Route _match; @@ -12,7 +11,7 @@ class RouteMatcher implements UriMatchHandler { @override void related(String type, String id, String relationship) { - _match = RelatedRoute(RelationshipTarget(type, id, relationship)); + _match = RelatedRoute(RelatedTarget(type, id, relationship)); } @override diff --git a/lib/src/server/target.dart b/lib/src/server/target.dart deleted file mode 100644 index df7804ae..00000000 --- a/lib/src/server/target.dart +++ /dev/null @@ -1,26 +0,0 @@ -class CollectionTarget { - CollectionTarget(this.type); - - final String type; -} - -class ResourceTarget implements CollectionTarget { - ResourceTarget(this.type, this.id); - - @override - final String type; - - final String id; -} - -class RelationshipTarget implements ResourceTarget { - RelationshipTarget(this.type, this.id, this.relationship); - - @override - final String type; - - @override - final String id; - - final String relationship; -} diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 453fc5c3..8c3171ee 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -40,7 +40,7 @@ void main() async { parameters: Include(['authors'])); expect(response.data.unwrap().attributes['title'], 'Refactoring'); - expect(response.data.included.first.unwrap().attributes['name'], + expect(response.document.included.first.unwrap().attributes['name'], 'Martin Fowler'); }, testOn: 'browser'); } diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index 9d35647d..934e92bd 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -51,7 +51,7 @@ void main() { parameters: Include(['authors'])); expect(response.data.unwrap().attributes['title'], 'Refactoring'); - expect(response.data.included.first.unwrap().attributes['name'], + expect(response.document.included.first.unwrap().attributes['name'], 'Martin Fowler'); }); }, testOn: 'vm'); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 58d8d618..6f5e6cf1 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -55,15 +55,15 @@ void main() async { test('not compound by default', () async { final r = await client.fetchResource('posts', '1'); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.isCompound, isFalse); + expect(r.document.isCompound, isFalse); }); test('included == [] when requested but nothing to include', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['tags'])); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included, []); - expect(r.data.isCompound, isTrue); + expect(r.document.included, []); + expect(r.document.isCompound, isTrue); expect(r.data.self.toString(), '/posts/1?include=tags'); }); @@ -71,41 +71,41 @@ void main() async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments'])); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 2); + expectResourcesEqual(r.document.included[0].unwrap(), comment1); + expectResourcesEqual(r.document.included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author'])); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 2); + expectResourcesEqual(r.document.included.first.unwrap(), bob); + expectResourcesEqual(r.document.included.last.unwrap(), alice); }); test('can include third-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author.birthplace'])); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 1); + expectResourcesEqual(r.document.included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments', 'comments.author'])); expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - expect(r.data.isCompound, isTrue); + expect(r.document.included.length, 4); + expectResourcesEqual(r.document.included[0].unwrap(), comment1); + expectResourcesEqual(r.document.included[1].unwrap(), comment2); + expectResourcesEqual(r.document.included[2].unwrap(), bob); + expectResourcesEqual(r.document.included[3].unwrap(), alice); + expect(r.document.isCompound, isTrue); }); }); @@ -113,7 +113,7 @@ void main() async { test('not compound by default', () async { final r = await client.fetchCollection('posts'); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.isCompound, isFalse); + expect(r.document.isCompound, isFalse); }); test('document is compound when requested but nothing to include', @@ -121,49 +121,49 @@ void main() async { final r = await client.fetchCollection('posts', parameters: Include(['tags'])); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included, []); - expect(r.data.isCompound, isTrue); + expect(r.document.included, []); + expect(r.document.isCompound, isTrue); }); test('can include first-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments'])); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 2); + expectResourcesEqual(r.document.included[0].unwrap(), comment1); + expectResourcesEqual(r.document.included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author'])); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - expect(r.data.isCompound, isTrue); + expect(r.document.included.length, 2); + expectResourcesEqual(r.document.included.first.unwrap(), bob); + expectResourcesEqual(r.document.included.last.unwrap(), alice); + expect(r.document.isCompound, isTrue); }); test('can include third-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author.birthplace'])); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 1); + expectResourcesEqual(r.document.included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments', 'comments.author'])); expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - expect(r.data.isCompound, isTrue); + expect(r.document.isCompound, isTrue); + expect(r.document.included.length, 4); + expectResourcesEqual(r.document.included[0].unwrap(), comment1); + expectResourcesEqual(r.document.included[1].unwrap(), comment2); + expectResourcesEqual(r.document.included[2].unwrap(), bob); + expectResourcesEqual(r.document.included[3].unwrap(), alice); }); }); } diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 6d92b3ec..9cbca52f 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -41,41 +41,6 @@ void main() { expect(data.self.toString(), '/self'); }); - group('included resources decoding', () { - test('null decodes to null', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'} - }))); - expect(data.isCompound, isFalse); - }); - test('[] decodes to []', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': [] - }))); - expect(data.included, equals([])); - expect(data.isCompound, isTrue); - }); - test('non empty [] decodes to non-empty []', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': [ - {'type': 'oranges', 'id': '1'} - ] - }))); - expect(data.included, isNotEmpty); - expect(data.isCompound, isTrue); - }); - test('invalid value throws DocumentException', () { - expect( - () => ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': {} - }))), - throwsA(TypeMatcher())); - }); - }); - group('custom links', () { final res = ResourceObject('apples', '1'); test('recognizes custom links', () { From 9700bc73744a324452eeef08af3ad52f25808860 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 19 Apr 2020 17:15:32 -0700 Subject: [PATCH 53/99] Moved included fom data to document --- example/client.dart | 4 ++-- lib/src/document/document.dart | 3 ++- lib/src/server/controller.dart | 5 ++--- lib/src/server/response.dart | 2 +- test/unit/document/resource_data_test.dart | 1 - 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/example/client.dart b/example/client.dart index 35a0defd..b55af4a4 100644 --- a/example/client.dart +++ b/example/client.dart @@ -39,10 +39,10 @@ void main() async { parameters: Include(['authors'])); /// Extract the primary resource. - final book = response.data.unwrap(); + final book = response.document.data.unwrap(); /// Extract the included resource. - final author = response.data.included.first.unwrap(); + final author = response.document.included.first.unwrap(); print('Book: $book'); print('Author: $author'); diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index bd84201d..a365daad 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -48,9 +48,10 @@ class Document implements JsonEncodable { /// The Primary Data. May be null. final D data; - /// Included objects in a compound document + /// Included objects in a compound document. final List included; + /// True for non-error documents with included resources. final bool isCompound; /// List of errors. May be empty or null. diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 5dfa2cef..5ab5238b 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/request.dart'; +import 'package:json_api/src/server/response.dart'; /// This is a controller consolidating all possible requests a JSON:API server /// may handle. @@ -20,8 +20,7 @@ abstract class Controller { /// Finds an returns a relationship of a primary resource. /// See https://jsonapi.org/format/#fetching-relationships - Future fetchRelationship( - Request request); + Future fetchRelationship(Request request); /// Deletes the resource. /// See https://jsonapi.org/format/#crud-deleting diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index e72e7694..d41a506b 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,9 +1,9 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/routing/target.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/response_factory.dart'; -import 'package:json_api/src/routing/target.dart'; abstract class Response { HttpResponse convert(ResponseFactory f); diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 9cbca52f..4c4c71ec 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; import 'package:test/test.dart'; void main() { From 51a4a453f55c310091416e31a674a20c937eab24 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 23 Apr 2020 20:11:08 -0700 Subject: [PATCH 54/99] Client request --- example/client.dart | 14 +- lib/client.dart | 3 +- lib/http.dart | 1 + lib/src/client/json_api_client.dart | 288 ++++++++---------- lib/src/client/request.dart | 69 +++++ lib/src/client/response.dart | 73 ++--- lib/src/client/routing_client.dart | 119 -------- lib/src/document/document.dart | 50 +-- lib/src/document/primary_data.dart | 2 + lib/src/http/http_method.dart | 7 + lib/src/http/http_request.dart | 30 +- lib/src/http/transforming_http_handler.dart | 22 ++ lib/src/query/query_parameters.dart | 3 + lib/src/server/response_factory.dart | 11 + test/e2e/browser_test.dart | 11 +- test/e2e/client_server_interaction_test.dart | 14 +- test/functional/compound_document_test.dart | 113 +++---- .../crud/creating_resources_test.dart | 108 +++---- .../crud/deleting_resources_test.dart | 40 ++- .../crud/fetching_relationships_test.dart | 170 ++++++----- .../crud/fetching_resources_test.dart | 216 +++++++------ test/functional/crud/seed_resources.dart | 2 +- .../crud/updating_relationships_test.dart | 220 +++++++------ .../crud/updating_resources_test.dart | 63 ++-- test/helper/test_http_handler.dart | 4 +- test/unit/client/async_processing_test.dart | 24 +- 26 files changed, 807 insertions(+), 870 deletions(-) create mode 100644 lib/src/client/request.dart delete mode 100644 lib/src/client/routing_client.dart create mode 100644 lib/src/http/http_method.dart create mode 100644 lib/src/http/transforming_http_handler.dart diff --git a/example/client.dart b/example/client.dart index b55af4a4..0c360756 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,4 +1,4 @@ -import 'package:http/http.dart'; +import 'package:http/http.dart' as http; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; @@ -13,15 +13,15 @@ void main() async { /// Create the HTTP client. We're using Dart's native client. /// Do not forget to call [Client.close] when you're done using it. - final httpClient = Client(); + final httpClient = http.Client(); - /// We'll use a logging handler to show the requests and responses. + /// We'll use a logging handler wrapper to show the requests and responses. final httpHandler = LoggingHttpHandler(DartHttp(httpClient), onRequest: (r) => print('${r.method} ${r.uri}'), onResponse: (r) => print('${r.statusCode}')); /// The JSON:API client - final client = RoutingClient(JsonApiClient(httpHandler), routing); + final client = JsonApiClient(httpHandler, routing); /// Create the first resource. await client.createResource( @@ -38,11 +38,13 @@ void main() async { final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); + final document = response.decodeDocument(); + /// Extract the primary resource. - final book = response.document.data.unwrap(); + final book = document.data.unwrap(); /// Extract the included resource. - final author = response.document.included.first.unwrap(); + final author = document.included.first.unwrap(); print('Book: $book'); print('Author: $author'); diff --git a/lib/client.dart b/lib/client.dart index 38a2080f..7c78e11f 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -2,6 +2,7 @@ library client; export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/response.dart'; -export 'package:json_api/src/client/routing_client.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/http.dart b/lib/http.dart index b62b5ec3..0b312c0b 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -5,3 +5,4 @@ export 'package:json_api/src/http/http_handler.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; export 'package:json_api/src/http/logging_http_handler.dart'; +export 'package:json_api/src/http/transforming_http_handler.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 798ecd61..e2e49eda 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,199 +1,149 @@ -import 'dart:async'; -import 'dart:convert'; - +import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/response.dart'; -import 'package:json_api/src/client/status_code.dart'; +import 'package:json_api/routing.dart'; -/// The JSON:API Client. +/// The JSON:API client class JsonApiClient { - /// Creates an instance of JSON:API client. - /// Provide instances of [HttpHandler] (e.g. [DartHttp]) - JsonApiClient(this._httpHandler); - - final HttpHandler _httpHandler; - - /// Fetches a resource collection at the [uri]. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollectionAt(Uri uri, + JsonApiClient(this._http, this._uri); + + final HttpHandler _http; + final UriFactory _uri; + + /// Fetches a primary resource collection by [type]. + Future> fetchCollection(String type, + {Map headers, QueryParameters parameters}) => + send( + Request.fetchCollection(parameters: parameters), + _uri.collection(type), + headers: headers, + ); + + /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. + Future> fetchRelatedCollection( + String type, String id, String relationship, + {Map headers, QueryParameters parameters}) => + send(Request.fetchCollection(parameters: parameters), + _uri.related(type, id, relationship), + headers: headers); + + /// Fetches a primary resource by [type] and [id]. + Future> fetchResource(String type, String id, {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); - - /// Fetches a single resource at the [uri]. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResourceAt(Uri uri, + send(Request.fetchResource(parameters: parameters), + _uri.resource(type, id), + headers: headers); + + /// Fetches a related resource by [type], [id], [relationship]. + Future> fetchRelatedResource( + String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ResourceData.fromJson); - - /// Fetches a to-one relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOneAt(Uri uri, + send(Request.fetchResource(parameters: parameters), + _uri.related(type, id, relationship), + headers: headers); + + /// Fetches a to-one relationship by [type], [id], [relationship]. + Future> fetchToOne( + String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ToOne.fromJson); - - /// Fetches a to-many relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToManyAt(Uri uri, + send(Request.fetchToOne(parameters: parameters), + _uri.relationship(type, id, relationship), + headers: headers); + + /// Fetches a to-many relationship by [type], [id], [relationship]. + Future> fetchToMany( + String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), 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. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationshipAt(Uri uri, + send( + Request.fetchToMany(parameters: parameters), + _uri.relationship(type, id, relationship), + headers: headers, + ); + + /// Fetches a [relationship] of [type] : [id]. + Future> fetchRelationship( + String type, String id, String relationship, {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), Relationship.fromJson); + send(Request.fetchRelationship(parameters: parameters), + _uri.relationship(type, id, relationship), + headers: headers); - /// Creates a new resource. The resource will be added to a collection - /// according to its type. - /// - /// https://jsonapi.org/format/#crud-creating - Future> createResourceAt(Uri uri, Resource resource, + /// Creates the [resource] on the server. + Future> createResource(Resource resource, {Map headers}) => - _call(_post(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); - - /// Deletes the resource. - /// - /// https://jsonapi.org/format/#crud-deleting - Future deleteResourceAt(Uri uri, {Map headers}) => - _call(_delete(uri, headers), null); - - /// Updates the resource via PATCH query. - /// - /// https://jsonapi.org/format/#crud-updating - Future> updateResourceAt(Uri uri, Resource resource, + send(Request.createResource(_resourceDoc(resource)), + _uri.collection(resource.type), + headers: headers); + + /// Deletes the resource by [type] and [id]. + Future deleteResource(String type, String id, {Map headers}) => - _call( - _patch(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); + send(Request.deleteResource(), _uri.resource(type, id), headers: headers); - /// Updates a to-one relationship via PATCH query - /// - /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOneAt(Uri uri, Identifier identifier, + /// Updates the [resource]. + Future> updateResource(Resource resource, {Map headers}) => - _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); + send(Request.updateResource(_resourceDoc(resource)), + _uri.resource(resource.type, resource.id), + headers: headers); - /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt] - /// with id = null. - Future> deleteToOneAt(Uri uri, + /// Replaces the to-one [relationship] of [type] : [id]. + Future> replaceToOne( + String type, String id, String relationship, Identifier identifier, {Map headers}) => - replaceToOneAt(uri, null, headers: headers); + send(Request.replaceToOne(_toOneDoc(identifier)), + _uri.relationship(type, id, relationship), + headers: headers); - /// Removes the [identifiers] from the to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToManyAt( - Uri uri, Iterable identifiers, + /// Deletes the to-one [relationship] of [type] : [id]. + Future> deleteToOne( + String type, String id, String relationship, {Map headers}) => - _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), - ToMany.fromJson); - - /// Replaces a to-many relationship with the given set of [identifiers]. - /// - /// The server MUST either completely replace every member of the relationship, - /// return an appropriate error response if some resources can not be found or accessed, - /// or return a 403 Forbidden response if complete replacement is not allowed by the server. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToManyAt( - Uri uri, Iterable identifiers, + send(Request.replaceToOne(_toOneDoc(null)), + _uri.relationship(type, id, relationship), + headers: headers); + + /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. + Future> deleteFromToMany(String type, String id, + String relationship, Iterable identifiers, {Map headers}) => - _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); + send(Request.deleteFromToMany(_toManyDoc(identifiers)), + _uri.relationship(type, id, relationship), + headers: headers); - /// Adds the given set of [identifiers] to a to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationshipAt( - Uri uri, Iterable identifiers, + /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. + Future> replaceToMany(String type, String id, + String relationship, Iterable identifiers, {Map headers}) => - _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); + send(Request.replaceToMany(_toManyDoc(identifiers)), + _uri.relationship(type, id, relationship), + headers: headers); - final _api = Api(version: '1.0'); + /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. + Future> addToMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) => + send(Request.addToMany(_toManyDoc(identifiers)), + _uri.relationship(type, id, relationship), + headers: headers); + + /// Sends the request to the [uri] via [handler] and returns the response. + /// Extra [headers] may be added to the request. + Future> send(Request request, Uri uri, + {Map headers}) async => + Response( + await _http.call(HttpRequest( + request.method, request.parameters.addToUri(uri), + body: request.body, headers: {...?headers, ...request.headers})), + request.decoder); Document _resourceDoc(Resource resource) => - Document(ResourceData.fromResource(resource), api: _api); + Document(ResourceData.fromResource(resource)); Document _toManyDoc(Iterable identifiers) => - Document(ToMany.fromIdentifiers(identifiers), api: _api); + Document(ToMany.fromIdentifiers(identifiers)); Document _toOneDoc(Identifier identifier) => - Document(ToOne.fromIdentifier(identifier), api: _api); - - HttpRequest _get(Uri uri, Map headers, - QueryParameters queryParameters) => - HttpRequest('GET', (queryParameters ?? QueryParameters({})).addToUri(uri), - headers: { - ...?headers, - 'Accept': Document.contentType, - }); - - HttpRequest _post(Uri uri, Map headers, Document doc) => - HttpRequest('POST', uri, - headers: { - ...?headers, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - HttpRequest _delete(Uri uri, Map headers) => - HttpRequest('DELETE', uri, headers: { - ...?headers, - 'Accept': Document.contentType, - }); - - HttpRequest _deleteWithBody( - Uri uri, Map headers, Document doc) => - HttpRequest('DELETE', uri, - headers: { - ...?headers, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - HttpRequest _patch(uri, Map headers, Document doc) => - HttpRequest('PATCH', uri, - headers: { - ...?headers, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - Future> _call( - HttpRequest request, D Function(Object _) decodePrimaryData) async { - final response = await _httpHandler(request); - final document = response.body.isEmpty ? null : jsonDecode(response.body); - if (document == null) { - return Response(response.statusCode, response.headers); - } - if (StatusCode(response.statusCode).isPending) { - return Response(response.statusCode, response.headers, - asyncDocument: document == null - ? null - : Document.fromJson(document, ResourceData.fromJson)); - } - return Response(response.statusCode, response.headers, - document: document == null - ? null - : Document.fromJson(document, decodePrimaryData)); - } + Document(ToOne.fromIdentifier(identifier)); } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart new file mode 100644 index 00000000..68ec1416 --- /dev/null +++ b/lib/src/client/request.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/http/http_method.dart'; + +/// A JSON:API request. +class Request { + Request(this.method, this.decoder, {QueryParameters parameters}) + : headers = const {'Accept': Document.contentType}, + body = '', + parameters = parameters ?? QueryParameters.empty(); + + Request.withPayload(Document document, this.method, this.decoder, + {QueryParameters parameters}) + : headers = const { + 'Accept': Document.contentType, + 'Content-Type': Document.contentType + }, + body = jsonEncode(document), + parameters = parameters ?? QueryParameters.empty(); + + static Request fetchCollection( + {QueryParameters parameters}) => + Request(HttpMethod.GET, ResourceCollectionData.fromJson, + parameters: parameters); + + static Request fetchResource({QueryParameters parameters}) => + Request(HttpMethod.GET, ResourceData.fromJson, parameters: parameters); + + static Request fetchToOne({QueryParameters parameters}) => + Request(HttpMethod.GET, ToOne.fromJson, parameters: parameters); + + static Request fetchToMany({QueryParameters parameters}) => + Request(HttpMethod.GET, ToMany.fromJson, parameters: parameters); + + static Request fetchRelationship( + {QueryParameters parameters}) => + Request(HttpMethod.GET, Relationship.fromJson, parameters: parameters); + + static Request createResource( + Document document) => + Request.withPayload(document, HttpMethod.POST, ResourceData.fromJson); + + static Request updateResource( + Document document) => + Request.withPayload(document, HttpMethod.PATCH, ResourceData.fromJson); + + static Request deleteResource() => + Request(HttpMethod.DELETE, ResourceData.fromJson); + + static Request replaceToOne(Document document) => + Request.withPayload(document, HttpMethod.PATCH, ToOne.fromJson); + + static Request deleteFromToMany(Document document) => + Request.withPayload(document, HttpMethod.DELETE, ToMany.fromJson); + + static Request replaceToMany(Document document) => + Request.withPayload(document, HttpMethod.PATCH, ToMany.fromJson); + + static Request addToMany(Document document) => + Request.withPayload(document, HttpMethod.POST, ToMany.fromJson); + + final PrimaryDataDecoder decoder; + final String method; + final String body; + final Map headers; + final QueryParameters parameters; +} diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 6b33c9e3..12357e88 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,67 +1,52 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/src/nullable.dart'; -/// A response returned by JSON:API client +/// A JSON:API response class Response { - const Response(this.statusCode, this.headers, - {this.document, this.asyncDocument}); - - /// HTTP status code - final int statusCode; + Response(this.http, this._decoder); - /// Document parsed from the response body. - /// May be null. - final Document document; + final PrimaryDataDecoder _decoder; - /// The document received with `202 Accepted` response (if any) - /// https://jsonapi.org/recommendations/#asynchronous-processing - final Document asyncDocument; + /// The HTTP response. + final HttpResponse http; - /// Headers returned by the server. - final Map headers; + /// Returns the Document parsed from the response body. + /// Throws a [StateError] if the HTTP response contains empty body. + /// Throws a [DocumentException] if the received document structure is invalid. + /// Throws a [FormatException] if the received JSON is invalid. + Document decodeDocument() { + if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); + return Document.fromJson(jsonDecode(http.body), _decoder); + } - /// Primary Data from the document (if any). For unsuccessful operations - /// this property will be null, the error details may be found in [Document.errors]. - D get data => document?.data; + /// Returns the async Document parsed from the response body. + /// Throws a [StateError] if the HTTP response contains empty body. + /// Throws a [DocumentException] if the received document structure is invalid. + /// Throws a [FormatException] if the received JSON is invalid. + Document decodeAsyncDocument() { + if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); + return Document.fromJson(jsonDecode(http.body), ResourceData.fromJson); + } - /// List of errors (if any) returned by the server in case of an unsuccessful - /// operation. May be empty. Will be null if the operation was successful. - List get errors => document?.errors; + bool get isEmpty => http.body.isEmpty; - /// Primary Data from the async document (if any) - ResourceData get asyncData => asyncDocument?.data; + bool get isNotEmpty => http.body.isNotEmpty; /// Was the query successful? /// /// For pending (202 Accepted) requests both [isSuccessful] and [isFailed] /// are always false. - bool get isSuccessful => StatusCode(statusCode).isSuccessful; + bool get isSuccessful => StatusCode(http.statusCode).isSuccessful; /// This property is an equivalent of `202 Accepted` HTTP status. /// It indicates that the query is accepted but not finished yet (e.g. queued). - /// If the response is async, the [data] and [document] properties will be null - /// and the returned primary data (usually representing a job queue) will be - /// in [asyncData] and [asyncDocument]. - /// The [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. - /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing - bool get isAsync => StatusCode(statusCode).isPending; + bool get isAsync => StatusCode(http.statusCode).isPending; /// Any non 2** status code is considered a failed operation. /// For failed requests, [document] is expected to contain [ErrorDocument] - bool get isFailed => StatusCode(statusCode).isFailed; - - /// The `Location` HTTP header value. For `201 Created` responses this property - /// contains the location of a newly created resource. - Uri get location => nullable(Uri.parse)(headers['location']); - - /// The `Content-Location` HTTP header value. For `202 Accepted` responses - /// this property contains the location of the Job Queue resource. - /// - /// More details: https://jsonapi.org/recommendations/#asynchronous-processing - Uri get contentLocation => nullable(Uri.parse)(headers['content-location']); + bool get isFailed => StatusCode(http.statusCode).isFailed; } diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart deleted file mode 100644 index aaab1f97..00000000 --- a/lib/src/client/routing_client.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; - -import 'response.dart'; - -/// This is a wrapper over [JsonApiClient] capable of building the -/// request URIs by itself. -class RoutingClient { - RoutingClient(this._client, this._routes); - - final JsonApiClient _client; - final UriFactory _routes; - - /// Fetches a primary resource collection by [type]. - Future> fetchCollection(String type, - {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_routes.collection(type), - headers: headers, parameters: parameters); - - /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - Future> fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_routes.related(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a primary resource by [type] and [id]. - Future> fetchResource(String type, String id, - {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_routes.resource(type, id), - headers: headers, parameters: parameters); - - /// Fetches a related resource by [type], [id], [relationship]. - Future> fetchRelatedResource( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_routes.related(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToOneAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToManyAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchRelationshipAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Creates the [resource] on the server. - Future> createResource(Resource resource, - {Map headers}) => - _client.createResourceAt(_routes.collection(resource.type), resource, - headers: headers); - - /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, - {Map headers}) => - _client.deleteResourceAt(_routes.resource(type, id), headers: headers); - - /// Updates the [resource]. - Future> updateResource(Resource resource, - {Map headers}) => - _client.updateResourceAt( - _routes.resource(resource.type, resource.id), resource, - headers: headers); - - /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( - String type, String id, String relationship, Identifier identifier, - {Map headers}) => - _client.replaceToOneAt( - _routes.relationship(type, id, relationship), identifier, - headers: headers); - - /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( - String type, String id, String relationship, - {Map headers}) => - _client.deleteToOneAt(_routes.relationship(type, id, relationship), - headers: headers); - - /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.deleteFromToManyAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); - - /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.replaceToManyAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); - - /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.addToRelationshipAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); -} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index a365daad..f40b065c 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -54,7 +54,7 @@ class Document implements JsonEncodable { /// True for non-error documents with included resources. final bool isCompound; - /// List of errors. May be empty or null. + /// List of errors. May be empty. final List errors; /// Meta data. May be empty. @@ -70,8 +70,8 @@ class Document implements JsonEncodable { final bool isMeta; /// Reconstructs a document with the specified primary data - static Document fromJson( - Object json, Data Function(Object json) primaryData) { + static Document fromJson( + Object json, D Function(Object _) decode) { if (json is Map) { final api = nullable(Api.fromJson)(json['jsonapi']); final meta = json['meta']; @@ -83,11 +83,12 @@ class Document implements JsonEncodable { } } else if (json.containsKey('data')) { final included = json['included']; - final doc = Document(primaryData(json), meta: meta, api: api); - if (included is List) { - return CompoundDocument(doc, included.map(ResourceObject.fromJson)); - } - return doc; + return Document(decode(json), + meta: meta, + api: api, + included: included is List + ? included.map(ResourceObject.fromJson) + : null); } else if (json['meta'] != null) { return Document.empty(meta, api: api); } @@ -106,36 +107,3 @@ class Document implements JsonEncodable { if (isCompound) 'included': included, }; } - -class CompoundDocument implements Document { - CompoundDocument(this._document, Iterable included) - : included = List.unmodifiable(included); - - final Document _document; - @override - final List included; - - @override - Api get api => _document.api; - - @override - D get data => _document.data; - - @override - List get errors => _document.errors; - - @override - bool get isCompound => true; - - @override - bool get isError => false; - - @override - bool get isMeta => false; - - @override - Map get meta => _document.meta; - - @override - Map toJson() => {..._document.toJson(), 'included': included}; -} diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 85f76a48..ac8759a4 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -21,3 +21,5 @@ abstract class PrimaryData implements JsonEncodable { if (links.isNotEmpty) 'links': links, }; } + +typedef PrimaryDataDecoder = D Function(Object json); diff --git a/lib/src/http/http_method.dart b/lib/src/http/http_method.dart new file mode 100644 index 00000000..bfcd7674 --- /dev/null +++ b/lib/src/http/http_method.dart @@ -0,0 +1,7 @@ +class HttpMethod { + static final DELETE = 'DELETE'; + static final GET = 'GET'; + static final OPTIONS = 'OPTIONS'; + static final PATCH = 'PATCH'; + static final POST = 'POST'; +} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index c0e05000..95da8919 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,11 +1,12 @@ +import 'package:json_api/src/http/http_method.dart'; + /// The request which is sent by the client and received by the server class HttpRequest { - HttpRequest(String method, this.uri, + HttpRequest(String method, Uri uri, {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - method = method.toUpperCase(), - body = body ?? ''; + : this._(method.toUpperCase(), uri, _normalize(headers), body ?? ''); + + HttpRequest._(this.method, this.uri, this.headers, this.body); /// Requested URI final Uri uri; @@ -19,13 +20,22 @@ class HttpRequest { /// Request headers. Unmodifiable. Lowercase keys final Map headers; - bool get isGet => method == 'GET'; + static Map _normalize(Map headers) => + Map.unmodifiable( + (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))); + + HttpRequest withHeaders(Map headers) => + HttpRequest._(method, uri, _normalize(headers), body); + + HttpRequest withUri(Uri uri) => HttpRequest._(method, uri, headers, body); + + bool get isGet => method == HttpMethod.GET; - bool get isPost => method == 'POST'; + bool get isPost => method == HttpMethod.POST; - bool get isDelete => method == 'DELETE'; + bool get isDelete => method == HttpMethod.DELETE; - bool get isPatch => method == 'PATCH'; + bool get isPatch => method == HttpMethod.PATCH; - bool get isOptions => method == 'OPTIONS'; + bool get isOptions => method == HttpMethod.OPTIONS; } diff --git a/lib/src/http/transforming_http_handler.dart b/lib/src/http/transforming_http_handler.dart new file mode 100644 index 00000000..dea514c3 --- /dev/null +++ b/lib/src/http/transforming_http_handler.dart @@ -0,0 +1,22 @@ +import 'package:json_api/http.dart'; + +class TransformingHttpHandler implements HttpHandler { + TransformingHttpHandler(this._handler, + {HttpRequestTransformer requestTransformer, + HttpResponseTransformer responseTransformer}) + : _requestTransformer = requestTransformer ?? _identity, + _responseTransformer = responseTransformer ?? _identity; + + final HttpHandler _handler; + final HttpRequestTransformer _requestTransformer; + final HttpResponseTransformer _responseTransformer; + + @override + Future call(HttpRequest request) async => + _responseTransformer(await _handler.call(_requestTransformer(request))); +} + +typedef HttpRequestTransformer = HttpRequest Function(HttpRequest _); +typedef HttpResponseTransformer = HttpResponse Function(HttpResponse _); + +T _identity(T _) => _; diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart index b55beced..6875e1db 100644 --- a/lib/src/query/query_parameters.dart +++ b/lib/src/query/query_parameters.dart @@ -3,6 +3,9 @@ class QueryParameters { QueryParameters(Map parameters) : _parameters = {...parameters}; + + QueryParameters.empty() : this(const {}); + final Map _parameters; bool get isEmpty => _parameters.isEmpty; diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index d2f3d26f..0e7d8cf8 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -13,6 +13,8 @@ abstract class ResponseFactory { HttpResponse noContent(); + HttpResponse accepted(Resource resource); + HttpResponse primaryResource( Request request, Resource resource, {Iterable include}); @@ -54,6 +56,15 @@ class HttpResponseFactory implements ResponseFactory { @override HttpResponse noContent() => HttpResponse(204); + @override + HttpResponse accepted(Resource resource) => HttpResponse(202, + headers: { + 'Content-Type': Document.contentType, + 'Content-Location': _uri.resource(resource.type, resource.id).toString() + }, + body: jsonEncode(Document(ResourceData(_resource(resource), + links: {'self': Link(_uri.resource(resource.type, resource.id))})))); + @override HttpResponse primaryResource( Request request, Resource resource, diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 8c3171ee..03496d00 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -23,7 +23,7 @@ void main() async { final channel = spawnHybridUri('hybrid_server.dart', message: port); await channel.stream.first; - final client = RoutingClient(JsonApiClient(DartHttp(httpClient)), routing); + final client = JsonApiClient(DartHttp(httpClient), routing); final writer = Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); @@ -33,14 +33,15 @@ void main() async { await client.createResource(book); await client .updateResource(Resource('books', '2', toMany: {'authors': []})); - await client.addToRelationship( - 'books', '2', 'authors', [Identifier('writers', '1')]); + await client + .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); - expect(response.data.unwrap().attributes['title'], 'Refactoring'); - expect(response.document.included.first.unwrap().attributes['name'], + expect(response.decodeDocument().data.unwrap().attributes['title'], + 'Refactoring'); + expect(response.decodeDocument().included.first.unwrap().attributes['name'], 'Martin Fowler'); }, testOn: 'browser'); } diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index 934e92bd..cbb570a8 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -20,13 +20,13 @@ void main() { final jsonApiServer = JsonApiServer(RepositoryController(repo)); final serverHandler = DartServer(jsonApiServer); Client httpClient; - RoutingClient client; + JsonApiClient client; HttpServer server; setUp(() async { server = await HttpServer.bind(host, port); httpClient = Client(); - client = RoutingClient(JsonApiClient(DartHttp(httpClient)), routing); + client = JsonApiClient(DartHttp(httpClient), routing); unawaited(server.forEach(serverHandler)); }); @@ -44,14 +44,16 @@ void main() { await client.createResource(book); await client .updateResource(Resource('books', '2', toMany: {'authors': []})); - await client.addToRelationship( - 'books', '2', 'authors', [Identifier('writers', '1')]); + await client + .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', parameters: Include(['authors'])); - expect(response.data.unwrap().attributes['title'], 'Refactoring'); - expect(response.document.included.first.unwrap().attributes['name'], + expect(response.decodeDocument().data.unwrap().attributes['title'], + 'Refactoring'); + expect( + response.decodeDocument().included.first.unwrap().attributes['name'], 'Martin Fowler'); }); }, testOn: 'vm'); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 6f5e6cf1..3e3654a2 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -10,7 +10,7 @@ import 'package:test/test.dart'; import '../helper/expect_resources_equal.dart'; void main() async { - RoutingClient client; + JsonApiClient client; JsonApiServer server; final host = 'localhost'; final port = 80; @@ -48,122 +48,125 @@ void main() async { 'tags': {} }); server = JsonApiServer(RepositoryController(repository)); - client = RoutingClient(JsonApiClient(server), routing); + client = JsonApiClient(server, routing); }); group('Single Resouces', () { test('not compound by default', () async { final r = await client.fetchResource('posts', '1'); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.isCompound, isFalse); + final document = r.decodeDocument(); + expectResourcesEqual(document.data.unwrap(), post); + expect(document.isCompound, isFalse); }); test('included == [] when requested but nothing to include', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.included, []); - expect(r.document.isCompound, isTrue); - expect(r.data.self.toString(), '/posts/1?include=tags'); + expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expect(r.decodeDocument().included, []); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().data.self.toString(), '/posts/1?include=tags'); }); test('can include first-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 2); - expectResourcesEqual(r.document.included[0].unwrap(), comment1); - expectResourcesEqual(r.document.included[1].unwrap(), comment2); + expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 2); + expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); + expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 2); - expectResourcesEqual(r.document.included.first.unwrap(), bob); - expectResourcesEqual(r.document.included.last.unwrap(), alice); + expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 2); + expectResourcesEqual(r.decodeDocument().included.first.unwrap(), bob); + expectResourcesEqual(r.decodeDocument().included.last.unwrap(), alice); }); test('can include third-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 1); - expectResourcesEqual(r.document.included.first.unwrap(), wonderland); + expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 1); + expectResourcesEqual( + r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.document.included.length, 4); - expectResourcesEqual(r.document.included[0].unwrap(), comment1); - expectResourcesEqual(r.document.included[1].unwrap(), comment2); - expectResourcesEqual(r.document.included[2].unwrap(), bob); - expectResourcesEqual(r.document.included[3].unwrap(), alice); - expect(r.document.isCompound, isTrue); + expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expect(r.decodeDocument().included.length, 4); + expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); + expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); + expectResourcesEqual(r.decodeDocument().included[2].unwrap(), bob); + expectResourcesEqual(r.decodeDocument().included[3].unwrap(), alice); + expect(r.decodeDocument().isCompound, isTrue); }); }); group('Resource Collection', () { test('not compound by default', () async { final r = await client.fetchCollection('posts'); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.isCompound, isFalse); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().isCompound, isFalse); }); test('document is compound when requested but nothing to include', () async { final r = await client.fetchCollection('posts', parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.included, []); - expect(r.document.isCompound, isTrue); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().included, []); + expect(r.decodeDocument().isCompound, isTrue); }); test('can include first-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 2); - expectResourcesEqual(r.document.included[0].unwrap(), comment1); - expectResourcesEqual(r.document.included[1].unwrap(), comment2); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 2); + expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); + expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.included.length, 2); - expectResourcesEqual(r.document.included.first.unwrap(), bob); - expectResourcesEqual(r.document.included.last.unwrap(), alice); - expect(r.document.isCompound, isTrue); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().included.length, 2); + expectResourcesEqual(r.decodeDocument().included.first.unwrap(), bob); + expectResourcesEqual(r.decodeDocument().included.last.unwrap(), alice); + expect(r.decodeDocument().isCompound, isTrue); }); test('can include third-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 1); - expectResourcesEqual(r.document.included.first.unwrap(), wonderland); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 1); + expectResourcesEqual( + r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.document.isCompound, isTrue); - expect(r.document.included.length, 4); - expectResourcesEqual(r.document.included[0].unwrap(), comment1); - expectResourcesEqual(r.document.included[1].unwrap(), comment2); - expectResourcesEqual(r.document.included[2].unwrap(), bob); - expectResourcesEqual(r.document.included[3].unwrap(), alice); + expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expect(r.decodeDocument().isCompound, isTrue); + expect(r.decodeDocument().included.length, 4); + expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); + expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); + expectResourcesEqual(r.decodeDocument().included[2].unwrap(), bob); + expectResourcesEqual(r.decodeDocument().included[3].unwrap(), alice); }); }); } diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 740cf72f..dc433380 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -11,7 +11,7 @@ void main() async { final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); + final urls = StandardRouting(base); group('Server-genrated ID', () { test('201 Created', () async { @@ -19,35 +19,35 @@ void main() async { 'people': {}, }, nextId: Uuid().v4); final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server); - final routingClient = RoutingClient(client, routing); + final client = JsonApiClient(server, urls); final person = NewResource('people', attributes: {'name': 'Martin Fowler'}); - final r = await routingClient.createResource(person); - expect(r.statusCode, 201); - expect(r.headers['content-type'], Document.contentType); - expect(r.location, isNotNull); - expect(r.location, r.data.links['self'].uri); - final created = r.data.unwrap(); + final r = await client.createResource(person); + expect(r.http.statusCode, 201); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['location'], isNotNull); + expect(r.http.headers['location'], + r.decodeDocument().data.links['self'].uri.toString()); + final created = r.decodeDocument().data.unwrap(); expect(created.type, person.type); expect(created.id, isNotNull); expect(created.attributes, equals(person.attributes)); - final r1 = await client.fetchResourceAt(r.location); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), created); + final r1 = await client.send( + Request.fetchResource(), Uri.parse(r.http.headers['location'])); + expect(r1.http.statusCode, 200); + expectResourcesEqual(r1.decodeDocument().data.unwrap(), created); }); test('403 when the id can not be generated', () async { final repository = InMemoryRepository({'people': {}}); final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server); - final routingClient = RoutingClient(client, routing); + final routingClient = JsonApiClient(server, urls); final r = await routingClient.createResource(Resource('people', null)); - expect(r.statusCode, 403); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 403); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '403'); expect(error.title, 'Unsupported operation'); expect(error.detail, 'Id generation is not supported'); @@ -56,7 +56,6 @@ void main() async { group('Client-genrated ID', () { JsonApiClient client; - RoutingClient routingClient; setUp(() async { final repository = InMemoryRepository({ 'books': {}, @@ -67,32 +66,31 @@ void main() async { 'apples': {} }); final server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, urls); }); test('204 No Content', () async { final person = Resource('people', '123', attributes: {'name': 'Martin Fowler'}); - final r = await routingClient.createResource(person); + final r = await client.createResource(person); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.location, isNull); - expect(r.data, isNull); - final r1 = await routingClient.fetchResource(person.type, person.id); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); + expect(r.http.headers['location'], isNull); + final r1 = await client.fetchResource(person.type, person.id); expect(r1.isSuccessful, isTrue); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), person); + expect(r1.http.statusCode, 200); + expectResourcesEqual(r1.decodeDocument().data.unwrap(), person); }); test('404 when the collection does not exist', () async { - final r = await routingClient.createResource(Resource('unicorns', null)); + final r = await client.createResource(Resource('unicorns', null)); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); @@ -101,13 +99,13 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { final book = Resource('books', null, toOne: {'publisher': Identifier('companies', '123')}); - final r = await routingClient.createResource(book); + final r = await client.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '123' does not exist in 'companies'"); @@ -117,27 +115,29 @@ void main() async { final book = Resource('books', null, toMany: { 'authors': [Identifier('people', '123')] }); - final r = await routingClient.createResource(book); + final r = await client.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '123' does not exist in 'people'"); }); test('409 when the resource type does not match collection', () async { - final r = await client.createResourceAt( - routing.collection('fruits'), Resource('cucumbers', null)); + final r = await client.send( + Request.createResource( + Document(ResourceData.fromResource(Resource('cucumbers', null)))), + urls.collection('fruits')); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 409); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '409'); expect(error.title, 'Invalid resource type'); expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); @@ -145,14 +145,14 @@ void main() async { test('409 when the resource with this id already exists', () async { final apple = Resource('apples', '123'); - await routingClient.createResource(apple); - final r = await routingClient.createResource(apple); + await client.createResource(apple); + final r = await client.createResource(apple); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 409); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '409'); expect(error.title, 'Resource exists'); expect(error.detail, 'Resource with this type and id already exists'); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index ff6b76dc..2828ca6d 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -12,7 +12,6 @@ import 'seed_resources.dart'; void main() async { JsonApiServer server; JsonApiClient client; - RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); @@ -22,45 +21,44 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, routing); - await seedResources(routingClient); + await seedResources(client); }); test('successful', () async { - final r = await routingClient.deleteResource('books', '1'); + final r = await client.deleteResource('books', '1'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); - final r1 = await routingClient.fetchResource('books', '1'); + final r1 = await client.fetchResource('books', '1'); expect(r1.isSuccessful, isFalse); - expect(r1.statusCode, 404); - expect(r1.headers['content-type'], Document.contentType); + expect(r1.http.statusCode, 404); + expect(r1.http.headers['content-type'], Document.contentType); }); - test('404 on collecton', () async { - final r = await routingClient.deleteResource('unicorns', '42'); + test('404 on collection', () async { + final r = await client.deleteResource('unicorns', '42'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.deleteResource('books', '42'); + final r = await client.deleteResource('books', '42'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 82bfb1f5..500593a3 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -12,7 +12,6 @@ import 'seed_resources.dart'; void main() async { JsonApiServer server; JsonApiClient client; - RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); @@ -22,107 +21,113 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, routing); - await seedResources(routingClient); + await seedResources(client); }); group('To-one', () { test('200 OK', () async { - final r = await routingClient.fetchToOne('books', '1', 'publisher'); + final r = await client.fetchToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.self.uri.toString(), '/books/1/relationships/publisher'); - expect(r.data.related.uri.toString(), '/books/1/publisher'); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.self.uri.toString(), + '/books/1/relationships/publisher'); + expect( + r.decodeDocument().data.related.uri.toString(), '/books/1/publisher'); + expect(r.decodeDocument().data.unwrap().type, 'companies'); + expect(r.decodeDocument().data.unwrap().id, '1'); }); test('404 on collection', () async { - final r = await routingClient.fetchToOne('unicorns', '1', 'publisher'); + final r = await client.fetchToOne('unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.fetchToOne('books', '42', 'publisher'); + final r = await client.fetchToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'books'"); }); test('404 on relationship', () async { - final r = await routingClient.fetchToOne('books', '1', 'owner'); + final r = await client.fetchToOne('books', '1', 'owner'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Relationship not found'); + expect(r.decodeDocument().errors.first.detail, "Relationship 'owner' does not exist in this resource"); }); }); group('To-many', () { test('200 OK', () async { - final r = await routingClient.fetchToMany('books', '1', 'authors'); + final r = await client.fetchToMany('books', '1', 'authors'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.type, 'people'); - expect(r.data.self.uri.toString(), '/books/1/relationships/authors'); - expect(r.data.related.uri.toString(), '/books/1/authors'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().length, 2); + expect(r.decodeDocument().data.unwrap().first.type, 'people'); + expect(r.decodeDocument().data.self.uri.toString(), + '/books/1/relationships/authors'); + expect( + r.decodeDocument().data.related.uri.toString(), '/books/1/authors'); }); test('404 on collection', () async { - final r = await routingClient.fetchToMany('unicorns', '1', 'athors'); + final r = await client.fetchToMany('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.fetchToMany('books', '42', 'authors'); + final r = await client.fetchToMany('books', '42', 'authors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'books'"); }); test('404 on relationship', () async { - final r = await routingClient.fetchToMany('books', '1', 'readers'); + final r = await client.fetchToMany('books', '1', 'readers'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Relationship not found'); + expect(r.decodeDocument().errors.first.detail, "Relationship 'readers' does not exist in this resource"); }); }); group('Generic', () { test('200 OK to-one', () async { - final r = - await routingClient.fetchRelationship('books', '1', 'publisher'); + final r = await client.fetchRelationship('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - final rel = r.data; + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + final rel = r.decodeDocument().data; if (rel is ToOne) { expect(rel.unwrap().type, 'companies'); expect(rel.unwrap().id, '1'); @@ -132,11 +137,11 @@ void main() async { }); test('200 OK to-many', () async { - final r = await routingClient.fetchRelationship('books', '1', 'authors'); + final r = await client.fetchRelationship('books', '1', 'authors'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - final rel = r.data; + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + final rel = r.decodeDocument().data; if (rel is ToMany) { expect(rel.unwrap().length, 2); expect(rel.unwrap().first.id, '1'); @@ -149,34 +154,35 @@ void main() async { }); test('404 on collection', () async { - final r = - await routingClient.fetchRelationship('unicorns', '1', 'athors'); + final r = await client.fetchRelationship('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.fetchRelationship('books', '42', 'authors'); + final r = await client.fetchRelationship('books', '42', 'authors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'books'"); }); test('404 on relationship', () async { - final r = await routingClient.fetchRelationship('books', '1', 'readers'); + final r = await client.fetchRelationship('books', '1', 'readers'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Relationship not found'); + expect(r.decodeDocument().errors.first.detail, "Relationship 'readers' does not exist in this resource"); }); }); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index ac50a9f9..433d924c 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -12,7 +12,6 @@ import 'seed_resources.dart'; void main() async { JsonApiServer server; JsonApiClient client; - RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); @@ -22,172 +21,187 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, routing); - await seedResources(routingClient); + await seedResources(client); }); group('Primary Resource', () { test('200 OK', () async { - final r = await routingClient.fetchResource('books', '1'); + final r = await client.fetchResource('books', '1'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().id, '1'); - expect(r.data.unwrap().attributes['title'], 'Refactoring'); - expect(r.data.self.uri.toString(), '/books/1'); - expect(r.data.resourceObject.links['self'].uri.toString(), '/books/1'); - final authors = r.data.resourceObject.relationships['authors']; + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().id, '1'); + expect( + r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); + expect(r.decodeDocument().data.self.uri.toString(), '/books/1'); + expect( + r.decodeDocument().data.resourceObject.links['self'].uri.toString(), + '/books/1'); + final authors = + r.decodeDocument().data.resourceObject.relationships['authors']; expect(authors.self.toString(), '/books/1/relationships/authors'); expect(authors.related.toString(), '/books/1/authors'); - final publisher = r.data.resourceObject.relationships['publisher']; + final publisher = + r.decodeDocument().data.resourceObject.relationships['publisher']; expect(publisher.self.toString(), '/books/1/relationships/publisher'); expect(publisher.related.toString(), '/books/1/publisher'); }); test('404 on collection', () async { - final r = await routingClient.fetchResource('unicorns', '1'); + final r = await client.fetchResource('unicorns', '1'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.fetchResource('people', '42'); + final r = await client.fetchResource('people', '42'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'people'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'people'"); }); }); group('Primary collections', () { test('200 OK', () async { - final r = await routingClient.fetchCollection('people'); + final r = await client.fetchCollection('people'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.links['self'].uri.toString(), '/people'); - expect(r.data.collection.length, 3); - expect(r.data.collection.first.self.uri.toString(), '/people/1'); - expect(r.data.collection.last.self.uri.toString(), '/people/3'); - expect(r.data.unwrap().length, 3); - expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); - expect(r.data.unwrap().last.attributes['name'], 'Robert Martin'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.links['self'].uri.toString(), '/people'); + expect(r.decodeDocument().data.collection.length, 3); + expect(r.decodeDocument().data.collection.first.self.uri.toString(), + '/people/1'); + expect(r.decodeDocument().data.collection.last.self.uri.toString(), + '/people/3'); + expect(r.decodeDocument().data.unwrap().length, 3); + expect(r.decodeDocument().data.unwrap().first.attributes['name'], + 'Martin Fowler'); + expect(r.decodeDocument().data.unwrap().last.attributes['name'], + 'Robert Martin'); }); test('404', () async { - final r = await routingClient.fetchCollection('unicorns'); + final r = await client.fetchCollection('unicorns'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); }); group('Related Resource', () { test('200 OK', () async { - final r = - await routingClient.fetchRelatedResource('books', '1', 'publisher'); + final r = await client.fetchRelatedResource('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); - expect(r.data.links['self'].uri.toString(), '/books/1/publisher'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().type, 'companies'); + expect(r.decodeDocument().data.unwrap().id, '1'); + expect(r.decodeDocument().data.links['self'].uri.toString(), + '/books/1/publisher'); expect( - r.data.resourceObject.links['self'].uri.toString(), '/companies/1'); + r.decodeDocument().data.resourceObject.links['self'].uri.toString(), + '/companies/1'); }); test('404 on collection', () async { - final r = await routingClient.fetchRelatedResource( - 'unicorns', '1', 'publisher'); + final r = await client.fetchRelatedResource('unicorns', '1', 'publisher'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = - await routingClient.fetchRelatedResource('books', '42', 'publisher'); + final r = await client.fetchRelatedResource('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'books'"); }); test('404 on relationship', () async { - final r = await routingClient.fetchRelatedResource('books', '1', 'owner'); + final r = await client.fetchRelatedResource('books', '1', 'owner'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Relationship not found'); + expect(r.decodeDocument().errors.first.detail, "Relationship 'owner' does not exist in this resource"); }); }); group('Related Collection', () { test('successful', () async { - final r = - await routingClient.fetchRelatedCollection('books', '1', 'authors'); + final r = await client.fetchRelatedCollection('books', '1', 'authors'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.links['self'].uri.toString(), '/books/1/authors'); - expect(r.data.collection.length, 2); - expect(r.data.collection.first.self.uri.toString(), '/people/1'); - expect(r.data.collection.last.self.uri.toString(), '/people/2'); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); - expect(r.data.unwrap().last.attributes['name'], 'Kent Beck'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.links['self'].uri.toString(), + '/books/1/authors'); + expect(r.decodeDocument().data.collection.length, 2); + expect(r.decodeDocument().data.collection.first.self.uri.toString(), + '/people/1'); + expect(r.decodeDocument().data.collection.last.self.uri.toString(), + '/people/2'); + expect(r.decodeDocument().data.unwrap().length, 2); + expect(r.decodeDocument().data.unwrap().first.attributes['name'], + 'Martin Fowler'); + expect(r.decodeDocument().data.unwrap().last.attributes['name'], + 'Kent Beck'); }); test('404 on collection', () async { - final r = - await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); + final r = await client.fetchRelatedCollection('unicorns', '1', 'athors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Collection not found'); + expect(r.decodeDocument().errors.first.detail, + "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = - await routingClient.fetchRelatedCollection('books', '42', 'authors'); + final r = await client.fetchRelatedCollection('books', '42', 'authors'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Resource not found'); + expect(r.decodeDocument().errors.first.detail, + "Resource '42' does not exist in 'books'"); }); test('404 on relationship', () async { - final r = - await routingClient.fetchRelatedCollection('books', '1', 'readers'); + final r = await client.fetchRelatedCollection('books', '1', 'readers'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().errors.first.status, '404'); + expect(r.decodeDocument().errors.first.title, 'Relationship not found'); + expect(r.decodeDocument().errors.first.detail, "Relationship 'readers' does not exist in this resource"); }); }); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index bf0cdc2a..e65f6a45 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -Future seedResources(RoutingClient client) async { +Future seedResources(JsonApiClient client) async { await client.createResource( Resource('people', '1', attributes: {'name': 'Martin Fowler'})); await client.createResource( diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 48568edb..625b3749 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -12,7 +12,6 @@ import 'seed_resources.dart'; void main() async { JsonApiServer server; JsonApiClient client; - RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); @@ -22,45 +21,44 @@ void main() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, routing); - await seedResources(routingClient); + await seedResources(client); }); group('Updatng a to-one relationship', () { test('204 No Content', () async { - final r = await routingClient.replaceToOne( + final r = await client.replaceToOne( 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'].id, '2'); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toOne['publisher'].id, '2'); }); test('404 on collection', () async { - final r = await routingClient.replaceToOne( + final r = await client.replaceToOne( 'unicorns', '1', 'breed', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.replaceToOne( + final r = await client.replaceToOne( 'books', '42', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); @@ -69,34 +67,34 @@ void main() async { group('Deleting a to-one relationship', () { test('204 No Content', () async { - final r = await routingClient.deleteToOne('books', '1', 'publisher'); + final r = await client.deleteToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'], isNull); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toOne['publisher'], isNull); }); test('404 on collection', () async { - final r = await routingClient.deleteToOne('unicorns', '1', 'breed'); + final r = await client.deleteToOne('unicorns', '1', 'breed'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 on resource', () async { - final r = await routingClient.deleteToOne('books', '42', 'publisher'); + final r = await client.deleteToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); @@ -105,38 +103,38 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { - final r = await routingClient + final r = await client .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); - expect(r1.data.unwrap().toMany['authors'].first.id, '1'); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 1); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].first.id, '1'); }); test('404 when collection not found', () async { - final r = await routingClient.replaceToMany( + final r = await client.replaceToMany( 'unicorns', '1', 'breed', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 when resource not found', () async { - final r = await routingClient.replaceToMany( + final r = await client.replaceToMany( 'books', '42', 'publisher', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); @@ -145,68 +143,68 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '3')]); + final r = await client + .addToMany('books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().length, 3); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '3'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().length, 3); + expect(r.decodeDocument().data.unwrap().first.id, '1'); + expect(r.decodeDocument().data.unwrap().last.id, '3'); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 3); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 3); }); test('successfully adding an existing identifier', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '2')]); + final r = await client + .addToMany('books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().length, 2); + expect(r.decodeDocument().data.unwrap().first.id, '1'); + expect(r.decodeDocument().data.unwrap().last.id, '2'); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); - expect(r1.headers['content-type'], Document.contentType); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); + expect(r1.http.headers['content-type'], Document.contentType); }); test('404 when collection not found', () async { - final r = await routingClient.addToRelationship( - 'unicorns', '1', 'breed', [Identifier('companies', '3')]); + final r = await client + .addToMany('unicorns', '1', 'breed', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 when resource not found', () async { - final r = await routingClient.addToRelationship( + final r = await client.addToMany( 'books', '42', 'publisher', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); test('404 when relationship not found', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'sellers', [Identifier('companies', '3')]); + final r = await client + .addToMany('books', '1', 'sellers', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Relationship not found'); expect(error.detail, @@ -216,53 +214,53 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { - final r = await routingClient.deleteFromToMany( + final r = await client.deleteFromToMany( 'books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().length, 1); - expect(r.data.unwrap().first.id, '2'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().length, 1); + expect(r.decodeDocument().data.unwrap().first.id, '2'); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 1); }); test('successfully deleting a non-present identifier', () async { - final r = await routingClient.deleteFromToMany( + final r = await client.deleteFromToMany( 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().length, 2); + expect(r.decodeDocument().data.unwrap().first.id, '1'); + expect(r.decodeDocument().data.unwrap().last.id, '2'); - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); + final r1 = await client.fetchResource('books', '1'); + expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); }); test('404 when collection not found', () async { - final r = await routingClient.deleteFromToMany( + final r = await client.deleteFromToMany( 'unicorns', '1', 'breed', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Collection not found'); expect(error.detail, "Collection 'unicorns' does not exist"); }); test('404 when resource not found', () async { - final r = await routingClient.deleteFromToMany( + final r = await client.deleteFromToMany( 'books', '42', 'publisher', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index dd055d4a..5730e502 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -13,25 +13,22 @@ import 'seed_resources.dart'; void main() async { JsonApiServer server; JsonApiClient client; - RoutingClient routingClient; final host = 'localhost'; final port = 80; final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); + final urls = StandardRouting(base); setUp(() async { final repository = InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); + client = JsonApiClient(server, urls); - await seedResources(routingClient); + await seedResources(client); }); test('200 OK', () async { - final r = - await routingClient.updateResource(Resource('books', '1', attributes: { + final r = await client.updateResource(Resource('books', '1', attributes: { 'title': 'Refactoring. Improving the Design of Existing Code', 'pages': 448 }, toOne: { @@ -41,49 +38,53 @@ void main() async { 'reviewers': [Identifier('people', '2')] })); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.headers['content-type'], Document.contentType); - expect(r.data.unwrap().attributes['title'], + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring. Improving the Design of Existing Code'); - expect(r.data.unwrap().attributes['pages'], 448); - expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.data.unwrap().toOne['publisher'], isNull); + expect(r.decodeDocument().data.unwrap().attributes['pages'], 448); expect( - r.data.unwrap().toMany['authors'], equals([Identifier('people', '1')])); - expect(r.data.unwrap().toMany['reviewers'], + r.decodeDocument().data.unwrap().attributes['ISBN-10'], '0134757599'); + expect(r.decodeDocument().data.unwrap().toOne['publisher'], isNull); + expect(r.decodeDocument().data.unwrap().toMany['authors'], + equals([Identifier('people', '1')])); + expect(r.decodeDocument().data.unwrap().toMany['reviewers'], equals([Identifier('people', '2')])); - final r1 = await routingClient.fetchResource('books', '1'); - expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); + final r1 = await client.fetchResource('books', '1'); + expectResourcesEqual( + r1.decodeDocument().data.unwrap(), r.decodeDocument().data.unwrap()); }); test('204 No Content', () async { - final r = await routingClient.updateResource(Resource('books', '1')); + final r = await client.updateResource(Resource('books', '1')); expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); + expect(r.isEmpty, isTrue); + expect(r.http.statusCode, 204); }); test('404 on the target resource', () async { - final r = await routingClient.updateResource(Resource('books', '42')); + final r = await client.updateResource(Resource('books', '42')); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 404); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); test('409 when the resource type does not match the collection', () async { - final r = await client.updateResourceAt( - routing.resource('people', '1'), Resource('books', '1')); + final r = await client.send( + Request.updateResource( + Document(ResourceData.fromResource(Resource('books', '1')))), + urls.resource('people', '1')); expect(r.isSuccessful, isFalse); - expect(r.statusCode, 409); - expect(r.headers['content-type'], Document.contentType); - expect(r.data, isNull); - final error = r.errors.first; + expect(r.http.statusCode, 409); + expect(r.http.headers['content-type'], Document.contentType); + expect(r.decodeDocument().data, isNull); + final error = r.decodeDocument().errors.first; expect(error.status, '409'); expect(error.title, 'Invalid resource type'); expect(error.detail, "Type 'books' does not belong in 'people'"); diff --git a/test/helper/test_http_handler.dart b/test/helper/test_http_handler.dart index 26a9275e..ef437885 100644 --- a/test/helper/test_http_handler.dart +++ b/test/helper/test_http_handler.dart @@ -2,11 +2,11 @@ import 'package:json_api/http.dart'; class TestHttpHandler implements HttpHandler { final requestLog = []; - HttpResponse nextResponse; + HttpResponse response; @override Future call(HttpRequest request) async { requestLog.add(request); - return nextResponse; + return response; } } diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart index 9d209620..ec9a2ac4 100644 --- a/test/unit/client/async_processing_test.dart +++ b/test/unit/client/async_processing_test.dart @@ -1,24 +1,26 @@ import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/response_factory.dart'; import 'package:test/test.dart'; import '../../helper/test_http_handler.dart'; void main() { final handler = TestHttpHandler(); - final client = RoutingClient(JsonApiClient(handler), StandardRouting()); final routing = StandardRouting(); + final client = JsonApiClient(handler, routing); + final responseFactory = HttpResponseFactory(routing); test('Client understands async responses', () async { -// final responseFactory = HttpResponseConverter(Uri.parse('/books'), routing); -// handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); -// -// final r = await client.createResource(Resource('books', '1')); -// expect(r.isAsync, true); -// expect(r.isSuccessful, false); -// expect(r.isFailed, false); -// expect(r.asyncData.unwrap().type, 'jobs'); -// expect(r.asyncData.unwrap().id, '42'); -// expect(r.contentLocation.toString(), '/jobs/42'); + handler.response = responseFactory.accepted(Resource('jobs', '42')); + + final r = await client.createResource(Resource('books', '1')); + expect(r.isAsync, true); + expect(r.isSuccessful, false); + expect(r.isFailed, false); + expect(r.decodeAsyncDocument().data.unwrap().type, 'jobs'); + expect(r.decodeAsyncDocument().data.unwrap().id, '42'); + expect(r.http.headers['content-location'], '/jobs/42'); }); } From 0e46903c7af9374b41557194508b0ed83da3d182 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 18:47:29 -0700 Subject: [PATCH 55/99] Remove JsonEncodable --- lib/src/document/api.dart | 4 +--- lib/src/document/document.dart | 4 +--- lib/src/document/error_object.dart | 4 +--- lib/src/document/identifier_object.dart | 4 +--- lib/src/document/json_encodable.dart | 4 ---- lib/src/document/link.dart | 6 ++---- lib/src/document/primary_data.dart | 4 +--- lib/src/document/resource_object.dart | 4 +--- 8 files changed, 8 insertions(+), 26 deletions(-) delete mode 100644 lib/src/document/json_encodable.dart diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index bb1fbed1..ace7a263 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -1,8 +1,7 @@ import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object -class Api implements JsonEncodable { +class Api { Api({String version, Map meta}) : meta = Map.unmodifiable(meta ?? const {}), version = version ?? ''; @@ -22,7 +21,6 @@ class Api implements JsonEncodable { throw DocumentException("The 'jsonapi' member must be a JSON object"); } - @override Map toJson() => { if (version.isNotEmpty) 'version': version, if (meta.isNotEmpty) 'meta': meta, diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index f40b065c..a4317a22 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -2,11 +2,10 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/api.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/error_object.dart'; -import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/nullable.dart'; -class Document implements JsonEncodable { +class Document { /// Create a document with primary data Document(this.data, {Map meta, Api api, Iterable included}) @@ -99,7 +98,6 @@ class Document implements JsonEncodable { static const contentType = 'application/vnd.api+json'; - @override Map toJson() => { if (data != null) ...data.toJson() else if (isError) 'errors': errors, if (isMeta || meta.isNotEmpty) 'meta': meta, diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index bd3d80c7..e6b73d8b 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,13 +1,12 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/nullable.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject implements JsonEncodable { +class ErrorObject { /// Creates an instance of a JSON:API Error. /// The [links] map may contain custom links. The about link /// passed through the [about] argument takes precedence and will overwrite @@ -87,7 +86,6 @@ class ErrorObject implements JsonEncodable { /// - parameter: a string indicating which URI query parameter caused the error. final Map source; - @override Map toJson() { return { if (id.isNotEmpty) 'id': id, diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index 88883f5f..12ebb15c 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,10 +1,9 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_encodable.dart'; /// [IdentifierObject] is a JSON representation of the [Identifier]. /// It carries all JSON-related logic and the Meta-data. -class IdentifierObject implements JsonEncodable { +class IdentifierObject { /// Creates an instance of [IdentifierObject]. /// [type] and [id] can not be null. IdentifierObject(this.type, this.id, {Map meta}) @@ -35,7 +34,6 @@ class IdentifierObject implements JsonEncodable { Identifier unwrap() => Identifier(type, id); - @override Map toJson() => { 'type': type, 'id': id, diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart deleted file mode 100644 index 78477116..00000000 --- a/lib/src/document/json_encodable.dart +++ /dev/null @@ -1,4 +0,0 @@ -abstract class JsonEncodable { - /// Converts the object to a json-encodable representation - Object toJson(); -} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index aafcb124..918ba273 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,9 +1,8 @@ import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link implements JsonEncodable { +class Link { Link(this.uri) { ArgumentError.checkNotNull(uri, 'uri'); } @@ -33,7 +32,6 @@ class Link implements JsonEncodable { throw DocumentException('A JSON:API links object must be a JSON object'); } - @override Object toJson() => uri.toString(); @override @@ -50,7 +48,7 @@ class LinkObject extends Link { final Map meta; @override - Object toJson() => { + Map toJson() => { 'href': uri.toString(), if (meta.isNotEmpty) 'meta': meta, }; diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index ac8759a4..5a93f43b 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -1,4 +1,3 @@ -import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; /// The top-level Primary Data. This is the essentials of the JSON:API Document. @@ -6,7 +5,7 @@ import 'package:json_api/src/document/link.dart'; /// [PrimaryData] may be considered a Document itself with two limitations: /// - 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 implements JsonEncodable { +abstract class PrimaryData { PrimaryData({Map links}) : links = Map.unmodifiable(links ?? const {}); @@ -16,7 +15,6 @@ abstract class PrimaryData implements JsonEncodable { /// The `self` link. May be null. Link get self => links['self']; - @override Map toJson() => { if (links.isNotEmpty) 'links': links, }; diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 31e70e27..9096f8ec 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -1,7 +1,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_encodable.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/document/resource.dart'; @@ -14,7 +13,7 @@ import 'package:json_api/src/nullable.dart'; /// resource collection. /// /// More on this: https://jsonapi.org/format/#document-resource-objects -class ResourceObject implements JsonEncodable { +class ResourceObject { ResourceObject(this.type, this.id, {Map attributes, Map relationships, @@ -69,7 +68,6 @@ class ResourceObject implements JsonEncodable { /// Returns the JSON object to be used in the `data` or `included` members /// of a JSON:API Document - @override Map toJson() => { 'type': type, if (id != null) 'id': id, From f2aac7bfb7725efc979af0c0484b978d3c67d2c0 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 19:17:57 -0700 Subject: [PATCH 56/99] Remove response.isEmpty --- lib/src/client/response.dart | 4 ---- lib/src/document/identifier.dart | 3 --- test/functional/crud/creating_resources_test.dart | 1 - test/functional/crud/deleting_resources_test.dart | 1 - test/functional/crud/updating_relationships_test.dart | 3 --- test/functional/crud/updating_resources_test.dart | 1 - 6 files changed, 13 deletions(-) diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 12357e88..3236feea 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -31,10 +31,6 @@ class Response { return Document.fromJson(jsonDecode(http.body), ResourceData.fromJson); } - bool get isEmpty => http.body.isEmpty; - - bool get isNotEmpty => http.body.isNotEmpty; - /// Was the query successful? /// /// For pending (202 Accepted) requests both [isSuccessful] and [isFailed] diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 68500f3e..9f2567fe 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -28,9 +28,6 @@ class Identifier { other.type == type && other.id == id; - @override - String toString() => 'Identifier($type:$id)'; - @override bool operator ==(other) => equals(other); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index dc433380..de93d020 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -74,7 +74,6 @@ void main() async { Resource('people', '123', attributes: {'name': 'Martin Fowler'}); final r = await client.createResource(person); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); expect(r.http.headers['location'], isNull); final r1 = await client.fetchResource(person.type, person.id); diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 2828ca6d..3da09c01 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -29,7 +29,6 @@ void main() async { test('successful', () async { final r = await client.deleteResource('books', '1'); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 625b3749..0bf09ad1 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -31,7 +31,6 @@ void main() async { final r = await client.replaceToOne( 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); @@ -69,7 +68,6 @@ void main() async { test('204 No Content', () async { final r = await client.deleteToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); @@ -106,7 +104,6 @@ void main() async { final r = await client .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 5730e502..9ef64c57 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -59,7 +59,6 @@ void main() async { test('204 No Content', () async { final r = await client.updateResource(Resource('books', '1')); expect(r.isSuccessful, isTrue); - expect(r.isEmpty, isTrue); expect(r.http.statusCode, 204); }); From ec3098337d53e3ad9335250817f3850b1841f2ff Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 20:39:47 -0700 Subject: [PATCH 57/99] Mutable meta, mutable links --- lib/src/document/links.dart | 0 lib/src/document/meta.dart | 3 +++ 2 files changed, 3 insertions(+) create mode 100644 lib/src/document/links.dart create mode 100644 lib/src/document/meta.dart diff --git a/lib/src/document/links.dart b/lib/src/document/links.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/src/document/meta.dart b/lib/src/document/meta.dart new file mode 100644 index 00000000..81199f53 --- /dev/null +++ b/lib/src/document/meta.dart @@ -0,0 +1,3 @@ +mixin Meta { + final meta = {}; +} From 64336fb33cf522ba0dc8fdd73bfeda0c7ac9564c Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 20:40:15 -0700 Subject: [PATCH 58/99] Mutable meta, mutable links --- lib/src/document/api.dart | 16 ++++----- lib/src/document/document.dart | 15 ++++----- lib/src/document/error_object.dart | 26 +++++---------- lib/src/document/identifier_object.dart | 10 +++--- lib/src/document/link.dart | 33 +++++-------------- lib/src/document/links.dart | 8 +++++ lib/src/document/meta.dart | 1 + lib/src/document/primary_data.dart | 14 +++----- lib/src/document/relationship.dart | 6 ++-- .../document/resource_collection_data.dart | 12 ------- lib/src/document/resource_data.dart | 4 +-- lib/src/document/resource_object.dart | 17 +++++----- test/functional/compound_document_test.dart | 3 +- .../crud/fetching_relationships_test.dart | 4 +-- .../crud/fetching_resources_test.dart | 8 +++-- test/unit/document/api_test.dart | 4 +-- test/unit/document/json_api_error_test.dart | 2 +- test/unit/document/link_test.dart | 9 ++--- test/unit/document/relationship_test.dart | 2 +- .../resource_collection_data_test.dart | 2 +- test/unit/document/resource_data_test.dart | 16 +-------- test/unit/document/to_many_test.dart | 2 +- test/unit/document/to_one_test.dart | 2 +- 23 files changed, 80 insertions(+), 136 deletions(-) diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart index ace7a263..2ccf50fd 100644 --- a/lib/src/document/api.dart +++ b/lib/src/document/api.dart @@ -1,17 +1,17 @@ import 'package:json_api/src/document/document_exception.dart'; +import 'package:json_api/src/document/meta.dart'; /// Details: https://jsonapi.org/format/#document-jsonapi-object -class Api { - Api({String version, Map meta}) - : meta = Map.unmodifiable(meta ?? const {}), - version = version ?? ''; +class Api with Meta { + Api({String version, Map meta}) : version = version ?? v1 { + this.meta.addAll(meta ?? {}); + } + + static const v1 = '1.0'; /// The JSON:API version. May be null. final String version; - /// Meta data. May be empty or null. - final Map meta; - bool get isNotEmpty => version.isNotEmpty || meta.isNotEmpty; static Api fromJson(Object json) { @@ -22,7 +22,7 @@ class Api { } Map toJson() => { - if (version.isNotEmpty) 'version': version, + if (version != v1) 'version': version, if (meta.isNotEmpty) 'meta': meta, }; } diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index a4317a22..e3dca051 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -2,21 +2,22 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/api.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api/src/document/meta.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/nullable.dart'; -class Document { +class Document with Meta { /// Create a document with primary data Document(this.data, {Map meta, Api api, Iterable included}) : errors = const [], included = List.unmodifiable(included ?? []), - meta = Map.unmodifiable(meta ?? const {}), api = api ?? Api(), isError = false, isCompound = included != null, isMeta = false { ArgumentError.checkNotNull(data); + this.meta.addAll(meta ?? {}); } /// Create a document with errors (no primary data) @@ -24,17 +25,17 @@ class Document { {Map meta, Api api}) : data = null, included = const [], - meta = Map.unmodifiable(meta ?? const {}), errors = List.unmodifiable(errors ?? const []), api = api ?? Api(), isCompound = false, isError = true, - isMeta = false; + isMeta = false { + this.meta.addAll(meta ?? {}); + } /// Create an empty document (no primary data and no errors) Document.empty(Map meta, {Api api}) : data = null, - meta = Map.unmodifiable(meta ?? const {}), included = const [], errors = const [], api = api ?? Api(), @@ -42,6 +43,7 @@ class Document { isCompound = false, isMeta = true { ArgumentError.checkNotNull(meta); + this.meta.addAll(meta); } /// The Primary Data. May be null. @@ -56,9 +58,6 @@ class Document { /// List of errors. May be empty. final List errors; - /// Meta data. May be empty. - final Map meta; - /// The `jsonapi` object. final Api api; diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index e6b73d8b..709fc0b8 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,15 +1,17 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/links.dart'; +import 'package:json_api/src/document/meta.dart'; import 'package:json_api/src/nullable.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject { +class ErrorObject with Meta, 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 + /// passed through the [links['about']] argument takes precedence and will overwrite /// the `about` key in [links]. ErrorObject({ String id, @@ -25,9 +27,10 @@ class ErrorObject { code = code ?? '', title = title ?? '', detail = detail ?? '', - source = Map.unmodifiable(source ?? const {}), - links = Map.unmodifiable(links ?? const {}), - meta = Map.unmodifiable(meta ?? const {}); + source = Map.unmodifiable(source ?? const {}) { + this.meta.addAll(meta ?? {}); + this.links.addAll(links ?? {}); + } static ErrorObject fromJson(Object json) { if (json is Map) { @@ -48,10 +51,6 @@ class ErrorObject { /// May be empty. final String id; - /// A link that leads to further details about this particular occurrence of the problem. - /// May be null. - Link get about => links['about']; - /// The HTTP status code applicable to this problem, expressed as a string value. /// May be empty. final String status; @@ -70,15 +69,6 @@ class ErrorObject { /// May be empty. final String detail; - /// A meta object containing non-standard meta-information about the error. - /// May be empty. - final Map meta; - - /// The `links` object. - /// May be empty. - /// https://jsonapi.org/format/#document-links - final Map links; - /// The `source` object. /// An object containing references to the source of the error, optionally including any of the following members: /// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart index 12ebb15c..dd782954 100644 --- a/lib/src/document/identifier_object.dart +++ b/lib/src/document/identifier_object.dart @@ -1,15 +1,16 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/meta.dart'; /// [IdentifierObject] is a JSON representation of the [Identifier]. /// It carries all JSON-related logic and the Meta-data. -class IdentifierObject { +class IdentifierObject with Meta { /// Creates an instance of [IdentifierObject]. /// [type] and [id] can not be null. - IdentifierObject(this.type, this.id, {Map meta}) - : meta = Map.unmodifiable(meta ?? const {}) { + IdentifierObject(this.type, this.id, {Map meta}) { ArgumentError.checkNotNull(type); ArgumentError.checkNotNull(id); + this.meta.addAll(meta ?? {}); } /// Resource type @@ -18,9 +19,6 @@ class IdentifierObject { /// Resource id final String id; - /// Meta data. May be empty or null. - final Map meta; - static IdentifierObject fromIdentifier(Identifier identifier, {Map meta}) => IdentifierObject(identifier.type, identifier.id, meta: meta); diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 918ba273..7dec855c 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,10 +1,12 @@ import 'package:json_api/src/document/document_exception.dart'; +import 'package:json_api/src/document/meta.dart'; /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri) { +class Link with Meta { + Link(this.uri, {Map meta}) { ArgumentError.checkNotNull(uri, 'uri'); + this.meta.addAll(meta ?? {}); } final Uri uri; @@ -13,10 +15,7 @@ class Link { static Link fromJson(Object json) { if (json is String) return Link(Uri.parse(json)); if (json is Map) { - final href = json['href']; - if (href is String) { - return LinkObject(Uri.parse(href), meta: json['meta']); - } + return Link(Uri.parse(json['href']), meta: json['meta']); } throw DocumentException( 'A JSON:API link must be a JSON string or a JSON object'); @@ -26,30 +25,14 @@ class Link { /// Details on the `links` member: https://jsonapi.org/format/#document-links static Map mapFromJson(Object json) { if (json is Map) { - return Map.unmodifiable(({...json}..removeWhere((_, v) => v == null)) - .map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))); + return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); } throw DocumentException('A JSON:API links object must be a JSON object'); } - Object toJson() => uri.toString(); + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; @override String toString() => uri.toString(); } - -/// A JSON:API link object -/// https://jsonapi.org/format/#document-links -class LinkObject extends Link { - LinkObject(Uri href, {Map meta}) - : meta = Map.unmodifiable(meta ?? const {}), - super(href); - - final Map meta; - - @override - Map toJson() => { - 'href': uri.toString(), - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/links.dart b/lib/src/document/links.dart index e69de29b..edf9708e 100644 --- a/lib/src/document/links.dart +++ b/lib/src/document/links.dart @@ -0,0 +1,8 @@ +import 'package:json_api/src/document/link.dart'; + +mixin Links { + /// The `links` object. + /// May be empty. + /// https://jsonapi.org/format/#document-links + final links = {}; +} diff --git a/lib/src/document/meta.dart b/lib/src/document/meta.dart index 81199f53..c052f8d9 100644 --- a/lib/src/document/meta.dart +++ b/lib/src/document/meta.dart @@ -1,3 +1,4 @@ mixin Meta { + /// Meta data. May be empty. final meta = {}; } diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart index 5a93f43b..1e3cea78 100644 --- a/lib/src/document/primary_data.dart +++ b/lib/src/document/primary_data.dart @@ -1,19 +1,15 @@ import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/links.dart'; /// The top-level Primary Data. This is the essentials of the JSON:API Document. /// /// [PrimaryData] may be considered a Document itself with two limitations: /// - 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 { - PrimaryData({Map links}) - : links = Map.unmodifiable(links ?? const {}); - - /// The top-level `links` object. May be empty or null. - final Map links; - - /// The `self` link. May be null. - Link get self => links['self']; +abstract class PrimaryData with Links { + PrimaryData({Map links}) { + this.links.addAll(links ?? {}); + } Map toJson() => { if (links.isNotEmpty) 'links': links, diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 3d7628d2..b3e2d4e5 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -61,9 +61,8 @@ class ToOne extends Relationship { static ToOne fromJson(Object json) { if (json is Map && json.containsKey('data')) { - final links = json['links']; return ToOne(nullable(IdentifierObject.fromJson)(json['data']), - links: (links == null) ? null : Link.mapFromJson(links)); + links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( "A to-one relationship must be a JSON object and contain the 'data' member"); @@ -103,10 +102,9 @@ class ToMany extends Relationship { if (json is Map && json.containsKey('data')) { final data = json['data']; if (data is List) { - final links = json['links']; return ToMany( data.map(IdentifierObject.fromJson), - links: (links == null) ? null : Link.mapFromJson(links), + links: nullable(Link.mapFromJson)(json['links']), ); } } diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart index c47c4a0f..b0f47bdf 100644 --- a/lib/src/document/resource_collection_data.dart +++ b/lib/src/document/resource_collection_data.dart @@ -25,18 +25,6 @@ class ResourceCollectionData extends PrimaryData { final List collection; - /// The link to the last page. May be null. - Link get last => (links ?? {})['last']; - - /// The link to the first page. May be null. - Link get first => (links ?? {})['first']; - - /// The link to the next page. May be null. - Link get next => (links ?? {})['next']; - - /// The link to the prev page. May be null. - Link get prev => (links ?? {})['prev']; - /// Returns a list of resources contained in the collection List unwrap() => collection.map((_) => _.unwrap()).toList(); diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index b87e4706..7ea57665 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -8,7 +8,7 @@ import 'package:json_api/src/nullable.dart'; /// Represents a single resource or a single related resource of a to-one relationship class ResourceData extends PrimaryData { ResourceData(this.resourceObject, {Map links}) - : super(links: {...?resourceObject?.links, ...?links}); + : super(links: links); static ResourceData fromResource(Resource resource) => ResourceData(ResourceObject.fromResource(resource)); @@ -16,7 +16,7 @@ class ResourceData extends PrimaryData { static ResourceData fromJson(Object json) { if (json is Map) { return ResourceData(nullable(ResourceObject.fromJson)(json['data']), - links: Link.mapFromJson(json['links'] ?? {})); + links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( "A JSON:API resource document must be a JSON object and contain the 'data' member"); diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 9096f8ec..ea35d4f9 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -2,6 +2,8 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/links.dart'; +import 'package:json_api/src/document/meta.dart'; import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/nullable.dart'; @@ -13,16 +15,17 @@ import 'package:json_api/src/nullable.dart'; /// resource collection. /// /// More on this: https://jsonapi.org/format/#document-resource-objects -class ResourceObject { +class ResourceObject with Meta, Links { ResourceObject(this.type, this.id, {Map attributes, Map relationships, Map meta, Map links}) - : links = Map.unmodifiable(links ?? const {}), - attributes = Map.unmodifiable(attributes ?? const {}), - meta = Map.unmodifiable(meta ?? const {}), - relationships = Map.unmodifiable(relationships ?? const {}); + : attributes = Map.unmodifiable(attributes ?? const {}), + relationships = Map.unmodifiable(relationships ?? const {}) { + this.meta.addAll(meta ?? {}); + this.links.addAll(links ?? {}); + } static ResourceObject fromResource(Resource resource) => ResourceObject(resource.type, resource.id, @@ -59,10 +62,6 @@ class ResourceObject { final String id; final Map attributes; final Map relationships; - final Map meta; - - /// Read-only `links` object. May be empty. - final Map links; Link get self => links['self']; diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 3e3654a2..cfd45b7c 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -65,7 +65,8 @@ void main() async { expectResourcesEqual(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().included, []); expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().data.self.toString(), '/posts/1?include=tags'); + expect(r.decodeDocument().data.links['self'].toString(), + '/posts/1?include=tags'); }); test('can include first-level relatives', () async { diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 500593a3..e0a45cc1 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -31,7 +31,7 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.self.uri.toString(), + expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/publisher'); expect( r.decodeDocument().data.related.uri.toString(), '/books/1/publisher'); @@ -81,7 +81,7 @@ void main() async { expect(r.http.headers['content-type'], Document.contentType); expect(r.decodeDocument().data.unwrap().length, 2); expect(r.decodeDocument().data.unwrap().first.type, 'people'); - expect(r.decodeDocument().data.self.uri.toString(), + expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/authors'); expect( r.decodeDocument().data.related.uri.toString(), '/books/1/authors'); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 433d924c..7857bf9d 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -35,17 +35,19 @@ void main() async { expect(r.decodeDocument().data.unwrap().id, '1'); expect( r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); - expect(r.decodeDocument().data.self.uri.toString(), '/books/1'); + expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1'); expect( r.decodeDocument().data.resourceObject.links['self'].uri.toString(), '/books/1'); final authors = r.decodeDocument().data.resourceObject.relationships['authors']; - expect(authors.self.toString(), '/books/1/relationships/authors'); + expect( + authors.links['self'].toString(), '/books/1/relationships/authors'); expect(authors.related.toString(), '/books/1/authors'); final publisher = r.decodeDocument().data.resourceObject.relationships['publisher']; - expect(publisher.self.toString(), '/books/1/relationships/publisher'); + expect(publisher.links['self'].toString(), + '/books/1/relationships/publisher'); expect(publisher.related.toString(), '/books/1/publisher'); }); diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart index 317cbf35..a53de6c9 100644 --- a/test/unit/document/api_test.dart +++ b/test/unit/document/api_test.dart @@ -7,8 +7,8 @@ import 'package:test/test.dart'; void main() { test('Api can be json-encoded', () { - final api = Api.fromJson( - json.decode(json.encode(Api(version: '1.0', meta: {'foo': 'bar'})))); + final api = + Api.fromJson(json.decode(json.encode(Api()..meta['foo'] = 'bar'))); expect('1.0', api.version); expect('bar', api.meta['foo']); }); diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart index 2b7b9b47..adec37bb 100644 --- a/test/unit/document/json_api_error_test.dart +++ b/test/unit/document/json_api_error_test.dart @@ -18,7 +18,7 @@ void main() { }); expect(e.links['my-link'].toString(), 'http://example.com'); expect(e.links['about'].toString(), '/about'); - expect(e.about.toString(), '/about'); + expect(e.links['about'].toString(), '/about'); }); test('custom "links" survives json serialization', () { diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart index b17245c9..bbd7bc29 100644 --- a/test/unit/document/link_test.dart +++ b/test/unit/document/link_test.dart @@ -12,16 +12,11 @@ void main() { }); test('link object can be parsed from JSON', () { - final link = - LinkObject(Uri.parse('http://example.com'), meta: {'foo': 'bar'}); + final link = Link(Uri.parse('http://example.com'), meta: {'foo': 'bar'}); final parsed = Link.fromJson(json.decode(json.encode(link))); expect(parsed.uri.toString(), 'http://example.com'); - if (parsed is LinkObject) { - expect(parsed.meta['foo'], 'bar'); - } else { - fail('LinkObject expected'); - } + expect(parsed.meta['foo'], 'bar'); }); test('a map of link object can be parsed from JSON', () { diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 360c3de5..6dcb4f6d 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -19,7 +19,7 @@ void main() { 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.links['self'].toString(), '/self'); expect(r.related.toString(), '/related'); }); diff --git a/test/unit/document/resource_collection_data_test.dart b/test/unit/document/resource_collection_data_test.dart index 76d0dfa5..b90b22e9 100644 --- a/test/unit/document/resource_collection_data_test.dart +++ b/test/unit/document/resource_collection_data_test.dart @@ -26,7 +26,7 @@ void main() { }); expect(r.links['my-link'].toString(), '/my-link'); expect(r.links['self'].toString(), '/self'); - expect(r.self.toString(), '/self'); + expect(r.links['self'].toString(), '/self'); }); test('survives json serialization', () { diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart index 4c4c71ec..b8f82c0c 100644 --- a/test/unit/document/resource_data_test.dart +++ b/test/unit/document/resource_data_test.dart @@ -26,20 +26,6 @@ void main() { expect(data.unwrap(), null); }); - test('Inherits links from ResourceObject', () { - final res = ResourceObject('apples', '1', links: { - 'foo': Link(Uri.parse('/foo')), - 'bar': Link(Uri.parse('/bar')), - 'self': Link(Uri.parse('/self')), - }); - 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', () { @@ -55,7 +41,7 @@ void main() { }); expect(data.links['my-link'].toString(), '/my-link'); expect(data.links['self'].toString(), '/self'); - expect(data.self.toString(), '/self'); + expect(data.links['self'].toString(), '/self'); }); test('survives json serialization', () { diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart index de2b3f8a..cd10599d 100644 --- a/test/unit/document/to_many_test.dart +++ b/test/unit/document/to_many_test.dart @@ -19,7 +19,7 @@ void main() { 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.links['self'].toString(), '/self'); expect(r.related.toString(), '/related'); }); diff --git a/test/unit/document/to_one_test.dart b/test/unit/document/to_one_test.dart index e9ac2bc6..35d881fb 100644 --- a/test/unit/document/to_one_test.dart +++ b/test/unit/document/to_one_test.dart @@ -19,7 +19,7 @@ void main() { 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.links['self'].toString(), '/self'); expect(r.related.toString(), '/related'); }); From 56bf81cdb2e20dd4286342675c78aa29ff8f1534 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 21:23:47 -0700 Subject: [PATCH 59/99] Relationship refactoring step 1 --- lib/src/document/identifier.dart | 5 +++- lib/src/document/identity.dart | 7 +++++ lib/src/document/resource.dart | 34 +++++++++++++++++------ lib/src/server/in_memory_repository.dart | 13 ++------- lib/src/server/repository_controller.dart | 12 ++++---- test/unit/document/resource_test.dart | 3 +- 6 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 lib/src/document/identity.dart diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 9f2567fe..53e17737 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,11 +1,12 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/document/identity.dart'; /// Resource identifier /// /// Together with [Resource] forms the core of the Document model. /// Identifiers are passed between the server and the client in the form /// of [IdentifierObject]s. -class Identifier { +class Identifier with Identity { /// Neither [type] nor [id] can be null or empty. Identifier(this.type, this.id) { ArgumentError.checkNotNull(type); @@ -16,9 +17,11 @@ class Identifier { Identifier(resource.type, resource.id); /// Resource type + @override final String type; /// Resource id + @override final String id; /// Returns true if the two identifiers have the same [type] and [id] diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart new file mode 100644 index 00000000..3f57c93b --- /dev/null +++ b/lib/src/document/identity.dart @@ -0,0 +1,7 @@ +mixin Identity { + String get type; + + String get id; + + String get key => '$type:$id'; +} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 60d6207f..087fcb78 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,11 +1,12 @@ import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/identity.dart'; /// Resource /// /// Together with [Identifier] forms the core of the Document model. /// Resources are passed between the server and the client in the form /// of [ResourceObject]s. -class Resource { +class Resource with Identity { /// Creates an instance of [Resource]. /// The [type] can not be null. /// The [id] may be null for the resources to be created on the server. @@ -13,23 +14,25 @@ class Resource { {Map attributes, Map toOne, Map> toMany}) - : attributes = Map.unmodifiable(attributes ?? const {}), - toOne = Map.unmodifiable(toOne ?? const {}), + : toOne = Map.unmodifiable(toOne ?? const {}), toMany = Map.unmodifiable( (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v).toList()))) { ArgumentError.notNull(type); + this.attributes.addAll(attributes ?? {}); } /// Resource type + @override final String type; /// Resource id /// /// May be null for resources to be created on the server + @override final String id; - /// Unmodifiable map of attributes - final Map attributes; + /// The map of attributes + final attributes = {}; /// Unmodifiable map of to-one relationships final Map toOne; @@ -37,11 +40,26 @@ class Resource { /// Unmodifiable map of to-many relationships final Map> toMany; - /// Resource type and id combined - String get key => '$type:$id'; + /// All related resource identifiers. + Iterable get related => + toOne.values.followedBy(toMany.values.expand((_) => _)); + + /// True for resources without attributes and relationships + bool get isEmpty => attributes.isEmpty && toOne.isEmpty && toMany.isEmpty; + + bool hasOne(String key) => toOne.containsKey(key); + + bool hasMany(String key) => toMany.containsKey(key); + + Resource withId(String newId) { + // TODO: move to NewResource() + if (id != null) throw StateError('Should not change id'); + return Resource(type, newId, + attributes: attributes, toOne: toOne, toMany: toMany); + } @override - String toString() => 'Resource($key $attributes)'; + String toString() => 'Resource($key)'; } /// Resource to be created on the server. Does not have the id yet diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index bdae0759..75642cf0 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -23,8 +23,7 @@ class InMemoryRepository implements Repository { if (collection != resource.type) { throw _invalidType(resource, collection); } - for (final relationship in resource.toOne.values - .followedBy(resource.toMany.values.expand((_) => _))) { + for (final relationship in resource.related) { // Make sure the relationships exist await get(relationship.type, relationship.id); } @@ -33,10 +32,7 @@ class InMemoryRepository implements Repository { throw UnsupportedOperation('Id generation is not supported'); } final id = _nextId(); - final created = Resource(resource.type, id ?? resource.id, - attributes: resource.attributes, - toOne: resource.toOne, - toMany: resource.toMany); + final created = resource.withId(id); _collections[collection][created.id] = created; return created; } @@ -65,10 +61,7 @@ class InMemoryRepository implements Repository { throw _invalidType(resource, type); } final original = await get(type, id); - if (resource.attributes.isEmpty && - resource.toOne.isEmpty && - resource.toMany.isEmpty && - resource.id == id) { + if (resource.isEmpty && resource.id == id) { return null; } final updated = Resource( diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 713e478c..5809e3e7 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -107,12 +107,12 @@ class RepositoryController implements Controller { _do(() async { final resource = await _repo.get(request.target.type, request.target.id); - if (resource.toOne.containsKey(request.target.relationship)) { + if (resource.hasOne(request.target.relationship)) { final i = resource.toOne[request.target.relationship]; return RelatedResourceResponse( request, await _repo.get(i.type, i.id)); } - if (resource.toMany.containsKey(request.target.relationship)) { + if (resource.hasMany(request.target.relationship)) { final related = []; for (final identifier in resource.toMany[request.target.relationship]) { @@ -129,11 +129,11 @@ class RepositoryController implements Controller { _do(() async { final resource = await _repo.get(request.target.type, request.target.id); - if (resource.toOne.containsKey(request.target.relationship)) { + if (resource.hasOne(request.target.relationship)) { return ToOneResponse( request, resource.toOne[request.target.relationship]); } - if (resource.toMany.containsKey(request.target.relationship)) { + if (resource.hasMany(request.target.relationship)) { return ToManyResponse( request, resource.toMany[request.target.relationship]); } @@ -198,9 +198,9 @@ class RepositoryController implements Controller { final resources = []; final ids = []; - if (resource.toOne.containsKey(path.first)) { + if (resource.hasOne(path.first)) { ids.add(resource.toOne[path.first]); - } else if (resource.toMany.containsKey(path.first)) { + } else if (resource.hasMany(path.first)) { ids.addAll(resource.toMany[path.first]); } for (final id in ids) { diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 0a4418eb..bb7bea4f 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -10,7 +10,6 @@ void main() { }); test('toString', () { - expect(Resource('appless', '42', attributes: {'color': 'red'}).toString(), - 'Resource(appless:42 {color: red})'); + expect(Resource('apples', '42').toString(), 'Resource(apples:42)'); }); } From a2c877582bcaad2c5f3af868796dddc4b931bd14 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 21:24:35 -0700 Subject: [PATCH 60/99] Relationship refactoring step 2 --- lib/document.dart | 2 +- lib/src/client/json_api_client.dart | 24 +++++------ lib/src/client/request.dart | 28 ++++++------- ...tionship.dart => relationship_object.dart} | 42 +++++++++---------- lib/src/document/resource_object.dart | 18 ++++---- lib/src/server/response_factory.dart | 8 ++-- lib/src/server/route.dart | 10 ++--- .../crud/fetching_relationships_test.dart | 4 +- test/unit/document/meta_members_test.dart | 2 +- test/unit/document/relationship_test.dart | 8 ++-- test/unit/document/resource_object_test.dart | 2 +- test/unit/document/to_many_test.dart | 8 ++-- test/unit/document/to_one_test.dart | 8 ++-- 13 files changed, 82 insertions(+), 82 deletions(-) rename lib/src/document/{relationship.dart => relationship_object.dart} (71%) diff --git a/lib/document.dart b/lib/document.dart index ce38bee2..eef4613a 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -8,7 +8,7 @@ export 'package:json_api/src/document/identifier.dart'; export 'package:json_api/src/document/identifier_object.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/primary_data.dart'; -export 'package:json_api/src/document/relationship.dart'; +export 'package:json_api/src/document/relationship_object.dart'; export 'package:json_api/src/document/resource.dart'; export 'package:json_api/src/document/resource_collection_data.dart'; export 'package:json_api/src/document/resource_data.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index e2e49eda..6c8ba2d0 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -44,7 +44,7 @@ class JsonApiClient { headers: headers); /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( + Future> fetchToOne( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => send(Request.fetchToOne(parameters: parameters), @@ -52,7 +52,7 @@ class JsonApiClient { headers: headers); /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( + Future> fetchToMany( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => send( @@ -62,7 +62,7 @@ class JsonApiClient { ); /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( + Future> fetchRelationship( String type, String id, String relationship, {Map headers, QueryParameters parameters}) => send(Request.fetchRelationship(parameters: parameters), @@ -89,7 +89,7 @@ class JsonApiClient { headers: headers); /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( + Future> replaceToOne( String type, String id, String relationship, Identifier identifier, {Map headers}) => send(Request.replaceToOne(_toOneDoc(identifier)), @@ -97,7 +97,7 @@ class JsonApiClient { headers: headers); /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( + Future> deleteToOne( String type, String id, String relationship, {Map headers}) => send(Request.replaceToOne(_toOneDoc(null)), @@ -105,7 +105,7 @@ class JsonApiClient { headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, + Future> deleteFromToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => send(Request.deleteFromToMany(_toManyDoc(identifiers)), @@ -113,7 +113,7 @@ class JsonApiClient { headers: headers); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, + Future> replaceToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => send(Request.replaceToMany(_toManyDoc(identifiers)), @@ -121,7 +121,7 @@ class JsonApiClient { headers: headers); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToMany(String type, String id, + Future> addToMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) => send(Request.addToMany(_toManyDoc(identifiers)), @@ -141,9 +141,9 @@ class JsonApiClient { Document _resourceDoc(Resource resource) => Document(ResourceData.fromResource(resource)); - Document _toManyDoc(Iterable identifiers) => - Document(ToMany.fromIdentifiers(identifiers)); + Document _toManyDoc(Iterable identifiers) => + Document(ToManyObject.fromIdentifiers(identifiers)); - Document _toOneDoc(Identifier identifier) => - Document(ToOne.fromIdentifier(identifier)); + Document _toOneDoc(Identifier identifier) => + Document(ToOneObject.fromIdentifier(identifier)); } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 68ec1416..7f71548c 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -28,15 +28,15 @@ class Request { static Request fetchResource({QueryParameters parameters}) => Request(HttpMethod.GET, ResourceData.fromJson, parameters: parameters); - static Request fetchToOne({QueryParameters parameters}) => - Request(HttpMethod.GET, ToOne.fromJson, parameters: parameters); + static Request fetchToOne({QueryParameters parameters}) => + Request(HttpMethod.GET, ToOneObject.fromJson, parameters: parameters); - static Request fetchToMany({QueryParameters parameters}) => - Request(HttpMethod.GET, ToMany.fromJson, parameters: parameters); + static Request fetchToMany({QueryParameters parameters}) => + Request(HttpMethod.GET, ToManyObject.fromJson, parameters: parameters); - static Request fetchRelationship( + static Request fetchRelationship( {QueryParameters parameters}) => - Request(HttpMethod.GET, Relationship.fromJson, parameters: parameters); + Request(HttpMethod.GET, RelationshipObject.fromJson, parameters: parameters); static Request createResource( Document document) => @@ -49,17 +49,17 @@ class Request { static Request deleteResource() => Request(HttpMethod.DELETE, ResourceData.fromJson); - static Request replaceToOne(Document document) => - Request.withPayload(document, HttpMethod.PATCH, ToOne.fromJson); + static Request replaceToOne(Document document) => + Request.withPayload(document, HttpMethod.PATCH, ToOneObject.fromJson); - static Request deleteFromToMany(Document document) => - Request.withPayload(document, HttpMethod.DELETE, ToMany.fromJson); + static Request deleteFromToMany(Document document) => + Request.withPayload(document, HttpMethod.DELETE, ToManyObject.fromJson); - static Request replaceToMany(Document document) => - Request.withPayload(document, HttpMethod.PATCH, ToMany.fromJson); + static Request replaceToMany(Document document) => + Request.withPayload(document, HttpMethod.PATCH, ToManyObject.fromJson); - static Request addToMany(Document document) => - Request.withPayload(document, HttpMethod.POST, ToMany.fromJson); + static Request addToMany(Document document) => + Request.withPayload(document, HttpMethod.POST, ToManyObject.fromJson); final PrimaryDataDecoder decoder; final String method; diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship_object.dart similarity index 71% rename from lib/src/document/relationship.dart rename to lib/src/document/relationship_object.dart index b3e2d4e5..4edb4394 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship_object.dart @@ -14,32 +14,32 @@ import 'package:json_api/src/nullable.dart'; /// It can also be a part of [ResourceObject].relationships map. /// /// More on this: https://jsonapi.org/format/#document-resource-object-relationships -class Relationship extends PrimaryData { - Relationship({Map links}) : super(links: links); +class RelationshipObject extends PrimaryData { + RelationshipObject({Map links}) : super(links: links); /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(Object json) { + static RelationshipObject fromJson(Object json) { if (json is Map) { if (json.containsKey('data')) { final data = json['data']; if (data == null || data is Map) { - return ToOne.fromJson(json); + return ToOneObject.fromJson(json); } if (data is List) { - return ToMany.fromJson(json); + return ToManyObject.fromJson(json); } } - return Relationship(links: nullable(Link.mapFromJson)(json['links'])); + return RelationshipObject(links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( 'A JSON:API relationship object must be a JSON object'); } /// Parses the `relationships` member of a Resource Object - static Map mapFromJson(Object json) { + static Map mapFromJson(Object json) { if (json is Map) { return json - .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); + .map((k, v) => MapEntry(k.toString(), RelationshipObject.fromJson(v))); } throw DocumentException("The 'relationships' member must be a JSON object"); } @@ -49,19 +49,19 @@ class Relationship extends PrimaryData { } /// Relationship to-one -class ToOne extends Relationship { - ToOne(this.linkage, {Map links}) : super(links: links); +class ToOneObject extends RelationshipObject { + ToOneObject(this.linkage, {Map links}) : super(links: links); - ToOne.empty({Link self, Map links}) + ToOneObject.empty({Link self, Map links}) : linkage = null, super(links: links); - static ToOne fromIdentifier(Identifier identifier) => - ToOne(nullable(IdentifierObject.fromIdentifier)(identifier)); + static ToOneObject fromIdentifier(Identifier identifier) => + ToOneObject(nullable(IdentifierObject.fromIdentifier)(identifier)); - static ToOne fromJson(Object json) { + static ToOneObject fromJson(Object json) { if (json is Map && json.containsKey('data')) { - return ToOne(nullable(IdentifierObject.fromJson)(json['data']), + return ToOneObject(nullable(IdentifierObject.fromJson)(json['data']), links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( @@ -90,19 +90,19 @@ class ToOne extends Relationship { } /// Relationship to-many -class ToMany extends Relationship { - ToMany(Iterable linkage, {Map links}) +class ToManyObject extends RelationshipObject { + ToManyObject(Iterable linkage, {Map links}) : linkage = List.unmodifiable(linkage), super(links: links); - static ToMany fromIdentifiers(Iterable identifiers) => - ToMany(identifiers.map(IdentifierObject.fromIdentifier)); + static ToManyObject fromIdentifiers(Iterable identifiers) => + ToManyObject(identifiers.map(IdentifierObject.fromIdentifier)); - static ToMany fromJson(Object json) { + static ToManyObject fromJson(Object json) { if (json is Map && json.containsKey('data')) { final data = json['data']; if (data is List) { - return ToMany( + return ToManyObject( data.map(IdentifierObject.fromJson), links: nullable(Link.mapFromJson)(json['links']), ); diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index ea35d4f9..183bc939 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -4,7 +4,7 @@ import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/links.dart'; import 'package:json_api/src/document/meta.dart'; -import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/relationship_object.dart'; import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/nullable.dart'; @@ -18,7 +18,7 @@ import 'package:json_api/src/nullable.dart'; class ResourceObject with Meta, Links { ResourceObject(this.type, this.id, {Map attributes, - Map relationships, + Map relationships, Map meta, Map links}) : attributes = Map.unmodifiable(attributes ?? const {}), @@ -32,9 +32,9 @@ class ResourceObject with Meta, Links { attributes: resource.attributes, relationships: { ...resource.toOne.map((k, v) => MapEntry( - k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), + k, ToOneObject(nullable(IdentifierObject.fromIdentifier)(v)))), ...resource.toMany.map((k, v) => - MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))) + MapEntry(k, ToManyObject(v.map(IdentifierObject.fromIdentifier)))) }); /// Reconstructs the `data` member of a JSON:API Document. @@ -49,7 +49,7 @@ class ResourceObject with Meta, Links { type.isNotEmpty) { return ResourceObject(json['type'], json['id'], attributes: attributes, - relationships: nullable(Relationship.mapFromJson)(relationships), + relationships: nullable(RelationshipObject.mapFromJson)(relationships), links: Link.mapFromJson(json['links'] ?? {}), meta: json['meta']); } @@ -61,7 +61,7 @@ class ResourceObject with Meta, Links { final String type; final String id; final Map attributes; - final Map relationships; + final Map relationships; Link get self => links['self']; @@ -84,11 +84,11 @@ class ResourceObject with Meta, Links { Resource unwrap() { final toOne = {}; final toMany = >{}; - final incomplete = {}; + final incomplete = {}; relationships.forEach((name, rel) { - if (rel is ToOne) { + if (rel is ToOneObject) { toOne[name] = rel.unwrap(); - } else if (rel is ToMany) { + } else if (rel is ToManyObject) { toMany[name] = rel.unwrap(); } else { incomplete[name] = rel; diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index 0e7d8cf8..e7cc5b15 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -139,7 +139,7 @@ class HttpResponseFactory implements ResponseFactory { Iterable identifiers) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ToMany( + body: jsonEncode(Document(ToManyObject( identifiers.map(IdentifierObject.fromIdentifier), links: { 'self': Link(_self(request)), @@ -153,7 +153,7 @@ class HttpResponseFactory implements ResponseFactory { Request request, Identifier identifier) => HttpResponse(200, headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ToOne( + body: jsonEncode(Document(ToOneObject( IdentifierObject.fromIdentifier(identifier), links: { 'self': Link(_self(request)), @@ -168,14 +168,14 @@ class HttpResponseFactory implements ResponseFactory { relationships: { ...resource.toOne.map((k, v) => MapEntry( k, - ToOne(nullable(IdentifierObject.fromIdentifier)(v), links: { + ToOneObject(nullable(IdentifierObject.fromIdentifier)(v), links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), }))), ...resource.toMany.map((k, v) => MapEntry( k, - ToMany(v.map(IdentifierObject.fromIdentifier), links: { + ToManyObject(v.map(IdentifierObject.fromIdentifier), links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart index f377ef3f..f6353181 100644 --- a/lib/src/server/route.dart +++ b/lib/src/server/route.dart @@ -174,24 +174,24 @@ class RelationshipRoute implements Route { final r = Request(request.uri, _target); if (request.isDelete) { return controller.deleteFromRelationship( - r, ToMany.fromJson(jsonDecode(request.body)).unwrap()); + r, ToManyObject.fromJson(jsonDecode(request.body)).unwrap()); } if (request.isGet) { return controller.fetchRelationship(r); } if (request.isPatch) { - final rel = Relationship.fromJson(jsonDecode(request.body)); - if (rel is ToOne) { + final rel = RelationshipObject.fromJson(jsonDecode(request.body)); + if (rel is ToOneObject) { return controller.replaceToOne(r, rel.unwrap()); } - if (rel is ToMany) { + if (rel is ToManyObject) { return controller.replaceToMany(r, rel.unwrap()); } throw IncompleteRelationshipException(); } if (request.isPost) { return controller.addToRelationship( - r, ToMany.fromJson(jsonDecode(request.body)).unwrap()); + r, ToManyObject.fromJson(jsonDecode(request.body)).unwrap()); } throw ArgumentError(); } diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index e0a45cc1..4b5139d5 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -128,7 +128,7 @@ void main() async { expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); final rel = r.decodeDocument().data; - if (rel is ToOne) { + if (rel is ToOneObject) { expect(rel.unwrap().type, 'companies'); expect(rel.unwrap().id, '1'); } else { @@ -142,7 +142,7 @@ void main() async { expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); final rel = r.decodeDocument().data; - if (rel is ToMany) { + if (rel is ToManyObject) { expect(rel.unwrap().length, 2); expect(rel.unwrap().first.id, '1'); expect(rel.unwrap().first.type, 'people'); diff --git a/test/unit/document/meta_members_test.dart b/test/unit/document/meta_members_test.dart index 4cf1c2d2..6a595ef8 100644 --- a/test/unit/document/meta_members_test.dart +++ b/test/unit/document/meta_members_test.dart @@ -89,7 +89,7 @@ void main() { expect(doc.meta['bool'], true); expect(doc.data.collection.first.meta, meta); expect( - (doc.data.collection.first.relationships['comments'] as ToMany) + (doc.data.collection.first.relationships['comments'] as ToManyObject) .linkage .first .meta, diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 6dcb4f6d..68de23ec 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -6,12 +6,12 @@ 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'))}); + final r = RelationshipObject(links: {'my-link': Link(Uri.parse('/my-link'))}); expect(r.links['my-link'].toString(), '/my-link'); }); test('"links" may contain the "related" and "self" keys', () { - final r = Relationship(links: { + final r = RelationshipObject(links: { 'my-link': Link(Uri.parse('/my-link')), 'related': Link(Uri.parse('/related')), 'self': Link(Uri.parse('/self')) @@ -24,11 +24,11 @@ void main() { }); test('custom "links" survives json serialization', () { - final r = Relationship(links: { + final r = RelationshipObject(links: { 'my-link': Link(Uri.parse('/my-link')), }); expect( - Relationship.fromJson(json.decode(json.encode(r))) + RelationshipObject.fromJson(json.decode(json.encode(r))) .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 d1f20738..314819c0 100644 --- a/test/unit/document/resource_object_test.dart +++ b/test/unit/document/resource_object_test.dart @@ -13,7 +13,7 @@ void main() { 'title': 'Ember Hamster', 'src': 'http://example.com/images/productivity.png' }, relationships: { - 'photographer': ToOne(IdentifierObject('people', '9')) + 'photographer': ToOneObject(IdentifierObject('people', '9')) }); expect( diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart index cd10599d..9ea59517 100644 --- a/test/unit/document/to_many_test.dart +++ b/test/unit/document/to_many_test.dart @@ -6,12 +6,12 @@ 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'))}); + final r = ToManyObject([], links: {'my-link': Link(Uri.parse('/my-link'))}); expect(r.links['my-link'].toString(), '/my-link'); }); test('"links" may contain the "related" and "self" keys', () { - final r = ToMany([], links: { + final r = ToManyObject([], links: { 'my-link': Link(Uri.parse('/my-link')), 'related': Link(Uri.parse('/related')), 'self': Link(Uri.parse('/self')) @@ -24,11 +24,11 @@ void main() { }); test('custom "links" survives json serialization', () { - final r = ToMany([], links: { + final r = ToManyObject([], links: { 'my-link': Link(Uri.parse('/my-link')), }); expect( - ToMany.fromJson(json.decode(json.encode(r))) + ToManyObject.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 index 35d881fb..0260f770 100644 --- a/test/unit/document/to_one_test.dart +++ b/test/unit/document/to_one_test.dart @@ -6,12 +6,12 @@ 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'))}); + final r = ToOneObject(null, links: {'my-link': Link(Uri.parse('/my-link'))}); expect(r.links['my-link'].toString(), '/my-link'); }); test('"links" may contain the "related" and "self" keys', () { - final r = ToOne(null, links: { + final r = ToOneObject(null, links: { 'my-link': Link(Uri.parse('/my-link')), 'related': Link(Uri.parse('/related')), 'self': Link(Uri.parse('/self')) @@ -24,11 +24,11 @@ void main() { }); test('custom "links" survives json serialization', () { - final r = ToOne(null, links: { + final r = ToOneObject(null, links: { 'my-link': Link(Uri.parse('/my-link')), }); expect( - ToOne.fromJson(json.decode(json.encode(r))) + ToOneObject.fromJson(json.decode(json.encode(r))) .links['my-link'] .toString(), '/my-link'); From 78399f2ea3d883e0dd9e5df12a30576536e37e3b Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 26 Apr 2020 22:09:44 -0700 Subject: [PATCH 61/99] Remove IdentifierObject --- lib/document.dart | 1 - lib/src/document/identifier.dart | 22 +++++++--- lib/src/document/identifier_object.dart | 40 ------------------- lib/src/document/relationship_object.dart | 33 +++++---------- lib/src/document/resource_object.dart | 13 +++--- lib/src/server/response_factory.dart | 9 ++--- lib/src/server/route.dart | 8 ++-- test/functional/compound_document_test.dart | 12 +++--- .../crud/fetching_relationships_test.dart | 22 +++++----- .../crud/updating_relationships_test.dart | 22 +++++----- .../unit/document/identifier_object_test.dart | 2 +- test/unit/document/resource_object_test.dart | 2 +- 12 files changed, 72 insertions(+), 114 deletions(-) delete mode 100644 lib/src/document/identifier_object.dart diff --git a/lib/document.dart b/lib/document.dart index eef4613a..1c1d4e6e 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -5,7 +5,6 @@ export 'package:json_api/src/document/document.dart'; export 'package:json_api/src/document/document_exception.dart'; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identifier_object.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/primary_data.dart'; export 'package:json_api/src/document/relationship_object.dart'; diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 53e17737..d0c1c00e 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,20 +1,26 @@ -import 'package:json_api/document.dart'; +import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/meta.dart'; /// Resource identifier /// /// Together with [Resource] forms the core of the Document model. /// Identifiers are passed between the server and the client in the form /// of [IdentifierObject]s. -class Identifier with Identity { +class Identifier with Meta, Identity { /// Neither [type] nor [id] can be null or empty. - Identifier(this.type, this.id) { + Identifier(this.type, this.id, {Map meta}) { ArgumentError.checkNotNull(type); ArgumentError.checkNotNull(id); + this.meta.addAll(meta ?? {}); } - static Identifier of(Resource resource) => - Identifier(resource.type, resource.id); + static Identifier fromJson(Object json) { + if (json is Map) { + return Identifier(json['type'], json['id'], meta: json['meta']); + } + throw DocumentException('A JSON:API identifier must be a JSON object'); + } /// Resource type @override @@ -36,4 +42,10 @@ class Identifier with Identity { @override int get hashCode => 0; + + Map toJson() => { + 'type': type, + 'id': id, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart deleted file mode 100644 index dd782954..00000000 --- a/lib/src/document/identifier_object.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/meta.dart'; - -/// [IdentifierObject] is a JSON representation of the [Identifier]. -/// It carries all JSON-related logic and the Meta-data. -class IdentifierObject with Meta { - /// Creates an instance of [IdentifierObject]. - /// [type] and [id] can not be null. - IdentifierObject(this.type, this.id, {Map meta}) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - this.meta.addAll(meta ?? {}); - } - - /// Resource type - final String type; - - /// Resource id - final String id; - - static IdentifierObject fromIdentifier(Identifier identifier, - {Map meta}) => - 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']); - } - throw DocumentException('A JSON:API identifier must be a JSON object'); - } - - Identifier unwrap() => Identifier(type, id); - - Map toJson() => { - 'type': type, - 'id': id, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/relationship_object.dart b/lib/src/document/relationship_object.dart index 4edb4394..4966a70e 100644 --- a/lib/src/document/relationship_object.dart +++ b/lib/src/document/relationship_object.dart @@ -1,6 +1,5 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/identifier_object.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/primary_data.dart'; import 'package:json_api/src/document/resource_object.dart'; @@ -29,7 +28,8 @@ class RelationshipObject extends PrimaryData { return ToManyObject.fromJson(json); } } - return RelationshipObject(links: nullable(Link.mapFromJson)(json['links'])); + return RelationshipObject( + links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( 'A JSON:API relationship object must be a JSON object'); @@ -38,8 +38,8 @@ class RelationshipObject extends PrimaryData { /// Parses the `relationships` member of a Resource Object static Map mapFromJson(Object json) { if (json is Map) { - return json - .map((k, v) => MapEntry(k.toString(), RelationshipObject.fromJson(v))); + return json.map( + (k, v) => MapEntry(k.toString(), RelationshipObject.fromJson(v))); } throw DocumentException("The 'relationships' member must be a JSON object"); } @@ -57,11 +57,11 @@ class ToOneObject extends RelationshipObject { super(links: links); static ToOneObject fromIdentifier(Identifier identifier) => - ToOneObject(nullable(IdentifierObject.fromIdentifier)(identifier)); + ToOneObject(identifier); static ToOneObject fromJson(Object json) { if (json is Map && json.containsKey('data')) { - return ToOneObject(nullable(IdentifierObject.fromJson)(json['data']), + return ToOneObject(nullable(Identifier.fromJson)(json['data']), links: nullable(Link.mapFromJson)(json['links'])); } throw DocumentException( @@ -73,37 +73,30 @@ class ToOneObject extends RelationshipObject { /// Can be null for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final IdentifierObject linkage; + final Identifier linkage; @override Map toJson() => { ...super.toJson(), 'data': linkage, }; - - /// Converts to [Identifier]. - /// For empty relationships returns null. - Identifier unwrap() => linkage?.unwrap(); - - /// Same as [unwrap] - Identifier get identifier => unwrap(); } /// Relationship to-many class ToManyObject extends RelationshipObject { - ToManyObject(Iterable linkage, {Map links}) + ToManyObject(Iterable linkage, {Map links}) : linkage = List.unmodifiable(linkage), super(links: links); static ToManyObject fromIdentifiers(Iterable identifiers) => - ToManyObject(identifiers.map(IdentifierObject.fromIdentifier)); + ToManyObject(identifiers); static ToManyObject fromJson(Object json) { if (json is Map && json.containsKey('data')) { final data = json['data']; if (data is List) { return ToManyObject( - data.map(IdentifierObject.fromJson), + data.map(Identifier.fromJson), links: nullable(Link.mapFromJson)(json['links']), ); } @@ -117,15 +110,11 @@ class ToManyObject extends RelationshipObject { /// Can be empty for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final List linkage; + final List linkage; @override Map toJson() => { ...super.toJson(), 'data': linkage, }; - - /// Converts to List. - /// For empty relationships returns an empty List. - List unwrap() => linkage.map((_) => _.unwrap()).toList(); } diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 183bc939..997e3a38 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -31,10 +31,8 @@ class ResourceObject with Meta, Links { ResourceObject(resource.type, resource.id, attributes: resource.attributes, relationships: { - ...resource.toOne.map((k, v) => MapEntry( - k, ToOneObject(nullable(IdentifierObject.fromIdentifier)(v)))), - ...resource.toMany.map((k, v) => - MapEntry(k, ToManyObject(v.map(IdentifierObject.fromIdentifier)))) + ...resource.toOne.map((k, v) => MapEntry(k, ToOneObject(v))), + ...resource.toMany.map((k, v) => MapEntry(k, ToManyObject(v))) }); /// Reconstructs the `data` member of a JSON:API Document. @@ -49,7 +47,8 @@ class ResourceObject with Meta, Links { type.isNotEmpty) { return ResourceObject(json['type'], json['id'], attributes: attributes, - relationships: nullable(RelationshipObject.mapFromJson)(relationships), + relationships: + nullable(RelationshipObject.mapFromJson)(relationships), links: Link.mapFromJson(json['links'] ?? {}), meta: json['meta']); } @@ -87,9 +86,9 @@ class ResourceObject with Meta, Links { final incomplete = {}; relationships.forEach((name, rel) { if (rel is ToOneObject) { - toOne[name] = rel.unwrap(); + toOne[name] = rel.linkage; } else if (rel is ToManyObject) { - toMany[name] = rel.unwrap(); + toMany[name] = rel.linkage; } else { incomplete[name] = rel; } diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index e7cc5b15..be121002 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; @@ -140,7 +139,7 @@ class HttpResponseFactory implements ResponseFactory { HttpResponse(200, headers: {'Content-Type': Document.contentType}, body: jsonEncode(Document(ToManyObject( - identifiers.map(IdentifierObject.fromIdentifier), + identifiers, links: { 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, @@ -154,7 +153,7 @@ class HttpResponseFactory implements ResponseFactory { HttpResponse(200, headers: {'Content-Type': Document.contentType}, body: jsonEncode(Document(ToOneObject( - IdentifierObject.fromIdentifier(identifier), + identifier, links: { 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, @@ -168,14 +167,14 @@ class HttpResponseFactory implements ResponseFactory { relationships: { ...resource.toOne.map((k, v) => MapEntry( k, - ToOneObject(nullable(IdentifierObject.fromIdentifier)(v), links: { + ToOneObject(v, links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), }))), ...resource.toMany.map((k, v) => MapEntry( k, - ToManyObject(v.map(IdentifierObject.fromIdentifier), links: { + ToManyObject(v, links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart index f6353181..4cf058fe 100644 --- a/lib/src/server/route.dart +++ b/lib/src/server/route.dart @@ -174,7 +174,7 @@ class RelationshipRoute implements Route { final r = Request(request.uri, _target); if (request.isDelete) { return controller.deleteFromRelationship( - r, ToManyObject.fromJson(jsonDecode(request.body)).unwrap()); + r, ToManyObject.fromJson(jsonDecode(request.body)).linkage); } if (request.isGet) { return controller.fetchRelationship(r); @@ -182,16 +182,16 @@ class RelationshipRoute implements Route { if (request.isPatch) { final rel = RelationshipObject.fromJson(jsonDecode(request.body)); if (rel is ToOneObject) { - return controller.replaceToOne(r, rel.unwrap()); + return controller.replaceToOne(r, rel.linkage); } if (rel is ToManyObject) { - return controller.replaceToMany(r, rel.unwrap()); + return controller.replaceToMany(r, rel.linkage); } throw IncompleteRelationshipException(); } if (request.isPost) { return controller.addToRelationship( - r, ToManyObject.fromJson(jsonDecode(request.body)).unwrap()); + r, ToManyObject.fromJson(jsonDecode(request.body)).linkage); } throw ArgumentError(); } diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index cfd45b7c..1dd5d498 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -20,22 +20,22 @@ void main() async { Resource('countries', '1', attributes: {'name': 'Wonderland'}); final alice = Resource('people', '1', attributes: {'name': 'Alice'}, - toOne: {'birthplace': Identifier.of(wonderland)}); + toOne: {'birthplace': Identifier(wonderland.type, wonderland.id)}); final bob = Resource('people', '2', attributes: {'name': 'Bob'}, - toOne: {'birthplace': Identifier.of(wonderland)}); + toOne: {'birthplace': Identifier(wonderland.type, wonderland.id)}); final comment1 = Resource('comments', '1', attributes: {'text': 'First comment!'}, - toOne: {'author': Identifier.of(bob)}); + toOne: {'author': Identifier(bob.type, bob.id)}); final comment2 = Resource('comments', '2', attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': Identifier.of(alice)}); + toOne: {'author': Identifier(alice.type, alice.id)}); final post = Resource('posts', '1', attributes: { 'title': 'Hello World' }, toOne: { - 'author': Identifier.of(alice) + 'author': Identifier(alice.type, alice.id) }, toMany: { - 'comments': [Identifier.of(comment1), Identifier.of(comment2)], + 'comments': [Identifier(comment1.type, comment1.id), Identifier(comment2.type, comment2.id)], 'tags': [] }); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 4b5139d5..024813d4 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -35,8 +35,8 @@ void main() async { '/books/1/relationships/publisher'); expect( r.decodeDocument().data.related.uri.toString(), '/books/1/publisher'); - expect(r.decodeDocument().data.unwrap().type, 'companies'); - expect(r.decodeDocument().data.unwrap().id, '1'); + expect(r.decodeDocument().data.linkage.type, 'companies'); + expect(r.decodeDocument().data.linkage.id, '1'); }); test('404 on collection', () async { @@ -79,8 +79,8 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.unwrap().length, 2); - expect(r.decodeDocument().data.unwrap().first.type, 'people'); + expect(r.decodeDocument().data.linkage.length, 2); + expect(r.decodeDocument().data.linkage.first.type, 'people'); expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/authors'); expect( @@ -129,8 +129,8 @@ void main() async { expect(r.http.headers['content-type'], Document.contentType); final rel = r.decodeDocument().data; if (rel is ToOneObject) { - expect(rel.unwrap().type, 'companies'); - expect(rel.unwrap().id, '1'); + expect(rel.linkage.type, 'companies'); + expect(rel.linkage.id, '1'); } else { fail('Not a ToOne relationship'); } @@ -143,11 +143,11 @@ void main() async { expect(r.http.headers['content-type'], Document.contentType); final rel = r.decodeDocument().data; if (rel is ToManyObject) { - expect(rel.unwrap().length, 2); - expect(rel.unwrap().first.id, '1'); - expect(rel.unwrap().first.type, 'people'); - expect(rel.unwrap().last.id, '2'); - expect(rel.unwrap().last.type, 'people'); + expect(rel.linkage.length, 2); + expect(rel.linkage.first.id, '1'); + expect(rel.linkage.first.type, 'people'); + expect(rel.linkage.last.id, '2'); + expect(rel.linkage.last.type, 'people'); } else { fail('Not a ToMany relationship'); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 0bf09ad1..fb1c2157 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -145,9 +145,9 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.unwrap().length, 3); - expect(r.decodeDocument().data.unwrap().first.id, '1'); - expect(r.decodeDocument().data.unwrap().last.id, '3'); + expect(r.decodeDocument().data.linkage.length, 3); + expect(r.decodeDocument().data.linkage.first.id, '1'); + expect(r.decodeDocument().data.linkage.last.id, '3'); final r1 = await client.fetchResource('books', '1'); expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 3); @@ -159,9 +159,9 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.unwrap().length, 2); - expect(r.decodeDocument().data.unwrap().first.id, '1'); - expect(r.decodeDocument().data.unwrap().last.id, '2'); + expect(r.decodeDocument().data.linkage.length, 2); + expect(r.decodeDocument().data.linkage.first.id, '1'); + expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); @@ -216,8 +216,8 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.unwrap().length, 1); - expect(r.decodeDocument().data.unwrap().first.id, '2'); + expect(r.decodeDocument().data.linkage.length, 1); + expect(r.decodeDocument().data.linkage.first.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 1); @@ -229,9 +229,9 @@ void main() async { expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data.unwrap().length, 2); - expect(r.decodeDocument().data.unwrap().first.id, '1'); - expect(r.decodeDocument().data.unwrap().last.id, '2'); + expect(r.decodeDocument().data.linkage.length, 2); + expect(r.decodeDocument().data.linkage.first.id, '1'); + expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); diff --git a/test/unit/document/identifier_object_test.dart b/test/unit/document/identifier_object_test.dart index f7aff941..bf49737a 100644 --- a/test/unit/document/identifier_object_test.dart +++ b/test/unit/document/identifier_object_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { test('throws DocumentException when can not be decoded', () { - expect(() => IdentifierObject.fromJson([]), + expect(() => Identifier.fromJson([]), throwsA(TypeMatcher())); }); } diff --git a/test/unit/document/resource_object_test.dart b/test/unit/document/resource_object_test.dart index 314819c0..0608af68 100644 --- a/test/unit/document/resource_object_test.dart +++ b/test/unit/document/resource_object_test.dart @@ -13,7 +13,7 @@ void main() { 'title': 'Ember Hamster', 'src': 'http://example.com/images/productivity.png' }, relationships: { - 'photographer': ToOneObject(IdentifierObject('people', '9')) + 'photographer': ToOneObject(Identifier('people', '9')) }); expect( From ba2b43874818fc9cc12a0220a04d0a3c687d818f Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 27 Apr 2020 00:06:54 -0700 Subject: [PATCH 62/99] Relationship refactoring step 3 --- lib/src/document/relationship.dart | 59 +++++++++++++++++ lib/src/document/resource.dart | 66 ++++++++++++++----- lib/src/document/resource_object.dart | 5 +- lib/src/server/in_memory_repository.dart | 11 +--- lib/src/server/repository_controller.dart | 52 +++++---------- lib/src/server/response_factory.dart | 4 +- test/functional/compound_document_test.dart | 2 +- .../crud/updating_relationships_test.dart | 18 +++-- .../crud/updating_resources_test.dart | 7 +- test/helper/expect_resources_equal.dart | 8 ++- 10 files changed, 153 insertions(+), 79 deletions(-) create mode 100644 lib/src/document/relationship.dart diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart new file mode 100644 index 00000000..5d9ef19b --- /dev/null +++ b/lib/src/document/relationship.dart @@ -0,0 +1,59 @@ +import 'package:json_api/document.dart'; + +class Relationship {} + +class ToOne implements Relationship { + ToOne(Identifier identifier) { + set(identifier); + } + + ToOne.empty(); + + final _values = {}; + + bool get isEmpty => _values.isEmpty; + + T mapIfExists(T Function(Identifier _) map, T Function() orElse) => + _values.isEmpty ? orElse() : map(_values.first); + + List toList() => mapIfExists((_) => [_], () => []); + + void set(Identifier identifier) { + ArgumentError.checkNotNull(identifier, 'identifier'); + _values + ..clear() + ..add(identifier); + } + + void clear() { + _values.clear(); + } + + static ToOne fromNullable(Identifier identifier) => + identifier == null ? ToOne.empty() : ToOne(identifier); +} + +class ToMany implements Relationship { + ToMany(Iterable identifiers) { + set(identifiers); + } + + final _map = {}; + + int get length => _map.length; + + List toList() => [..._map.values]; + + void set(Iterable identifiers) { + _map..clear(); + identifiers.forEach((i) => _map[i.key] = i); + } + + void remove(Iterable identifiers) { + identifiers.forEach((i) => _map.remove(i.key)); + } + + void addAll(Iterable identifiers) { + identifiers.forEach((i) => _map[i.key] = i); + } +} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 087fcb78..46f573f8 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,24 +1,31 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/links.dart'; +import 'package:json_api/src/document/meta.dart'; +import 'package:json_api/src/document/relationship.dart'; /// Resource /// /// Together with [Identifier] forms the core of the Document model. /// Resources are passed between the server and the client in the form /// of [ResourceObject]s. -class Resource with Identity { +class Resource with Meta, Links, Identity { /// Creates an instance of [Resource]. /// The [type] can not be null. /// The [id] may be null for the resources to be created on the server. Resource(this.type, this.id, {Map attributes, + Map meta, + Map links, Map toOne, - Map> toMany}) - : toOne = Map.unmodifiable(toOne ?? const {}), - toMany = Map.unmodifiable( - (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v).toList()))) { + Map> toMany}) { ArgumentError.notNull(type); this.attributes.addAll(attributes ?? {}); + this.meta.addAll(meta ?? {}); + this.links.addAll(links ?? {}); + toOne?.forEach((k, v) => this.toOne[k] = ToOne.fromNullable(v)); + toMany?.forEach((k, v) => this.toMany[k] = ToMany(v)); } /// Resource type @@ -34,15 +41,28 @@ class Resource with Identity { /// The map of attributes final attributes = {}; - /// Unmodifiable map of to-one relationships - final Map toOne; + /// The map of to-one relationships + final toOne = {}; - /// Unmodifiable map of to-many relationships - final Map> toMany; + /// The map of to-many relationships + final toMany = {}; /// All related resource identifiers. - Iterable get related => - toOne.values.followedBy(toMany.values.expand((_) => _)); + List get related => toOne.values + .map((_) => _.toList()) + .followedBy(toMany.values.map((_) => _.toList())) + .expand((_) => _) + .toList(); + + List relatedByKey(String key) { + if (hasOne(key)) { + return toOne[key].toList(); + } + if (hasMany(key)) { + return toMany[key].toList(); + } + return []; + } /// True for resources without attributes and relationships bool get isEmpty => attributes.isEmpty && toOne.isEmpty && toMany.isEmpty; @@ -51,22 +71,32 @@ class Resource with Identity { bool hasMany(String key) => toMany.containsKey(key); + void addAll(Resource other) { + attributes.addAll(other.attributes); + toOne.addAll(other.toOne); + toMany.addAll(other.toMany); + } + Resource withId(String newId) { // TODO: move to NewResource() if (id != null) throw StateError('Should not change id'); - return Resource(type, newId, - attributes: attributes, toOne: toOne, toMany: toMany); + return Resource(type, newId, attributes: attributes) + ..toOne.addAll(toOne) + ..toMany.addAll(toMany); } + Map get relationships => { + ...toOne.map((k, v) => MapEntry( + k, v.mapIfExists((_) => ToOneObject(_), () => ToOneObject(null)))), + ...toMany.map((k, v) => MapEntry(k, ToManyObject(v.toList()))) + }; + @override String toString() => 'Resource($key)'; } /// Resource to be created on the server. Does not have the id yet class NewResource extends Resource { - NewResource(String type, - {Map attributes, - Map toOne, - Map> toMany}) - : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); + NewResource(String type, {Map attributes}) + : super(type, null, attributes: attributes); } diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart index 997e3a38..76d2ac5a 100644 --- a/lib/src/document/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -30,10 +30,7 @@ class ResourceObject with Meta, Links { static ResourceObject fromResource(Resource resource) => ResourceObject(resource.type, resource.id, attributes: resource.attributes, - relationships: { - ...resource.toOne.map((k, v) => MapEntry(k, ToOneObject(v))), - ...resource.toMany.map((k, v) => MapEntry(k, ToManyObject(v))) - }); + relationships: resource.relationships); /// Reconstructs the `data` member of a JSON:API Document. static ResourceObject fromJson(Object json) { diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart index 75642cf0..7fcfe4b3 100644 --- a/lib/src/server/in_memory_repository.dart +++ b/lib/src/server/in_memory_repository.dart @@ -64,15 +64,8 @@ class InMemoryRepository implements Repository { if (resource.isEmpty && resource.id == id) { return null; } - final updated = Resource( - original.type, - original.id, - attributes: {...original.attributes}..addAll(resource.attributes), - toOne: {...original.toOne}..addAll(resource.toOne), - toMany: {...original.toMany}..addAll(resource.toMany), - ); - _collections[type][id] = updated; - return updated; + original.addAll(resource); + return original; } @override diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 5809e3e7..85297dd2 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -22,9 +22,9 @@ class RepositoryController implements Controller { Future addToRelationship( Request request, List identifiers) => _do(() async { - final original = + final resource = await _repo.get(request.target.type, request.target.id); - if (!original.toMany.containsKey(request.target.relationship)) { + if (!resource.toMany.containsKey(request.target.relationship)) { return ErrorResponse(404, [ ErrorObject( status: '404', @@ -33,17 +33,9 @@ class RepositoryController implements Controller { "There is no to-many relationship '${request.target.relationship}' in this resource") ]); } - final updated = await _repo.update( - request.target.type, - request.target.id, - Resource(request.target.type, request.target.id, toMany: { - request.target.relationship: { - ...original.toMany[request.target.relationship], - ...identifiers - }.toList() - })); + resource.toMany[request.target.relationship].addAll(identifiers); return ToManyResponse( - request, updated.toMany[request.target.relationship]); + request, resource.toMany[request.target.relationship].toList()); }); @override @@ -61,19 +53,11 @@ class RepositoryController implements Controller { Future deleteFromRelationship( Request request, List identifiers) => _do(() async { - final original = + final resource = await _repo.get(request.target.type, request.target.id); - final updated = await _repo.update( - request.target.type, - request.target.id, - Resource(request.target.type, request.target.id, toMany: { - request.target.relationship: ({ - ...original.toMany[request.target.relationship] - }..removeAll(identifiers)) - .toList() - })); + resource.toMany[request.target.relationship].remove(identifiers); return ToManyResponse( - request, updated.toMany[request.target.relationship]); + request, resource.toMany[request.target.relationship].toList()); }); @override @@ -108,14 +92,15 @@ class RepositoryController implements Controller { final resource = await _repo.get(request.target.type, request.target.id); if (resource.hasOne(request.target.relationship)) { - final i = resource.toOne[request.target.relationship]; return RelatedResourceResponse( - request, await _repo.get(i.type, i.id)); + request, + await resource.toOne[request.target.relationship].mapIfExists( + (i) async => _repo.get(i.type, i.id), () async => null)); } if (resource.hasMany(request.target.relationship)) { final related = []; for (final identifier - in resource.toMany[request.target.relationship]) { + in resource.toMany[request.target.relationship].toList()) { related.add(await _repo.get(identifier.type, identifier.id)); } return RelatedCollectionResponse(request, Collection(related)); @@ -131,11 +116,13 @@ class RepositoryController implements Controller { await _repo.get(request.target.type, request.target.id); if (resource.hasOne(request.target.relationship)) { return ToOneResponse( - request, resource.toOne[request.target.relationship]); + request, + resource.toOne[request.target.relationship] + .mapIfExists((i) => i, () => null)); } if (resource.hasMany(request.target.relationship)) { return ToManyResponse( - request, resource.toMany[request.target.relationship]); + request, resource.toMany[request.target.relationship].toList()); } return ErrorResponse( 404, _relationshipNotFound(request.target.relationship)); @@ -196,14 +183,7 @@ class RepositoryController implements Controller { ) async { if (path.isEmpty) return []; final resources = []; - final ids = []; - - if (resource.hasOne(path.first)) { - ids.add(resource.toOne[path.first]); - } else if (resource.hasMany(path.first)) { - ids.addAll(resource.toMany[path.first]); - } - for (final id in ids) { + for (final id in resource.relatedByKey(path.first)) { final r = await _repo.get(id.type, id.id); if (path.length > 1) { resources.addAll(await _getRelated(r, path.skip(1))); diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index be121002..17364b1a 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -167,14 +167,14 @@ class HttpResponseFactory implements ResponseFactory { relationships: { ...resource.toOne.map((k, v) => MapEntry( k, - ToOneObject(v, links: { + ToOneObject(v.mapIfExists((i) => i, () => null), links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), }))), ...resource.toMany.map((k, v) => MapEntry( k, - ToManyObject(v, links: { + ToManyObject(v.toList(), links: { 'self': Link(_uri.relationship(resource.type, resource.id, k)), 'related': Link(_uri.related(resource.type, resource.id, k)), diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 1dd5d498..db877dae 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -51,7 +51,7 @@ void main() async { client = JsonApiClient(server, routing); }); - group('Single Resouces', () { + group('Single Resources', () { test('not compound by default', () async { final r = await client.fetchResource('posts', '1'); final document = r.decodeDocument(); diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index fb1c2157..2a8626cf 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -34,7 +34,12 @@ void main() async { expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toOne['publisher'].id, '2'); + r1 + .decodeDocument() + .data + .unwrap() + .toOne['publisher'] + .mapIfExists((i) => expect(i.id, '2'), () => fail('No id')); }); test('404 on collection', () async { @@ -71,7 +76,8 @@ void main() async { expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toOne['publisher'], isNull); + expect( + r1.decodeDocument().data.unwrap().toOne['publisher'].isEmpty, true); }); test('404 on collection', () async { @@ -107,8 +113,12 @@ void main() async { expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 1); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].first.id, '1'); + expect( + r1.decodeDocument().data.unwrap().toMany['authors'].toList().length, + 1); + expect( + r1.decodeDocument().data.unwrap().toMany['authors'].toList().first.id, + '1'); }); test('404 when collection not found', () async { diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 9ef64c57..b68a6677 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -6,6 +6,7 @@ import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:test/test.dart'; +import 'package:test/test.dart'; import '../../helper/expect_resources_equal.dart'; import 'seed_resources.dart'; @@ -45,10 +46,10 @@ void main() async { expect(r.decodeDocument().data.unwrap().attributes['pages'], 448); expect( r.decodeDocument().data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.decodeDocument().data.unwrap().toOne['publisher'], isNull); - expect(r.decodeDocument().data.unwrap().toMany['authors'], + expect(r.decodeDocument().data.unwrap().toOne['publisher'].isEmpty, true); + expect(r.decodeDocument().data.unwrap().toMany['authors'].toList(), equals([Identifier('people', '1')])); - expect(r.decodeDocument().data.unwrap().toMany['reviewers'], + expect(r.decodeDocument().data.unwrap().toMany['reviewers'].toList(), equals([Identifier('people', '2')])); final r1 = await client.fetchResource('books', '1'); diff --git a/test/helper/expect_resources_equal.dart b/test/helper/expect_resources_equal.dart index 4a9898c2..49d88778 100644 --- a/test/helper/expect_resources_equal.dart +++ b/test/helper/expect_resources_equal.dart @@ -5,6 +5,10 @@ void expectResourcesEqual(Resource a, Resource b) { expect(a.type, equals(b.type)); expect(a.id, equals(b.id)); expect(a.attributes, equals(b.attributes)); - expect(a.toOne, equals(b.toOne)); - expect(a.toMany, equals(b.toMany)); + expect(a.toOne.keys, equals(b.toOne.keys)); + expect(a.toOne.values.map((_) => _.mapIfExists((_) => _, () => null)), + equals(b.toOne.values.map((_) => _.mapIfExists((_) => _, () => null)))); + expect(a.toMany.keys, equals(b.toMany.keys)); + expect(a.toMany.values.expand((_) => _.toList()), + equals(b.toMany.values.expand((_) => _.toList()))); } From 0d2bd2e47a26b8ff0137df71eab8a9a02201c1a5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 27 Apr 2020 20:21:15 -0700 Subject: [PATCH 63/99] Relationship refactoring step 4 --- lib/src/document/identifier.dart | 13 ---- lib/src/document/relationship.dart | 20 +++--- lib/src/document/relationship_object.dart | 11 +++- lib/src/document/resource.dart | 49 ++++++++++----- lib/src/server/repository_controller.dart | 19 +++--- lib/src/server/response_factory.dart | 19 ++---- test/functional/compound_document_test.dart | 62 +++++++++---------- .../crud/creating_resources_test.dart | 6 +- .../crud/fetching_relationships_test.dart | 4 +- .../crud/fetching_resources_test.dart | 4 +- .../crud/updating_relationships_test.dart | 16 ++--- .../crud/updating_resources_test.dart | 16 ++--- test/helper/expect_resources_equal.dart | 14 ----- test/helper/expect_same_json.dart | 7 +++ test/unit/document/identifier_test.dart | 8 --- test/unit/document/relationship_test.dart | 2 +- test/unit/document/resource_test.dart | 2 +- test/unit/document/to_many_test.dart | 2 +- test/unit/document/to_one_test.dart | 2 +- 19 files changed, 134 insertions(+), 142 deletions(-) delete mode 100644 test/helper/expect_resources_equal.dart create mode 100644 test/helper/expect_same_json.dart delete mode 100644 test/unit/document/identifier_test.dart diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index d0c1c00e..b2fbb960 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -30,19 +30,6 @@ class Identifier with Meta, Identity { @override final String id; - /// Returns true if the two identifiers have the same [type] and [id] - bool equals(Identifier other) => - other != null && - other.runtimeType == Identifier && - other.type == type && - other.id == id; - - @override - bool operator ==(other) => equals(other); - - @override - int get hashCode => 0; - Map toJson() => { 'type': type, 'id': id, diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 5d9ef19b..a1d9e76e 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,13 +1,12 @@ import 'package:json_api/document.dart'; -class Relationship {} - -class ToOne implements Relationship { - ToOne(Identifier identifier) { +class ToOne extends RelationshipObject { + ToOne(Identifier identifier, {Map links}) + : super(links: links) { set(identifier); } - ToOne.empty(); + ToOne.empty({Map links}) : super(links: links); final _values = {}; @@ -29,12 +28,16 @@ class ToOne implements Relationship { _values.clear(); } + @override + Map toJson() => {...super.toJson(), 'data': _values.first}; + static ToOne fromNullable(Identifier identifier) => identifier == null ? ToOne.empty() : ToOne(identifier); } -class ToMany implements Relationship { - ToMany(Iterable identifiers) { +class ToMany extends RelationshipObject { + ToMany(Iterable identifiers, {Map links}) + : super(links: links) { set(identifiers); } @@ -56,4 +59,7 @@ class ToMany implements Relationship { void addAll(Iterable identifiers) { identifiers.forEach((i) => _map[i.key] = i); } + + @override + Map toJson() => {...super.toJson(), 'data': _map.values}; } diff --git a/lib/src/document/relationship_object.dart b/lib/src/document/relationship_object.dart index 4966a70e..be35859d 100644 --- a/lib/src/document/relationship_object.dart +++ b/lib/src/document/relationship_object.dart @@ -1,7 +1,9 @@ import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/meta.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_object.dart'; import 'package:json_api/src/nullable.dart'; @@ -13,7 +15,7 @@ import 'package:json_api/src/nullable.dart'; /// It can also be a part of [ResourceObject].relationships map. /// /// More on this: https://jsonapi.org/format/#document-resource-object-relationships -class RelationshipObject extends PrimaryData { +class RelationshipObject extends PrimaryData with Meta { RelationshipObject({Map links}) : super(links: links); /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. @@ -44,8 +46,9 @@ class RelationshipObject extends PrimaryData { throw DocumentException("The 'relationships' member must be a JSON object"); } - /// The "related" link. May be null. - Link get related => links['related']; + @override + Map toJson() => + {...super.toJson(), if (meta.isNotEmpty) 'meta': meta}; } /// Relationship to-one @@ -75,6 +78,8 @@ class ToOneObject extends RelationshipObject { /// More on this: https://jsonapi.org/format/#document-resource-object-linkage final Identifier linkage; + ToOne unwrap() => ToOne.fromNullable(linkage); + @override Map toJson() => { ...super.toJson(), diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 46f573f8..254d6d9e 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -24,8 +24,8 @@ class Resource with Meta, Links, Identity { this.attributes.addAll(attributes ?? {}); this.meta.addAll(meta ?? {}); this.links.addAll(links ?? {}); - toOne?.forEach((k, v) => this.toOne[k] = ToOne.fromNullable(v)); - toMany?.forEach((k, v) => this.toMany[k] = ToMany(v)); + toOne?.forEach((k, v) => this._toOne[k] = ToOne.fromNullable(v)); + toMany?.forEach((k, v) => this._toMany[k] = ToMany(v)); } /// Resource type @@ -42,57 +42,72 @@ class Resource with Meta, Links, Identity { final attributes = {}; /// The map of to-one relationships - final toOne = {}; + final _toOne = {}; /// The map of to-many relationships - final toMany = {}; + final _toMany = {}; /// All related resource identifiers. - List get related => toOne.values + List get related => _toOne.values .map((_) => _.toList()) - .followedBy(toMany.values.map((_) => _.toList())) + .followedBy(_toMany.values.map((_) => _.toList())) .expand((_) => _) .toList(); List relatedByKey(String key) { if (hasOne(key)) { - return toOne[key].toList(); + return _toOne[key].toList(); } if (hasMany(key)) { - return toMany[key].toList(); + return _toMany[key].toList(); } return []; } /// True for resources without attributes and relationships - bool get isEmpty => attributes.isEmpty && toOne.isEmpty && toMany.isEmpty; + bool get isEmpty => attributes.isEmpty && _toOne.isEmpty && _toMany.isEmpty; - bool hasOne(String key) => toOne.containsKey(key); + bool hasOne(String key) => _toOne.containsKey(key); - bool hasMany(String key) => toMany.containsKey(key); + ToOne one(String key) => + _toOne[key] ?? (throw StateError('No such relationship')); + + ToMany many(String key) => + _toMany[key] ?? (throw StateError('No such relationship')); + + bool hasMany(String key) => _toMany.containsKey(key); void addAll(Resource other) { attributes.addAll(other.attributes); - toOne.addAll(other.toOne); - toMany.addAll(other.toMany); + _toOne.addAll(other._toOne); + _toMany.addAll(other._toMany); } Resource withId(String newId) { // TODO: move to NewResource() if (id != null) throw StateError('Should not change id'); return Resource(type, newId, attributes: attributes) - ..toOne.addAll(toOne) - ..toMany.addAll(toMany); + .._toOne.addAll(_toOne) + .._toMany.addAll(_toMany); } Map get relationships => { - ...toOne.map((k, v) => MapEntry( + ..._toOne.map((k, v) => MapEntry( k, v.mapIfExists((_) => ToOneObject(_), () => ToOneObject(null)))), - ...toMany.map((k, v) => MapEntry(k, ToManyObject(v.toList()))) + ..._toMany.map((k, v) => MapEntry(k, ToManyObject(v.toList()))) }; @override String toString() => 'Resource($key)'; + + Map toJson() => { + 'type': type, + 'id': id, + if (meta.isNotEmpty) 'meta': meta, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (links.isNotEmpty) 'links': links, + }; } /// Resource to be created on the server. Does not have the id yet diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index 85297dd2..c31408d0 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -24,7 +24,7 @@ class RepositoryController implements Controller { _do(() async { final resource = await _repo.get(request.target.type, request.target.id); - if (!resource.toMany.containsKey(request.target.relationship)) { + if (!resource.hasMany(request.target.relationship)) { return ErrorResponse(404, [ ErrorObject( status: '404', @@ -33,9 +33,9 @@ class RepositoryController implements Controller { "There is no to-many relationship '${request.target.relationship}' in this resource") ]); } - resource.toMany[request.target.relationship].addAll(identifiers); + resource.many(request.target.relationship).addAll(identifiers); return ToManyResponse( - request, resource.toMany[request.target.relationship].toList()); + request, resource.many(request.target.relationship).toList()); }); @override @@ -55,9 +55,9 @@ class RepositoryController implements Controller { _do(() async { final resource = await _repo.get(request.target.type, request.target.id); - resource.toMany[request.target.relationship].remove(identifiers); + resource.many(request.target.relationship).remove(identifiers); return ToManyResponse( - request, resource.toMany[request.target.relationship].toList()); + request, resource.many(request.target.relationship).toList()); }); @override @@ -94,13 +94,13 @@ class RepositoryController implements Controller { if (resource.hasOne(request.target.relationship)) { return RelatedResourceResponse( request, - await resource.toOne[request.target.relationship].mapIfExists( + await resource.one(request.target.relationship).mapIfExists( (i) async => _repo.get(i.type, i.id), () async => null)); } if (resource.hasMany(request.target.relationship)) { final related = []; for (final identifier - in resource.toMany[request.target.relationship].toList()) { + in resource.many(request.target.relationship).toList()) { related.add(await _repo.get(identifier.type, identifier.id)); } return RelatedCollectionResponse(request, Collection(related)); @@ -117,12 +117,13 @@ class RepositoryController implements Controller { if (resource.hasOne(request.target.relationship)) { return ToOneResponse( request, - resource.toOne[request.target.relationship] + resource + .one(request.target.relationship) .mapIfExists((i) => i, () => null)); } if (resource.hasMany(request.target.relationship)) { return ToManyResponse( - request, resource.toMany[request.target.relationship].toList()); + request, resource.many(request.target.relationship).toList()); } return ErrorResponse( 404, _relationshipNotFound(request.target.relationship)); diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index 17364b1a..f4bcb5f0 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -165,20 +165,13 @@ class HttpResponseFactory implements ResponseFactory { ResourceObject(resource.type, resource.id, attributes: resource.attributes, relationships: { - ...resource.toOne.map((k, v) => MapEntry( + ...resource.relationships.map((k, v) => MapEntry( k, - ToOneObject(v.mapIfExists((i) => i, () => null), links: { - 'self': - Link(_uri.relationship(resource.type, resource.id, k)), - 'related': Link(_uri.related(resource.type, resource.id, k)), - }))), - ...resource.toMany.map((k, v) => MapEntry( - k, - ToManyObject(v.toList(), links: { - 'self': - Link(_uri.relationship(resource.type, resource.id, k)), - 'related': Link(_uri.related(resource.type, resource.id, k)), - }))), + v + ..links['self'] = + Link(_uri.relationship(resource.type, resource.id, k)) + ..links['related'] = + Link(_uri.related(resource.type, resource.id, k)))) }, links: { 'self': Link(_uri.resource(resource.type, resource.id)) diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index db877dae..56a543f3 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -7,7 +7,7 @@ import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:test/test.dart'; -import '../helper/expect_resources_equal.dart'; +import '../helper/expect_same_json.dart'; void main() async { JsonApiClient client; @@ -55,14 +55,14 @@ void main() async { test('not compound by default', () async { final r = await client.fetchResource('posts', '1'); final document = r.decodeDocument(); - expectResourcesEqual(document.data.unwrap(), post); + expectSameJson(document.data.unwrap(), post); expect(document.isCompound, isFalse); }); test('included == [] when requested but nothing to include', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['tags'])); - expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().included, []); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().data.links['self'].toString(), @@ -72,42 +72,42 @@ void main() async { test('can include first-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments'])); - expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); - expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); - expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); + expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); + expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author'])); - expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); - expectResourcesEqual(r.decodeDocument().included.first.unwrap(), bob); - expectResourcesEqual(r.decodeDocument().included.last.unwrap(), alice); + expectSameJson(r.decodeDocument().included.first.unwrap(), bob); + expectSameJson(r.decodeDocument().included.last.unwrap(), alice); }); test('can include third-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 1); - expectResourcesEqual( + expectSameJson( r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchResource('posts', '1', parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.decodeDocument().data.unwrap(), post); + expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().included.length, 4); - expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); - expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); - expectResourcesEqual(r.decodeDocument().included[2].unwrap(), bob); - expectResourcesEqual(r.decodeDocument().included[3].unwrap(), alice); + expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); + expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); + expectSameJson(r.decodeDocument().included[2].unwrap(), bob); + expectSameJson(r.decodeDocument().included[3].unwrap(), alice); expect(r.decodeDocument().isCompound, isTrue); }); }); @@ -115,7 +115,7 @@ void main() async { group('Resource Collection', () { test('not compound by default', () async { final r = await client.fetchCollection('posts'); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isFalse); }); @@ -123,7 +123,7 @@ void main() async { () async { final r = await client.fetchCollection('posts', parameters: Include(['tags'])); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().included, []); expect(r.decodeDocument().isCompound, isTrue); }); @@ -131,43 +131,43 @@ void main() async { test('can include first-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments'])); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); - expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); - expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); + expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); + expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); }); test('can include second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author'])); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().included.length, 2); - expectResourcesEqual(r.decodeDocument().included.first.unwrap(), bob); - expectResourcesEqual(r.decodeDocument().included.last.unwrap(), alice); + expectSameJson(r.decodeDocument().included.first.unwrap(), bob); + expectSameJson(r.decodeDocument().included.last.unwrap(), alice); expect(r.decodeDocument().isCompound, isTrue); }); test('can include third-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 1); - expectResourcesEqual( + expectSameJson( r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchCollection('posts', parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.decodeDocument().data.unwrap().first, post); + expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 4); - expectResourcesEqual(r.decodeDocument().included[0].unwrap(), comment1); - expectResourcesEqual(r.decodeDocument().included[1].unwrap(), comment2); - expectResourcesEqual(r.decodeDocument().included[2].unwrap(), bob); - expectResourcesEqual(r.decodeDocument().included[3].unwrap(), alice); + expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); + expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); + expectSameJson(r.decodeDocument().included[2].unwrap(), bob); + expectSameJson(r.decodeDocument().included[3].unwrap(), alice); }); }); } diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index de93d020..5a45c4e6 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -5,7 +5,7 @@ import 'package:json_api/server.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; -import '../../helper/expect_resources_equal.dart'; +import '../../helper/expect_same_json.dart'; void main() async { final host = 'localhost'; @@ -36,7 +36,7 @@ void main() async { final r1 = await client.send( Request.fetchResource(), Uri.parse(r.http.headers['location'])); expect(r1.http.statusCode, 200); - expectResourcesEqual(r1.decodeDocument().data.unwrap(), created); + expectSameJson(r1.decodeDocument().data.unwrap(), created); }); test('403 when the id can not be generated', () async { @@ -79,7 +79,7 @@ void main() async { final r1 = await client.fetchResource(person.type, person.id); expect(r1.isSuccessful, isTrue); expect(r1.http.statusCode, 200); - expectResourcesEqual(r1.decodeDocument().data.unwrap(), person); + expectSameJson(r1.decodeDocument().data.unwrap(), person); }); test('404 when the collection does not exist', () async { diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 024813d4..31dc8907 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -34,7 +34,7 @@ void main() async { expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/publisher'); expect( - r.decodeDocument().data.related.uri.toString(), '/books/1/publisher'); + r.decodeDocument().data.links['related'].uri.toString(), '/books/1/publisher'); expect(r.decodeDocument().data.linkage.type, 'companies'); expect(r.decodeDocument().data.linkage.id, '1'); }); @@ -84,7 +84,7 @@ void main() async { expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/authors'); expect( - r.decodeDocument().data.related.uri.toString(), '/books/1/authors'); + r.decodeDocument().data.links['related'].uri.toString(), '/books/1/authors'); }); test('404 on collection', () async { diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index 7857bf9d..fa667683 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -43,12 +43,12 @@ void main() async { r.decodeDocument().data.resourceObject.relationships['authors']; expect( authors.links['self'].toString(), '/books/1/relationships/authors'); - expect(authors.related.toString(), '/books/1/authors'); + expect(authors.links['related'].toString(), '/books/1/authors'); final publisher = r.decodeDocument().data.resourceObject.relationships['publisher']; expect(publisher.links['self'].toString(), '/books/1/relationships/publisher'); - expect(publisher.related.toString(), '/books/1/publisher'); + expect(publisher.links['related'].toString(), '/books/1/publisher'); }); test('404 on collection', () async { diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 2a8626cf..09a24f74 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -38,7 +38,7 @@ void main() async { .decodeDocument() .data .unwrap() - .toOne['publisher'] + .one('publisher') .mapIfExists((i) => expect(i.id, '2'), () => fail('No id')); }); @@ -77,7 +77,7 @@ void main() async { final r1 = await client.fetchResource('books', '1'); expect( - r1.decodeDocument().data.unwrap().toOne['publisher'].isEmpty, true); + r1.decodeDocument().data.unwrap().one('publisher').isEmpty, true); }); test('404 on collection', () async { @@ -114,10 +114,10 @@ void main() async { final r1 = await client.fetchResource('books', '1'); expect( - r1.decodeDocument().data.unwrap().toMany['authors'].toList().length, + r1.decodeDocument().data.unwrap().many('authors').toList().length, 1); expect( - r1.decodeDocument().data.unwrap().toMany['authors'].toList().first.id, + r1.decodeDocument().data.unwrap().many('authors').toList().first.id, '1'); }); @@ -160,7 +160,7 @@ void main() async { expect(r.decodeDocument().data.linkage.last.id, '3'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 3); + expect(r1.decodeDocument().data.unwrap().many('authors').length, 3); }); test('successfully adding an existing identifier', () async { @@ -174,7 +174,7 @@ void main() async { expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); + expect(r1.decodeDocument().data.unwrap().many('authors').length, 2); expect(r1.http.headers['content-type'], Document.contentType); }); @@ -230,7 +230,7 @@ void main() async { expect(r.decodeDocument().data.linkage.first.id, '2'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 1); + expect(r1.decodeDocument().data.unwrap().many('authors').length, 1); }); test('successfully deleting a non-present identifier', () async { @@ -244,7 +244,7 @@ void main() async { expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().toMany['authors'].length, 2); + expect(r1.decodeDocument().data.unwrap().many('authors').length, 2); }); test('404 when collection not found', () async { diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index b68a6677..95318d28 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -6,9 +6,8 @@ import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:test/test.dart'; -import 'package:test/test.dart'; -import '../../helper/expect_resources_equal.dart'; +import '../../helper/expect_same_json.dart'; import 'seed_resources.dart'; void main() async { @@ -46,14 +45,15 @@ void main() async { expect(r.decodeDocument().data.unwrap().attributes['pages'], 448); expect( r.decodeDocument().data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.decodeDocument().data.unwrap().toOne['publisher'].isEmpty, true); - expect(r.decodeDocument().data.unwrap().toMany['authors'].toList(), - equals([Identifier('people', '1')])); - expect(r.decodeDocument().data.unwrap().toMany['reviewers'].toList(), - equals([Identifier('people', '2')])); + expect(r.decodeDocument().data.unwrap().one('publisher').isEmpty, true); + expect(r.decodeDocument().data.unwrap().many('authors').toList().first.key, + equals('people:1')); + expect( + r.decodeDocument().data.unwrap().many('reviewers').toList().first.key, + equals('people:2')); final r1 = await client.fetchResource('books', '1'); - expectResourcesEqual( + expectSameJson( r1.decodeDocument().data.unwrap(), r.decodeDocument().data.unwrap()); }); diff --git a/test/helper/expect_resources_equal.dart b/test/helper/expect_resources_equal.dart deleted file mode 100644 index 49d88778..00000000 --- a/test/helper/expect_resources_equal.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void expectResourcesEqual(Resource a, Resource b) { - expect(a.type, equals(b.type)); - expect(a.id, equals(b.id)); - expect(a.attributes, equals(b.attributes)); - expect(a.toOne.keys, equals(b.toOne.keys)); - expect(a.toOne.values.map((_) => _.mapIfExists((_) => _, () => null)), - equals(b.toOne.values.map((_) => _.mapIfExists((_) => _, () => null)))); - expect(a.toMany.keys, equals(b.toMany.keys)); - expect(a.toMany.values.expand((_) => _.toList()), - equals(b.toMany.values.expand((_) => _.toList()))); -} diff --git a/test/helper/expect_same_json.dart b/test/helper/expect_same_json.dart new file mode 100644 index 00000000..50be610c --- /dev/null +++ b/test/helper/expect_same_json.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void expectSameJson(Resource a, Resource b) => + expect(jsonEncode(a), jsonEncode(b)); diff --git a/test/unit/document/identifier_test.dart b/test/unit/document/identifier_test.dart deleted file mode 100644 index 05d485c4..00000000 --- a/test/unit/document/identifier_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('equal identifiers are detected by Set', () { - expect({Identifier('foo', '1'), Identifier('foo', '1')}.length, 1); - }); -} diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 68de23ec..11dd1d1c 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -20,7 +20,7 @@ void main() { expect(r.links['self'].toString(), '/self'); expect(r.links['related'].toString(), '/related'); expect(r.links['self'].toString(), '/self'); - expect(r.related.toString(), '/related'); + expect(r.links['related'].toString(), '/related'); }); test('custom "links" survives json serialization', () { diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index bb7bea4f..73f657aa 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -6,7 +6,7 @@ void main() { final r = Resource('type', 'id', toMany: { 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] }); - expect(r.toMany['rel'].length, 1); + expect(r.many('rel').length, 1); }); test('toString', () { diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart index 9ea59517..8861b982 100644 --- a/test/unit/document/to_many_test.dart +++ b/test/unit/document/to_many_test.dart @@ -20,7 +20,7 @@ void main() { expect(r.links['self'].toString(), '/self'); expect(r.links['related'].toString(), '/related'); expect(r.links['self'].toString(), '/self'); - expect(r.related.toString(), '/related'); + expect(r.links['related'].toString(), '/related'); }); test('custom "links" survives json serialization', () { diff --git a/test/unit/document/to_one_test.dart b/test/unit/document/to_one_test.dart index 0260f770..f9af2c98 100644 --- a/test/unit/document/to_one_test.dart +++ b/test/unit/document/to_one_test.dart @@ -20,7 +20,7 @@ void main() { expect(r.links['self'].toString(), '/self'); expect(r.links['related'].toString(), '/related'); expect(r.links['self'].toString(), '/self'); - expect(r.related.toString(), '/related'); + expect(r.links['related'].toString(), '/related'); }); test('custom "links" survives json serialization', () { From ea1e7a7626ba54930f08585dcf7cb662d7bf17a4 Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 8 May 2020 20:31:32 -0700 Subject: [PATCH 64/99] Separated ReqyestDocument --- example/client.dart | 15 +- lib/client.dart | 1 + lib/src/client/document.dart | 195 ++++++++++++++++++ lib/src/client/json_api_client.dart | 135 +++++++----- lib/src/client/request.dart | 21 +- test/e2e/browser_test.dart | 18 +- test/e2e/client_server_interaction_test.dart | 20 +- test/functional/compound_document_test.dart | 70 +++---- .../crud/creating_resources_test.dart | 87 ++++---- test/functional/crud/seed_resources.dart | 31 ++- .../crud/updating_relationships_test.dart | 35 ++-- .../crud/updating_resources_test.dart | 52 +++-- test/helper/expect_same_json.dart | 2 +- test/unit/client/async_processing_test.dart | 6 +- 14 files changed, 441 insertions(+), 247 deletions(-) create mode 100644 lib/src/client/document.dart diff --git a/example/client.dart b/example/client.dart index 0c360756..a6de2d91 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,6 +1,5 @@ import 'package:http/http.dart' as http; import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; @@ -24,19 +23,19 @@ void main() async { final client = JsonApiClient(httpHandler, routing); /// Create the first resource. - await client.createResource( - Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); + await client + .createResource('writers', '1', attributes: {'name': 'Martin Fowler'}); /// Create the second resource. - await client.createResource(Resource('books', '2', attributes: { + await client.createResource('books', '2', attributes: { 'title': 'Refactoring' - }, toMany: { - 'authors': [Identifier('writers', '1')] - })); + }, relationships: { + 'authors': Many([Identifier('writers', '1')]) + }); /// Fetch the book, including its authors. final response = await client.fetchResource('books', '2', - parameters: Include(['authors'])); + include: ['authors']); final document = response.decodeDocument(); diff --git a/lib/client.dart b/lib/client.dart index 7c78e11f..ea9d9938 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -4,5 +4,6 @@ export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/request.dart'; +export 'package:json_api/src/client/document.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart new file mode 100644 index 00000000..25f409b3 --- /dev/null +++ b/lib/src/client/document.dart @@ -0,0 +1,195 @@ +import 'dart:collection'; +class ContentType { + static const jsonApi = 'application/vnd.api+json'; +} + +abstract class RequestDocument { + Api get api; + + Map get meta; + + Map toJson(); +} + +class ResourceDocument implements RequestDocument { + ResourceDocument( + this.resource, { + Api api, + Map meta, + }) : meta = Map.unmodifiable(meta ?? {}), + api = api ?? Api(); + + final GenericResource resource; + @override + final Api api; + @override + final Map meta; + + @override + Map toJson() => { + 'data': resource.toJson(), + if (meta.isNotEmpty) 'meta': meta, + if (api.isNotEmpty) 'jsonapi': api.toJson() + }; +} + +class RelationshipDocument implements RequestDocument { + RelationshipDocument(this.relationship, {Api api}) : api = api ?? Api(); + + final Relationship relationship; + + @override + final Api api; + + @override + Map get meta => relationship.meta; + + @override + Map toJson() => + {...relationship.toJson(), if (api.isNotEmpty) 'jsonapi': api.toJson()}; +} + +abstract class GenericResource { + Map toJson(); +} + +class NewResource implements GenericResource { + NewResource( + this.type, { + Map attributes, + Map meta, + Map relationships, + }) : attributes = Map.unmodifiable(attributes ?? {}), + meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}); + + final String type; + final Map meta; + final Map attributes; + final Map relationships; + + @override + Map toJson() => { + 'type': type, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +class Resource implements GenericResource { + Resource( + this.type, + this.id, { + Map attributes, + Map meta, + Map relationships, + }) : attributes = Map.unmodifiable(attributes ?? {}), + meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}); + + final String type; + final String id; + final Map meta; + final Map attributes; + final Map relationships; + + @override + Map toJson() => { + 'type': type, + 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +abstract class Relationship implements Iterable { + Map toJson(); + + Map get meta; +} + +class One with IterableMixin implements Relationship { + One(Identifier identifier, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + _ids = List.unmodifiable([identifier]); + + One.empty({Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + _ids = const []; + + @override + final Map meta; + final List _ids; + + @override + Iterator get iterator => _ids.iterator; + + @override + Map toJson() => { + if (meta.isNotEmpty) 'meta': meta, + 'data': _ids.isEmpty ? null : _ids.first, + }; +} + +class Many with IterableMixin implements Relationship { + Many(Iterable identifiers, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + _ids = List.unmodifiable(identifiers); + + @override + final Map meta; + final List _ids; + + @override + Iterator get iterator => _ids.iterator; + + @override + Map toJson() => { + if (meta.isNotEmpty) 'meta': meta, + 'data': _ids, + }; +} + +class Identifier { + Identifier(this.type, this.id, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}) { + ArgumentError.checkNotNull(type, 'type'); + ArgumentError.checkNotNull(id, 'id'); + } + + final String type; + + final String id; + + final Map meta; + + Map toJson() => { + 'type': type, + 'id': id, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +class Api { + Api({String version, Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + version = version ?? ''; + + static const v1 = '1.0'; + + /// The JSON:API version. May be empty. + final String version; + + final Map meta; + + bool get isHigherVersion => version.isNotEmpty && version != v1; + + bool get isNotEmpty => version.isNotEmpty && meta.isNotEmpty; + + Map toJson() => { + if (version.isNotEmpty) 'version': version, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 6c8ba2d0..63ac1316 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,8 +1,9 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; +import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/document.dart'; /// The JSON:API client class JsonApiClient { @@ -12,138 +13,158 @@ class JsonApiClient { final UriFactory _uri; /// Fetches a primary resource collection by [type]. - Future> fetchCollection(String type, - {Map headers, QueryParameters parameters}) => + Future> fetchCollection(String type, + {Map headers, Iterable include = const []}) => send( - Request.fetchCollection(parameters: parameters), + Request.fetchCollection(parameters: Include(include)), _uri.collection(type), headers: headers, ); /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - Future> fetchRelatedCollection( + Future> fetchRelatedCollection( String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - send(Request.fetchCollection(parameters: parameters), + {Map headers, Iterable include = const []}) => + send(Request.fetchCollection(parameters: Include(include)), _uri.related(type, id, relationship), headers: headers); /// Fetches a primary resource by [type] and [id]. - Future> fetchResource(String type, String id, - {Map headers, QueryParameters parameters}) => - send(Request.fetchResource(parameters: parameters), + Future> fetchResource(String type, String id, + {Map headers, Iterable include = const []}) => + send(Request.fetchResource(parameters: Include(include)), _uri.resource(type, id), headers: headers); /// Fetches a related resource by [type], [id], [relationship]. - Future> fetchRelatedResource( + Future> fetchRelatedResource( String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - send(Request.fetchResource(parameters: parameters), + {Map headers, Iterable include = const []}) => + send(Request.fetchResource(parameters: Include(include)), _uri.related(type, id, relationship), headers: headers); /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( + Future> fetchToOne( String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - send(Request.fetchToOne(parameters: parameters), + {Map headers, Iterable include = const []}) => + send(Request.fetchToOne(parameters: Include(include)), _uri.relationship(type, id, relationship), headers: headers); /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( + Future> fetchToMany( String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => + {Map headers, Iterable include = const []}) => send( - Request.fetchToMany(parameters: parameters), + Request.fetchToMany(parameters: Include(include)), _uri.relationship(type, id, relationship), headers: headers, ); /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( + Future> fetchRelationship( String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - send(Request.fetchRelationship(parameters: parameters), + {Map headers = const {}, + Iterable include = const []}) => + send(Request.fetchRelationship(parameters: Include(include)), _uri.relationship(type, id, relationship), headers: headers); + /// Creates the [resource] on the server. The server is expected to assign the resource id. + Future> createNewResource(String type, + {Map attributes = const {}, + Map meta = const {}, + Map relationships = const {}, + Map headers = const {}}) => + send( + Request.createResource(ResourceDocument(NewResource(type, + attributes: attributes, + relationships: relationships, + meta: meta))), + _uri.collection(type), + headers: headers); + /// Creates the [resource] on the server. - Future> createResource(Resource resource, - {Map headers}) => - send(Request.createResource(_resourceDoc(resource)), - _uri.collection(resource.type), + Future> createResource(String type, String id, + {Map attributes = const {}, + Map meta = const {}, + Map relationships = const {}, + Map headers = const {}}) => + send( + Request.createResource(ResourceDocument(Resource(type, id, + attributes: attributes, + relationships: relationships, + meta: meta))), + _uri.collection(type), headers: headers); /// Deletes the resource by [type] and [id]. Future deleteResource(String type, String id, - {Map headers}) => + {Map headers = const {}}) => send(Request.deleteResource(), _uri.resource(type, id), headers: headers); /// Updates the [resource]. - Future> updateResource(Resource resource, - {Map headers}) => - send(Request.updateResource(_resourceDoc(resource)), - _uri.resource(resource.type, resource.id), + Future> updateResource(String type, String id, + {Map attributes = const {}, + Map meta = const {}, + Map relationships = const {}, + Map headers = const {}}) => + send( + Request.updateResource(ResourceDocument(Resource(type, id, + attributes: attributes, + relationships: relationships, + meta: meta))), + _uri.resource(type, id), headers: headers); /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( + Future> replaceToOne( String type, String id, String relationship, Identifier identifier, - {Map headers}) => - send(Request.replaceToOne(_toOneDoc(identifier)), + {Map headers = const {}}) => + send(Request.replaceToOne(RelationshipDocument(One(identifier))), _uri.relationship(type, id, relationship), headers: headers); /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( + Future> deleteToOne( String type, String id, String relationship, - {Map headers}) => - send(Request.replaceToOne(_toOneDoc(null)), + {Map headers = const {}}) => + send(Request.replaceToOne(RelationshipDocument(One.empty())), _uri.relationship(type, id, relationship), headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, + Future> deleteFromToMany(String type, String id, String relationship, Iterable identifiers, - {Map headers}) => - send(Request.deleteFromToMany(_toManyDoc(identifiers)), + {Map headers = const {}}) => + send(Request.deleteFromToMany(RelationshipDocument(Many(identifiers))), _uri.relationship(type, id, relationship), headers: headers); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, + Future> replaceToMany(String type, String id, String relationship, Iterable identifiers, - {Map headers}) => - send(Request.replaceToMany(_toManyDoc(identifiers)), + {Map headers = const {}}) => + send(Request.replaceToMany(RelationshipDocument(Many(identifiers))), _uri.relationship(type, id, relationship), headers: headers); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToMany(String type, String id, + Future> addToMany(String type, String id, String relationship, Iterable identifiers, - {Map headers}) => - send(Request.addToMany(_toManyDoc(identifiers)), + {Map headers = const {}}) => + send(Request.addToMany(RelationshipDocument(Many(identifiers))), _uri.relationship(type, id, relationship), headers: headers); /// Sends the request to the [uri] via [handler] and returns the response. /// Extra [headers] may be added to the request. - Future> send(Request request, Uri uri, - {Map headers}) async => + Future> send(Request request, Uri uri, + {Map headers = const {}}) async => Response( await _http.call(HttpRequest( request.method, request.parameters.addToUri(uri), body: request.body, headers: {...?headers, ...request.headers})), request.decoder); - - Document _resourceDoc(Resource resource) => - Document(ResourceData.fromResource(resource)); - - Document _toManyDoc(Iterable identifiers) => - Document(ToManyObject.fromIdentifiers(identifiers)); - - Document _toOneDoc(Identifier identifier) => - Document(ToOneObject.fromIdentifier(identifier)); } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 7f71548c..ac755e3d 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/query.dart'; +import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/http/http_method.dart'; /// A JSON:API request. @@ -11,7 +12,7 @@ class Request { body = '', parameters = parameters ?? QueryParameters.empty(); - Request.withPayload(Document document, this.method, this.decoder, + Request.withPayload(RequestDocument document, this.method, this.decoder, {QueryParameters parameters}) : headers = const { 'Accept': Document.contentType, @@ -36,29 +37,29 @@ class Request { static Request fetchRelationship( {QueryParameters parameters}) => - Request(HttpMethod.GET, RelationshipObject.fromJson, parameters: parameters); + Request(HttpMethod.GET, RelationshipObject.fromJson, + parameters: parameters); - static Request createResource( - Document document) => + static Request createResource(ResourceDocument document) => Request.withPayload(document, HttpMethod.POST, ResourceData.fromJson); - static Request updateResource( - Document document) => + static Request updateResource(ResourceDocument document) => Request.withPayload(document, HttpMethod.PATCH, ResourceData.fromJson); static Request deleteResource() => Request(HttpMethod.DELETE, ResourceData.fromJson); - static Request replaceToOne(Document document) => + static Request replaceToOne(RelationshipDocument document) => Request.withPayload(document, HttpMethod.PATCH, ToOneObject.fromJson); - static Request deleteFromToMany(Document document) => + static Request deleteFromToMany( + RelationshipDocument document) => Request.withPayload(document, HttpMethod.DELETE, ToManyObject.fromJson); - static Request replaceToMany(Document document) => + static Request replaceToMany(RelationshipDocument document) => Request.withPayload(document, HttpMethod.PATCH, ToManyObject.fromJson); - static Request addToMany(Document document) => + static Request addToMany(RelationshipDocument document) => Request.withPayload(document, HttpMethod.POST, ToManyObject.fromJson); final PrimaryDataDecoder decoder; diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 03496d00..e1f3d903 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,7 +1,5 @@ import 'package:http/http.dart'; import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -25,19 +23,17 @@ void main() async { final client = JsonApiClient(DartHttp(httpClient), routing); - final writer = - Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); - final book = Resource('books', '2', attributes: {'title': 'Refactoring'}); - - await client.createResource(writer); - await client.createResource(book); await client - .updateResource(Resource('books', '2', toMany: {'authors': []})); + .createResource('writers', '1', attributes: {'name': 'Martin Fowler'}); + await client + .createResource('books', '2', attributes: {'title': 'Refactoring'}); + await client + .updateResource('books', '2', relationships: {'authors': Many([])}); await client .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); - final response = await client.fetchResource('books', '2', - parameters: Include(['authors'])); + final response = + await client.fetchResource('books', '2', include: ['authors']); expect(response.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index cbb570a8..f7b75615 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -2,8 +2,6 @@ import 'dart:io'; import 'package:http/http.dart'; import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/client/dart_http.dart'; @@ -11,7 +9,7 @@ import 'package:pedantic/pedantic.dart'; import 'package:test/test.dart'; void main() { - group('Client-Server interation over HTTP', () { + group('Client-Server interaction over HTTP', () { final port = 8088; final host = 'localhost'; final routing = @@ -36,19 +34,17 @@ void main() { }); test('Happy Path', () async { - final writer = - Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); - final book = Resource('books', '2', attributes: {'title': 'Refactoring'}); - - await client.createResource(writer); - await client.createResource(book); + await client.createResource('writers', '1', + attributes: {'name': 'Martin Fowler'}); + await client + .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client - .updateResource(Resource('books', '2', toMany: {'authors': []})); + .updateResource('books', '2', relationships: {'authors': Many([])}); await client .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); - final response = await client.fetchResource('books', '2', - parameters: Include(['authors'])); + final response = + await client.fetchResource('books', '2', include: ['authors']); expect(response.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index 56a543f3..cc8c80f5 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -1,6 +1,5 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; +import 'package:json_api/document.dart' as d; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -17,25 +16,28 @@ void main() async { final base = Uri(scheme: 'http', host: host, port: port); final routing = StandardRouting(base); final wonderland = - Resource('countries', '1', attributes: {'name': 'Wonderland'}); - final alice = Resource('people', '1', + d.Resource('countries', '1', attributes: {'name': 'Wonderland'}); + final alice = d.Resource('people', '1', attributes: {'name': 'Alice'}, - toOne: {'birthplace': Identifier(wonderland.type, wonderland.id)}); - final bob = Resource('people', '2', + toOne: {'birthplace': d.Identifier(wonderland.type, wonderland.id)}); + final bob = d.Resource('people', '2', attributes: {'name': 'Bob'}, - toOne: {'birthplace': Identifier(wonderland.type, wonderland.id)}); - final comment1 = Resource('comments', '1', + toOne: {'birthplace': d.Identifier(wonderland.type, wonderland.id)}); + final comment1 = d.Resource('comments', '1', attributes: {'text': 'First comment!'}, - toOne: {'author': Identifier(bob.type, bob.id)}); - final comment2 = Resource('comments', '2', + toOne: {'author': d.Identifier(bob.type, bob.id)}); + final comment2 = d.Resource('comments', '2', attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': Identifier(alice.type, alice.id)}); - final post = Resource('posts', '1', attributes: { + toOne: {'author': d.Identifier(alice.type, alice.id)}); + final post = d.Resource('posts', '1', attributes: { 'title': 'Hello World' }, toOne: { - 'author': Identifier(alice.type, alice.id) + 'author': d.Identifier(alice.type, alice.id) }, toMany: { - 'comments': [Identifier(comment1.type, comment1.id), Identifier(comment2.type, comment2.id)], + 'comments': [ + d.Identifier(comment1.type, comment1.id), + d.Identifier(comment2.type, comment2.id) + ], 'tags': [] }); @@ -60,8 +62,7 @@ void main() async { }); test('included == [] when requested but nothing to include', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['tags'])); + final r = await client.fetchResource('posts', '1', include: ['tags']); expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().included, []); expect(r.decodeDocument().isCompound, isTrue); @@ -70,8 +71,7 @@ void main() async { }); test('can include first-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments'])); + final r = await client.fetchResource('posts', '1', include: ['comments']); expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); @@ -80,8 +80,8 @@ void main() async { }); test('can include second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author'])); + final r = await client + .fetchResource('posts', '1', include: ['comments.author']); expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); @@ -90,18 +90,17 @@ void main() async { }); test('can include third-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author.birthplace'])); + final r = await client + .fetchResource('posts', '1', include: ['comments.author.birthplace']); expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 1); - expectSameJson( - r.decodeDocument().included.first.unwrap(), wonderland); + expectSameJson(r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { final r = await client.fetchResource('posts', '1', - parameters: Include(['comments', 'comments.author'])); + include: ['comments', 'comments.author']); expectSameJson(r.decodeDocument().data.unwrap(), post); expect(r.decodeDocument().included.length, 4); expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); @@ -121,16 +120,14 @@ void main() async { test('document is compound when requested but nothing to include', () async { - final r = - await client.fetchCollection('posts', parameters: Include(['tags'])); + final r = await client.fetchCollection('posts', include: ['tags']); expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().included, []); expect(r.decodeDocument().isCompound, isTrue); }); test('can include first-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments'])); + final r = await client.fetchCollection('posts', include: ['comments']); expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 2); @@ -139,8 +136,8 @@ void main() async { }); test('can include second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author'])); + final r = + await client.fetchCollection('posts', include: ['comments.author']); expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().included.length, 2); expectSameJson(r.decodeDocument().included.first.unwrap(), bob); @@ -149,18 +146,17 @@ void main() async { }); test('can include third-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author.birthplace'])); + final r = await client + .fetchCollection('posts', include: ['comments.author.birthplace']); expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 1); - expectSameJson( - r.decodeDocument().included.first.unwrap(), wonderland); + expectSameJson(r.decodeDocument().included.first.unwrap(), wonderland); }); test('can include first- and second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments', 'comments.author'])); + final r = await client + .fetchCollection('posts', include: ['comments', 'comments.author']); expectSameJson(r.decodeDocument().data.unwrap().first, post); expect(r.decodeDocument().isCompound, isTrue); expect(r.decodeDocument().included.length, 4); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 5a45c4e6..0acb9a97 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:test/test.dart'; @@ -13,7 +12,7 @@ void main() async { final base = Uri(scheme: 'http', host: host, port: port); final urls = StandardRouting(base); - group('Server-genrated ID', () { + group('Server-generated ID', () { test('201 Created', () async { final repository = InMemoryRepository({ 'people': {}, @@ -21,18 +20,17 @@ void main() async { final server = JsonApiServer(RepositoryController(repository)); final client = JsonApiClient(server, urls); - final person = - NewResource('people', attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); + final r = await client + .createNewResource('people', attributes: {'name': 'Martin Fowler'}); expect(r.http.statusCode, 201); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.http.headers['location'], isNotNull); expect(r.http.headers['location'], r.decodeDocument().data.links['self'].uri.toString()); final created = r.decodeDocument().data.unwrap(); - expect(created.type, person.type); + expect(created.type, 'people'); expect(created.id, isNotNull); - expect(created.attributes, equals(person.attributes)); + expect(created.attributes, equals({'name': 'Martin Fowler'})); final r1 = await client.send( Request.fetchResource(), Uri.parse(r.http.headers['location'])); expect(r1.http.statusCode, 200); @@ -42,9 +40,9 @@ void main() async { test('403 when the id can not be generated', () async { final repository = InMemoryRepository({'people': {}}); final server = JsonApiServer(RepositoryController(repository)); - final routingClient = JsonApiClient(server, urls); + final client = JsonApiClient(server, urls); - final r = await routingClient.createResource(Resource('people', null)); + final r = await client.createNewResource('people'); expect(r.http.statusCode, 403); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; @@ -54,7 +52,7 @@ void main() async { }); }); - group('Client-genrated ID', () { + group('Client-generated ID', () { JsonApiClient client; setUp(() async { final repository = InMemoryRepository({ @@ -70,24 +68,24 @@ void main() async { }); test('204 No Content', () async { - final person = - Resource('people', '123', attributes: {'name': 'Martin Fowler'}); - final r = await client.createResource(person); + final r = await client.createResource('people', '123', + attributes: {'name': 'Martin Fowler'}); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); expect(r.http.headers['location'], isNull); - final r1 = await client.fetchResource(person.type, person.id); + final r1 = await client.fetchResource('people', '123'); expect(r1.isSuccessful, isTrue); expect(r1.http.statusCode, 200); - expectSameJson(r1.decodeDocument().data.unwrap(), person); + expectSameJson(r1.decodeDocument().data.unwrap(), + Resource('people', '123', attributes: {'name': 'Martin Fowler'})); }); test('404 when the collection does not exist', () async { - final r = await client.createResource(Resource('unicorns', null)); + final r = await client.createNewResource('unicorns'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -96,13 +94,12 @@ void main() async { }); test('404 when the related resource does not exist (to-one)', () async { - final book = Resource('books', null, - toOne: {'publisher': Identifier('companies', '123')}); - final r = await client.createResource(book); + final r = await client.createNewResource('books', + relationships: {'publisher': One(Identifier('companies', '123'))}); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -111,45 +108,43 @@ void main() async { }); test('404 when the related resource does not exist (to-many)', () async { - final book = Resource('books', null, toMany: { - 'authors': [Identifier('people', '123')] + final r = await client.createNewResource('books', relationships: { + 'authors': Many([Identifier('people', '123')]) }); - final r = await client.createResource(book); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '123' does not exist in 'people'"); }); - - test('409 when the resource type does not match collection', () async { - final r = await client.send( - Request.createResource( - Document(ResourceData.fromResource(Resource('cucumbers', null)))), - urls.collection('fruits')); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 409); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); - }); +// +// test('409 when the resource type does not match collection', () async { +// final r = await client.send( +// Request.createResource( +// Document(ResourceData.fromResource(Resource('cucumbers', null)))), +// urls.collection('fruits')); +// expect(r.isSuccessful, isFalse); +// expect(r.isFailed, isTrue); +// expect(r.http.statusCode, 409); +// expect(r.http.headers['content-type'], Document.contentType); +// expect(r.decodeDocument().data, isNull); +// final error = r.decodeDocument().errors.first; +// expect(error.status, '409'); +// expect(error.title, 'Invalid resource type'); +// expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); +// }); test('409 when the resource with this id already exists', () async { - final apple = Resource('apples', '123'); - await client.createResource(apple); - final r = await client.createResource(apple); + await client.createResource('apples', '123'); + final r = await client.createResource('apples', '123'); expect(r.isSuccessful, isFalse); expect(r.isFailed, isTrue); expect(r.http.statusCode, 409); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '409'); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index e65f6a45..b9efb29f 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -1,23 +1,20 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; Future seedResources(JsonApiClient client) async { - await client.createResource( - Resource('people', '1', attributes: {'name': 'Martin Fowler'})); - await client.createResource( - Resource('people', '2', attributes: {'name': 'Kent Beck'})); - await client.createResource( - Resource('people', '3', attributes: {'name': 'Robert Martin'})); - await client.createResource(Resource('companies', '1', - attributes: {'name': 'Addison-Wesley Professional'})); - await client.createResource( - Resource('companies', '2', attributes: {'name': 'Prentice Hall'})); - await client.createResource(Resource('books', '1', attributes: { + await client + .createResource('people', '1', attributes: {'name': 'Martin Fowler'}); + await client.createResource('people', '2', attributes: {'name': 'Kent Beck'}); + await client + .createResource('people', '3', attributes: {'name': 'Robert Martin'}); + await client.createResource('companies', '1', + attributes: {'name': 'Addison-Wesley Professional'}); + await client + .createResource('companies', '2', attributes: {'name': 'Prentice Hall'}); + await client.createResource('books', '1', attributes: { 'title': 'Refactoring', 'ISBN-10': '0134757599' - }, toOne: { - 'publisher': Identifier('companies', '1') - }, toMany: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] - })); + }, relationships: { + 'publisher': One(Identifier('companies', '1')), + 'authors': Many([Identifier('people', '1'), Identifier('people', '2')]) + }); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 09a24f74..9956b951 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -26,7 +25,7 @@ void main() async { await seedResources(client); }); - group('Updatng a to-one relationship', () { + group('Updating a to-one relationship', () { test('204 No Content', () async { final r = await client.replaceToOne( 'books', '1', 'publisher', Identifier('companies', '2')); @@ -47,7 +46,7 @@ void main() async { 'unicorns', '1', 'breed', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -60,7 +59,7 @@ void main() async { 'books', '42', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -84,7 +83,7 @@ void main() async { final r = await client.deleteToOne('unicorns', '1', 'breed'); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -96,7 +95,7 @@ void main() async { final r = await client.deleteToOne('books', '42', 'publisher'); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -126,7 +125,7 @@ void main() async { 'unicorns', '1', 'breed', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -139,7 +138,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '2')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -154,7 +153,7 @@ void main() async { .addToMany('books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.linkage.length, 3); expect(r.decodeDocument().data.linkage.first.id, '1'); expect(r.decodeDocument().data.linkage.last.id, '3'); @@ -168,14 +167,14 @@ void main() async { .addToMany('books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.linkage.length, 2); expect(r.decodeDocument().data.linkage.first.id, '1'); expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.decodeDocument().data.unwrap().many('authors').length, 2); - expect(r1.http.headers['content-type'], Document.contentType); + expect(r1.http.headers['content-type'], ContentType.jsonApi); }); test('404 when collection not found', () async { @@ -183,7 +182,7 @@ void main() async { .addToMany('unicorns', '1', 'breed', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -196,7 +195,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -209,7 +208,7 @@ void main() async { .addToMany('books', '1', 'sellers', [Identifier('companies', '3')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -225,7 +224,7 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.linkage.length, 1); expect(r.decodeDocument().data.linkage.first.id, '2'); @@ -238,7 +237,7 @@ void main() async { 'books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.linkage.length, 2); expect(r.decodeDocument().data.linkage.first.id, '1'); expect(r.decodeDocument().data.linkage.last.id, '2'); @@ -252,7 +251,7 @@ void main() async { 'unicorns', '1', 'breed', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); @@ -265,7 +264,7 @@ void main() async { 'books', '42', 'publisher', [Identifier('companies', '1')]); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 95318d28..f0d9c9f6 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -28,18 +27,17 @@ void main() async { }); test('200 OK', () async { - final r = await client.updateResource(Resource('books', '1', attributes: { + final r = await client.updateResource('books', '1', attributes: { 'title': 'Refactoring. Improving the Design of Existing Code', 'pages': 448 - }, toOne: { - 'publisher': null - }, toMany: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] - })); + }, relationships: { + 'publisher': One.empty(), + 'authors': Many([Identifier('people', '1')]), + 'reviewers': Many([Identifier('people', '2')]) + }); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring. Improving the Design of Existing Code'); expect(r.decodeDocument().data.unwrap().attributes['pages'], 448); @@ -58,35 +56,35 @@ void main() async { }); test('204 No Content', () async { - final r = await client.updateResource(Resource('books', '1')); + final r = await client.updateResource('books', '1'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); }); test('404 on the target resource', () async { - final r = await client.updateResource(Resource('books', '42')); + final r = await client.updateResource('books', '42'); expect(r.isSuccessful, isFalse); expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data, isNull); final error = r.decodeDocument().errors.first; expect(error.status, '404'); expect(error.title, 'Resource not found'); expect(error.detail, "Resource '42' does not exist in 'books'"); }); - - test('409 when the resource type does not match the collection', () async { - final r = await client.send( - Request.updateResource( - Document(ResourceData.fromResource(Resource('books', '1')))), - urls.resource('people', '1')); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 409); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'books' does not belong in 'people'"); - }); +// +// test('409 when the resource type does not match the collection', () async { +// final r = await client.send( +// Request.updateResource( +// Document(ResourceData.fromResource(Resource('books', '1')))), +// urls.resource('people', '1')); +// expect(r.isSuccessful, isFalse); +// expect(r.http.statusCode, 409); +// expect(r.http.headers['content-type'], ContentType.jsonApi); +// expect(r.decodeDocument().data, isNull); +// final error = r.decodeDocument().errors.first; +// expect(error.status, '409'); +// expect(error.title, 'Invalid resource type'); +// expect(error.detail, "Type 'books' does not belong in 'people'"); +// }); } diff --git a/test/helper/expect_same_json.dart b/test/helper/expect_same_json.dart index 50be610c..4476e2a3 100644 --- a/test/helper/expect_same_json.dart +++ b/test/helper/expect_same_json.dart @@ -3,5 +3,5 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; -void expectSameJson(Resource a, Resource b) => +void expectSameJson(Object a, Object b) => expect(jsonEncode(a), jsonEncode(b)); diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart index ec9a2ac4..98dd9899 100644 --- a/test/unit/client/async_processing_test.dart +++ b/test/unit/client/async_processing_test.dart @@ -1,5 +1,5 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; +import 'package:json_api/document.dart' as d; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/response_factory.dart'; import 'package:test/test.dart'; @@ -13,9 +13,9 @@ void main() { final responseFactory = HttpResponseFactory(routing); test('Client understands async responses', () async { - handler.response = responseFactory.accepted(Resource('jobs', '42')); + handler.response = responseFactory.accepted(d.Resource('jobs', '42')); - final r = await client.createResource(Resource('books', '1')); + final r = await client.createResource('books', '1'); expect(r.isAsync, true); expect(r.isSuccessful, false); expect(r.isFailed, false); From 652c8db14577a6368edf32220ad9bfa879ab7827 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 9 May 2020 17:32:24 -0700 Subject: [PATCH 65/99] Client throws exceptions --- example/client.dart | 4 +- lib/src/client/document.dart | 245 ++++++++++++--- lib/src/client/json_api_client.dart | 74 +++-- lib/src/client/response.dart | 60 +++- lib/src/document/error_object.dart | 53 +++- lib/src/maybe.dart | 95 ++++++ lib/src/server/repository_controller.dart | 15 +- test/e2e/browser_test.dart | 2 +- test/e2e/client_server_interaction_test.dart | 2 +- .../crud/creating_resources_test.dart | 105 ++++--- .../crud/deleting_resources_test.dart | 52 ++-- .../crud/fetching_relationships_test.dart | 176 ++++++----- .../crud/fetching_resources_test.dart | 169 ++++++----- test/functional/crud/seed_resources.dart | 7 +- .../crud/updating_relationships_test.dart | 285 +++++++++--------- .../crud/updating_resources_test.dart | 19 +- test/helper/expect_same_json.dart | 5 +- 17 files changed, 910 insertions(+), 458 deletions(-) create mode 100644 lib/src/maybe.dart diff --git a/example/client.dart b/example/client.dart index a6de2d91..8ac131c1 100644 --- a/example/client.dart +++ b/example/client.dart @@ -29,8 +29,8 @@ void main() async { /// Create the second resource. await client.createResource('books', '2', attributes: { 'title': 'Refactoring' - }, relationships: { - 'authors': Many([Identifier('writers', '1')]) + }, many: { + 'authors': [Identifier('writers', '1')] }); /// Fetch the book, including its authors. diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart index 25f409b3..0ec5eed7 100644 --- a/lib/src/client/document.dart +++ b/lib/src/client/document.dart @@ -1,4 +1,7 @@ import 'dart:collection'; + +import 'package:json_api/src/nullable.dart'; + class ContentType { static const jsonApi = 'application/vnd.api+json'; } @@ -19,7 +22,7 @@ class ResourceDocument implements RequestDocument { }) : meta = Map.unmodifiable(meta ?? {}), api = api ?? Api(); - final GenericResource resource; + final MinimalResource resource; @override final Api api; @override @@ -49,17 +52,12 @@ class RelationshipDocument implements RequestDocument { {...relationship.toJson(), if (api.isNotEmpty) 'jsonapi': api.toJson()}; } -abstract class GenericResource { - Map toJson(); -} - -class NewResource implements GenericResource { - NewResource( - this.type, { - Map attributes, - Map meta, - Map relationships, - }) : attributes = Map.unmodifiable(attributes ?? {}), +abstract class MinimalResource { + MinimalResource(this.type, + {Map attributes, + Map meta, + Map relationships}) + : attributes = Map.unmodifiable(attributes ?? {}), meta = Map.unmodifiable(meta ?? {}), relationships = Map.unmodifiable(relationships ?? {}); @@ -68,7 +66,6 @@ class NewResource implements GenericResource { final Map attributes; final Map relationships; - @override Map toJson() => { 'type': type, if (attributes.isNotEmpty) 'attributes': attributes, @@ -77,37 +74,44 @@ class NewResource implements GenericResource { }; } -class Resource implements GenericResource { - Resource( - this.type, - this.id, { - Map attributes, - Map meta, - Map relationships, - }) : attributes = Map.unmodifiable(attributes ?? {}), - meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}); +class NewResource extends MinimalResource { + NewResource(String type, + {Map attributes, + Map meta, + Map relationships}) + : super(type, + attributes: attributes, relationships: relationships, meta: meta); +} - final String type; +class Resource extends MinimalResource with Identity { + Resource(String type, this.id, + {Map attributes, + Map meta, + Map relationships}) + : super(type, + attributes: attributes, relationships: relationships, meta: meta); + + @override final String id; - final Map meta; - final Map attributes; - final Map relationships; @override - Map toJson() => { - 'type': type, - 'id': id, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (meta.isNotEmpty) 'meta': meta, - }; + Map toJson() => super.toJson()..['id'] = id; } abstract class Relationship implements Iterable { Map toJson(); Map get meta; + +// static Relationship fromJson(Object json) { +// if (json is Map) { +// final data = json['data']; +// if (data is List) +// +// } +// +// throw ArgumentError('Can not parse Relationship'); +// } } class One with IterableMixin implements Relationship { @@ -119,6 +123,10 @@ class One with IterableMixin implements Relationship { : meta = Map.unmodifiable(meta ?? {}), _ids = const []; + One.fromNullable(Identifier identifier, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + _ids = identifier == null ? const [] : [identifier]; + @override final Map meta; final List _ids; @@ -152,15 +160,24 @@ class Many with IterableMixin implements Relationship { }; } -class Identifier { +class Identifier with Identity { Identifier(this.type, this.id, {Map meta}) : meta = Map.unmodifiable(meta ?? {}) { ArgumentError.checkNotNull(type, 'type'); ArgumentError.checkNotNull(id, 'id'); } + static Identifier fromJson(Object json) { + if (json is Map) { + return Identifier(json['type'], json['id'], meta: json['meta']); + } + throw ArgumentError('A JSON:API identifier must be a JSON object'); + } + + @override final String type; + @override final String id; final Map meta; @@ -193,3 +210,161 @@ class Api { if (meta.isNotEmpty) 'meta': meta, }; } + +mixin Identity { + String get type; + + String get id; + + String get key => '$type:$id'; +} + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + String id, + String status, + String code, + String title, + String detail, + Map meta, + ErrorSource source, + Map links, + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + source = source ?? ErrorSource(), + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); + + static ErrorObject fromJson(Object json) { + if (json is Map) { + return ErrorObject( + id: json['id'], + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + source: nullable(ErrorSource.fromJson)(json['source']) ?? + ErrorSource(), + meta: json['meta'], + links: nullable(Link.mapFromJson)(json['links'])) ?? + {}; + } + throw ArgumentError('A JSON:API error must be a JSON object'); + } + + /// A unique identifier for this particular occurrence of the problem. + /// May be empty. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + /// May be empty. + final String status; + + /// An application-specific error code, expressed as a string value. + /// May be empty. + 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. + /// May be empty. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + /// May be empty. + final String detail; + + /// The `source` object. + final ErrorSource source; + + final Map meta; + final Map links; + + Map toJson() { + return { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (meta.isNotEmpty) 'meta': meta, + if (links.isNotEmpty) 'links': links, + if (source.isNotEmpty) 'source': source, + }; + } +} + +/// An object containing references to the source of the error, optionally including any of the following members: +/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, +/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. +/// - parameter: a string indicating which URI query parameter caused the error. +class ErrorSource { + ErrorSource({String pointer, String parameter}) + : pointer = pointer ?? '', + parameter = parameter ?? ''; + + static ErrorSource fromJson(Object json) { + if (json is Map) { + return ErrorSource( + pointer: json['pointer'], parameter: json['parameter']); + } + throw ArgumentError('Can not parse ErrorSource'); + } + + final String pointer; + + final String parameter; + + bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + + Map toJson() => { + if (pointer.isNotEmpty) 'pointer': pointer, + if (parameter.isNotEmpty) 'parameter': parameter + }; +} + +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { + ArgumentError.checkNotNull(uri, 'uri'); + } + + final Uri uri; + final Map meta; + + /// Reconstructs the link from the [json] object + static Link fromJson(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']), meta: json['meta']); + } + throw ArgumentError( + 'A JSON:API link must be a JSON string or a JSON object'); + } + + /// Reconstructs the document's `links` member into a map. + /// Details on the `links` member: https://jsonapi.org/format/#document-links + static Map mapFromJson(Object json) { + if (json is Map) { + return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); + } + throw ArgumentError('A JSON:API links object must be a JSON object'); + } + + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + + @override + String toString() => uri.toString(); +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 63ac1316..ff014d58 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import 'package:json_api/client.dart'; import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/client/document.dart'; +import 'package:json_api/src/maybe.dart'; /// The JSON:API client class JsonApiClient { @@ -71,30 +74,34 @@ class JsonApiClient { _uri.relationship(type, id, relationship), headers: headers); - /// Creates the [resource] on the server. The server is expected to assign the resource id. + /// Creates a new [resource] on the server. + /// The server is expected to assign the resource id. Future> createNewResource(String type, {Map attributes = const {}, Map meta = const {}, - Map relationships = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( Request.createResource(ResourceDocument(NewResource(type, attributes: attributes, - relationships: relationships, + relationships: _rel(one: one, many: many), meta: meta))), _uri.collection(type), headers: headers); - /// Creates the [resource] on the server. + /// Creates a new [resource] on the server. + /// The server is expected to accept the provided resource id. Future> createResource(String type, String id, {Map attributes = const {}, Map meta = const {}, - Map relationships = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( Request.createResource(ResourceDocument(Resource(type, id, attributes: attributes, - relationships: relationships, + relationships: _rel(one: one, many: many), meta: meta))), _uri.collection(type), headers: headers); @@ -119,7 +126,7 @@ class JsonApiClient { headers: headers); /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( + Future> replaceOne( String type, String id, String relationship, Identifier identifier, {Map headers = const {}}) => send(Request.replaceToOne(RelationshipDocument(One(identifier))), @@ -127,7 +134,7 @@ class JsonApiClient { headers: headers); /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( + Future> deleteOne( String type, String id, String relationship, {Map headers = const {}}) => send(Request.replaceToOne(RelationshipDocument(One.empty())), @@ -135,7 +142,7 @@ class JsonApiClient { headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, + Future> deleteMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.deleteFromToMany(RelationshipDocument(Many(identifiers))), @@ -143,7 +150,7 @@ class JsonApiClient { headers: headers); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, + Future> replaceMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.replaceToMany(RelationshipDocument(Many(identifiers))), @@ -151,7 +158,7 @@ class JsonApiClient { headers: headers); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToMany(String type, String id, + Future> addMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.addToMany(RelationshipDocument(Many(identifiers))), @@ -161,10 +168,43 @@ class JsonApiClient { /// Sends the request to the [uri] via [handler] and returns the response. /// Extra [headers] may be added to the request. Future> send(Request request, Uri uri, - {Map headers = const {}}) async => - Response( - await _http.call(HttpRequest( - request.method, request.parameters.addToUri(uri), - body: request.body, headers: {...?headers, ...request.headers})), - request.decoder); + {Map headers = const {}}) async { + final response = await _call(request, uri, headers); + if (StatusCode(response.statusCode).isFailed) { + throw RequestFailure.decode(response); + } + return Response(response, request.decoder); + } + + Map _rel( + {Map one = const {}, + Map> many = const {}}) => + one.map((key, value) => MapEntry(key, One.fromNullable(value))) + ..addAll(many.map((key, value) => MapEntry(key, Many(value)))); + + Future _call( + Request request, Uri uri, Map headers) => + _http.call(_toHttp(request, uri, headers)); + + HttpRequest _toHttp(Request request, Uri uri, Map headers) => + HttpRequest(request.method, request.parameters.addToUri(uri), + body: request.body, headers: {...?headers, ...request.headers}); +} + +class RequestFailure { + RequestFailure(this.http, {Iterable errors = const []}) + : errors = List.unmodifiable(errors ?? const []); + final List errors; + + static RequestFailure decode(HttpResponse http) => Maybe(http.body) + .where((_) => _.isNotEmpty) + .map(jsonDecode) + .whereType() + .map((_) => _['errors']) + .whereType() + .map((_) => _.map(ErrorObject.fromJson)) + .map((_) => RequestFailure(http, errors: _)) + .or(() => RequestFailure(http)); + + final HttpResponse http; } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 3236feea..a5705e92 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,14 +1,60 @@ +import 'dart:collection'; import 'dart:convert'; -import 'package:json_api/document.dart'; +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/src/client/status_code.dart'; +// +// +//class ResourceCollection with IterableMixin { +// ResourceCollection(Iterable resources) { +// resources.forEach((element) => _map[element.key] = element); +// } +// +// ResourceCollection.empty() : this([]); +// +// final _map = {}; +// +// @override +// Iterator get iterator => _map.values.iterator; +// +// Resource getByKey(String key, {Resource Function() orElse}) { +// if (_map.containsKey(key)) return _map[key]; +// if (orElse != null) return orElse(); +// throw StateError('No element'); +// } +//} +// +//class FetchCollectionResponse with IterableMixin { +// FetchCollectionResponse(this.http, this.resources, +// {ResourceCollection included}) +// : included = included ?? ResourceCollection.empty(); +// +// static FetchCollectionResponse fromHttp(HttpResponse http) { +// final json = jsonDecode(http.body); +// if (json is Map) { +// final resources = json['data']; +// if (resources is List) +// } +// +// } +// +// /// The HTTP response. +// final HttpResponse http; +// final ResourceCollection included; +// final ResourceCollection resources; +// +// @override +// Iterator get iterator => resources.iterator; +//} + /// A JSON:API response -class Response { +class Response { Response(this.http, this._decoder); - final PrimaryDataDecoder _decoder; + final d.PrimaryDataDecoder _decoder; /// The HTTP response. final HttpResponse http; @@ -17,18 +63,18 @@ class Response { /// Throws a [StateError] if the HTTP response contains empty body. /// Throws a [DocumentException] if the received document structure is invalid. /// Throws a [FormatException] if the received JSON is invalid. - Document decodeDocument() { + d.Document decodeDocument() { if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); - return Document.fromJson(jsonDecode(http.body), _decoder); + return d.Document.fromJson(jsonDecode(http.body), _decoder); } /// Returns the async Document parsed from the response body. /// Throws a [StateError] if the HTTP response contains empty body. /// Throws a [DocumentException] if the received document structure is invalid. /// Throws a [FormatException] if the received JSON is invalid. - Document decodeAsyncDocument() { + d.Document decodeAsyncDocument() { if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); - return Document.fromJson(jsonDecode(http.body), ResourceData.fromJson); + return d.Document.fromJson(jsonDecode(http.body), d.ResourceData.fromJson); } /// Was the query successful? diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 709fc0b8..4ff58a7e 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,14 +1,12 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/document_exception.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/links.dart'; -import 'package:json_api/src/document/meta.dart'; import 'package:json_api/src/nullable.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject with Meta, Links { +class ErrorObject { /// Creates an instance of a JSON:API Error. /// The [links] map may contain custom links. The about link /// passed through the [links['about']] argument takes precedence and will overwrite @@ -20,17 +18,16 @@ class ErrorObject with Meta, Links { String title, String detail, Map meta, - Map source, + ErrorSource source, Map links, }) : id = id ?? '', status = status ?? '', code = code ?? '', title = title ?? '', detail = detail ?? '', - source = Map.unmodifiable(source ?? const {}) { - this.meta.addAll(meta ?? {}); - this.links.addAll(links ?? {}); - } + source = source ?? ErrorSource(), + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); static ErrorObject fromJson(Object json) { if (json is Map) { @@ -40,7 +37,7 @@ class ErrorObject with Meta, Links { code: json['code'], title: json['title'], detail: json['detail'], - source: json['source'], + source: nullable(ErrorSource.fromJson)(json['source']), meta: json['meta'], links: nullable(Link.mapFromJson)(json['links'])); } @@ -70,11 +67,10 @@ class ErrorObject with Meta, Links { final String detail; /// The `source` object. - /// An object containing references to the source of the error, optionally including any of the following members: - /// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, - /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. - /// - parameter: a string indicating which URI query parameter caused the error. - final Map source; + final ErrorSource source; + + final Map meta; + final Map links; Map toJson() { return { @@ -89,3 +85,32 @@ class ErrorObject with Meta, Links { }; } } + +/// An object containing references to the source of the error, optionally including any of the following members: +/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, +/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. +/// - parameter: a string indicating which URI query parameter caused the error. +class ErrorSource { + ErrorSource({String pointer, String parameter}) + : pointer = pointer ?? '', + parameter = parameter ?? ''; + + static ErrorSource fromJson(Object json) { + if (json is Map) { + return ErrorSource( + pointer: json['pointer'], parameter: json['parameter']); + } + throw DocumentException('Can not parse ErrorSource'); + } + + final String pointer; + + final String parameter; + + bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + + Map toJson() => { + if (pointer.isNotEmpty) 'pointer': pointer, + if (parameter.isNotEmpty) 'parameter': parameter + }; +} diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart new file mode 100644 index 00000000..4f77689c --- /dev/null +++ b/lib/src/maybe.dart @@ -0,0 +1,95 @@ +/// A variation of the Maybe monad with eager execution. +abstract class Maybe { + factory Maybe(T t) => t == null ? Nothing() : Just(t); + + Maybe

map

(P Function(T t) f); + + Maybe

whereType

(); + + Maybe where(bool Function(T t) f); + + T or(T Function() f); + + void ifPresent(Function(T t) f); + + Maybe recover(T Function(E _) f); +} + +class Just implements Maybe { + Just(this.value) { + ArgumentError.checkNotNull(value); + } + + final T value; + + @override + Maybe

map

(P Function(T t) f) => Maybe(f(value)); + + @override + T or(T Function() f) => value; + + @override + void ifPresent(Function(T t) f) => f(value); + + @override + Maybe where(bool Function(T t) f) { + try { + return f(value) ? this : const Nothing(); + } catch (e) { + return Failure(e); + } + } + + @override + Maybe

whereType

() => value is P ? Just(value as P) : const Nothing(); + + @override + Maybe recover(T Function(E _) f) => this; +} + +class Nothing implements Maybe { + const Nothing(); + + @override + Maybe

map

(P Function(T t) map) => const Nothing(); + + @override + T or(T Function() f) => f(); + + @override + void ifPresent(Function(T t) f) {} + + @override + Maybe where(bool Function(T t) f) => this; + + @override + Maybe

whereType

() => const Nothing(); + + @override + Maybe recover(T Function(E _) f) => this; +} + +class Failure implements Maybe { + const Failure(this.exception); + + final Object exception; + + @override + void ifPresent(Function(T t) f) {} + + @override + Maybe

map

(P Function(T t) f) => this as Failure

; + + @override + T or(T Function() f) => f(); + + @override + Maybe where(bool Function(T t) f) => this; + + @override + Maybe

whereType

() => this as Failure

; + + @override + Maybe recover(T Function(E _) f) => + exception is E ? Maybe(f(exception as E)) : this; +} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart index c31408d0..e33f53ae 100644 --- a/lib/src/server/repository_controller.dart +++ b/lib/src/server/repository_controller.dart @@ -55,9 +55,18 @@ class RepositoryController implements Controller { _do(() async { final resource = await _repo.get(request.target.type, request.target.id); - resource.many(request.target.relationship).remove(identifiers); - return ToManyResponse( - request, resource.many(request.target.relationship).toList()); + if (resource.hasMany(request.target.relationship)) { + resource.many(request.target.relationship).remove(identifiers); + return ToManyResponse( + request, resource.many(request.target.relationship).toList()); + } + return ErrorResponse(404, [ + ErrorObject( + status: '404', + title: 'Relationship not found', + detail: + "There is no to-many relationship '${request.target.relationship}' in this resource") + ]); }); @override diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index e1f3d903..c8c37a95 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -30,7 +30,7 @@ void main() async { await client .updateResource('books', '2', relationships: {'authors': Many([])}); await client - .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); + .addMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index f7b75615..0fc55250 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -41,7 +41,7 @@ void main() { await client .updateResource('books', '2', relationships: {'authors': Many([])}); await client - .addToMany('books', '2', 'authors', [Identifier('writers', '1')]); + .addMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 0acb9a97..9d489ccc 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -42,13 +42,15 @@ void main() async { final server = JsonApiServer(RepositoryController(repository)); final client = JsonApiClient(server, urls); - final r = await client.createNewResource('people'); - expect(r.http.statusCode, 403); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '403'); - expect(error.title, 'Unsupported operation'); - expect(error.detail, 'Id generation is not supported'); + try { + await client.createNewResource('people'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 403); + expect(e.errors.first.status, '403'); + expect(e.errors.first.title, 'Unsupported operation'); + expect(e.errors.first.detail, 'Id generation is not supported'); + } }); }); @@ -81,45 +83,47 @@ void main() async { }); test('404 when the collection does not exist', () async { - final r = await client.createNewResource('unicorns'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + try { + await client.createNewResource('unicorns'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 when the related resource does not exist (to-one)', () async { - final r = await client.createNewResource('books', - relationships: {'publisher': One(Identifier('companies', '123'))}); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'companies'"); + try { + await client.createNewResource('books', + one: {'publisher': Identifier('companies', '123')}); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect(e.errors.first.detail, + "Resource '123' does not exist in 'companies'"); + } }); test('404 when the related resource does not exist (to-many)', () async { - final r = await client.createNewResource('books', relationships: { - 'authors': Many([Identifier('people', '123')]) - }); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'people'"); + try { + await client.createNewResource('books', many: { + 'authors': [Identifier('people', '123')] + }); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect(e.errors.first.detail, + "Resource '123' does not exist in 'people'"); + } }); // // test('409 when the resource type does not match collection', () async { @@ -140,16 +144,17 @@ void main() async { test('409 when the resource with this id already exists', () async { await client.createResource('apples', '123'); - final r = await client.createResource('apples', '123'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 409); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '409'); - expect(error.title, 'Resource exists'); - expect(error.detail, 'Resource with this type and id already exists'); + try { + await client.createResource('apples', '123'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 409); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '409'); + expect(e.errors.first.title, 'Resource exists'); + expect(e.errors.first.detail, + 'Resource with this type and id already exists'); + } }); }); } diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 3da09c01..79de5f22 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -30,36 +30,38 @@ void main() async { final r = await client.deleteResource('books', '1'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.isSuccessful, isFalse); - expect(r1.http.statusCode, 404); - expect(r1.http.headers['content-type'], Document.contentType); + try { + await client.fetchResource('books', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + } }); test('404 on collection', () async { - final r = await client.deleteResource('unicorns', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + try { + await client.deleteResource('unicorns', '42'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.deleteResource('books', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + try { + await client.deleteResource('books', '42'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect(e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); } diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 31dc8907..75b67e17 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -33,43 +33,51 @@ void main() async { expect(r.http.headers['content-type'], Document.contentType); expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/publisher'); - expect( - r.decodeDocument().data.links['related'].uri.toString(), '/books/1/publisher'); + expect(r.decodeDocument().data.links['related'].uri.toString(), + '/books/1/publisher'); expect(r.decodeDocument().data.linkage.type, 'companies'); expect(r.decodeDocument().data.linkage.id, '1'); }); test('404 on collection', () async { - final r = await client.fetchToOne('unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchToOne('unicorns', '1', 'publisher'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'books'"); + try { + await client.fetchToOne('books', '42', 'publisher'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); test('404 on relationship', () async { - final r = await client.fetchToOne('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Relationship not found'); - expect(r.decodeDocument().errors.first.detail, - "Relationship 'owner' does not exist in this resource"); + try { + await client.fetchToOne('books', '1', 'owner'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "Relationship 'owner' does not exist in this resource"); + } }); }); @@ -83,41 +91,49 @@ void main() async { expect(r.decodeDocument().data.linkage.first.type, 'people'); expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/authors'); - expect( - r.decodeDocument().data.links['related'].uri.toString(), '/books/1/authors'); + expect(r.decodeDocument().data.links['related'].uri.toString(), + '/books/1/authors'); }); test('404 on collection', () async { - final r = await client.fetchToMany('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchToMany('unicorns', '1', 'corns'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchToMany('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'books'"); + try { + await client.fetchToMany('books', '42', 'authors'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); test('404 on relationship', () async { - final r = await client.fetchToMany('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Relationship not found'); - expect(r.decodeDocument().errors.first.detail, - "Relationship 'readers' does not exist in this resource"); + try { + await client.fetchToMany('books', '1', 'readers'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "Relationship 'readers' does not exist in this resource"); + } }); }); @@ -154,36 +170,44 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelationship('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchRelationship('unicorns', '1', 'corns'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchRelationship('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'books'"); + try { + await client.fetchRelationship('books', '42', 'authors'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); test('404 on relationship', () async { - final r = await client.fetchRelationship('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Relationship not found'); - expect(r.decodeDocument().errors.first.detail, - "Relationship 'readers' does not exist in this resource"); + try { + await client.fetchRelationship('books', '1', 'readers'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "Relationship 'readers' does not exist in this resource"); + } }); }); } diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index fa667683..a646ff4d 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -52,25 +52,30 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchResource('unicorns', '1'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchResource('unicorns', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchResource('people', '42'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'people'"); + try { + await client.fetchResource('people', '42'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'people'"); + } }); }); @@ -93,15 +98,17 @@ void main() async { 'Robert Martin'); }); - test('404', () async { - final r = await client.fetchCollection('unicorns'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + test('404 on collection', () async { + try { + await client.fetchCollection('unicorns'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); }); @@ -121,36 +128,44 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelatedResource('unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchRelatedResource('unicorns', '1', 'publisher'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchRelatedResource('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'books'"); + try { + await client.fetchRelatedResource('books', '42', 'publisher'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); test('404 on relationship', () async { - final r = await client.fetchRelatedResource('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Relationship not found'); - expect(r.decodeDocument().errors.first.detail, - "Relationship 'owner' does not exist in this resource"); + try { + await client.fetchRelatedResource('books', '1', 'owner'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "Relationship 'owner' does not exist in this resource"); + } }); }); @@ -175,36 +190,44 @@ void main() async { }); test('404 on collection', () async { - final r = await client.fetchRelatedCollection('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Collection not found'); - expect(r.decodeDocument().errors.first.detail, - "Collection 'unicorns' does not exist"); + try { + await client.fetchRelatedCollection('unicorns', '1', 'corns'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.fetchRelatedCollection('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Resource not found'); - expect(r.decodeDocument().errors.first.detail, - "Resource '42' does not exist in 'books'"); + try { + await client.fetchRelatedCollection('books', '42', 'authors'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); test('404 on relationship', () async { - final r = await client.fetchRelatedCollection('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], Document.contentType); - expect(r.decodeDocument().errors.first.status, '404'); - expect(r.decodeDocument().errors.first.title, 'Relationship not found'); - expect(r.decodeDocument().errors.first.detail, - "Relationship 'readers' does not exist in this resource"); + try { + await client.fetchRelatedCollection('books', '1', 'owner'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], Document.contentType); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "Relationship 'owner' does not exist in this resource"); + } }); }); } diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index b9efb29f..9955aff5 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -13,8 +13,9 @@ Future seedResources(JsonApiClient client) async { await client.createResource('books', '1', attributes: { 'title': 'Refactoring', 'ISBN-10': '0134757599' - }, relationships: { - 'publisher': One(Identifier('companies', '1')), - 'authors': Many([Identifier('people', '1'), Identifier('people', '2')]) + }, one: { + 'publisher': Identifier('companies', '1'), + }, many: { + 'authors': [Identifier('people', '1'), Identifier('people', '2')] }); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 9956b951..4169b3b5 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -27,7 +27,7 @@ void main() async { group('Updating a to-one relationship', () { test('204 No Content', () async { - final r = await client.replaceToOne( + final r = await client.replaceOne( 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); @@ -42,115 +42,120 @@ void main() async { }); test('404 on collection', () async { - final r = await client.replaceToOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + try { + await client.replaceOne( + 'unicorns', '1', 'breed', Identifier('companies', '2')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.replaceToOne( - 'books', '42', 'publisher', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + try { + await client.replaceOne( + 'books', '42', 'publisher', Identifier('companies', '2')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); }); group('Deleting a to-one relationship', () { test('204 No Content', () async { - final r = await client.deleteToOne('books', '1', 'publisher'); + final r = await client.deleteOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect( - r1.decodeDocument().data.unwrap().one('publisher').isEmpty, true); + expect(r1.decodeDocument().data.unwrap().one('publisher').isEmpty, true); }); test('404 on collection', () async { - final r = await client.deleteToOne('unicorns', '1', 'breed'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + try { + await client.deleteOne('unicorns', '1', 'breed'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); test('404 on resource', () async { - final r = await client.deleteToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + try { + await client.deleteOne('books', '42', 'publisher'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); }); group('Replacing a to-many relationship', () { test('204 No Content', () async { final r = await client - .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); + .replaceMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); expect( - r1.decodeDocument().data.unwrap().many('authors').toList().length, - 1); + r1.decodeDocument().data.unwrap().many('authors').toList().length, 1); expect( r1.decodeDocument().data.unwrap().many('authors').toList().first.id, '1'); }); - test('404 when collection not found', () async { - final r = await client.replaceToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + test('404 on collection', () async { + try { + await client.replaceMany('unicorns', '1', 'breed', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); - test('404 when resource not found', () async { - final r = await client.replaceToMany( - 'books', '42', 'publisher', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + test('404 on resource', () async { + try { + await client.replaceMany('books', '42', 'publisher', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); }); group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { final r = await client - .addToMany('books', '1', 'authors', [Identifier('people', '3')]); + .addMany('books', '1', 'authors', [Identifier('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -164,7 +169,7 @@ void main() async { test('successfully adding an existing identifier', () async { final r = await client - .addToMany('books', '1', 'authors', [Identifier('people', '2')]); + .addMany('books', '1', 'authors', [Identifier('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -177,51 +182,52 @@ void main() async { expect(r1.http.headers['content-type'], ContentType.jsonApi); }); - test('404 when collection not found', () async { - final r = await client - .addToMany('unicorns', '1', 'breed', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + test('404 on collection', () async { + try { + await client.addMany('unicorns', '1', 'breed', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); - test('404 when resource not found', () async { - final r = await client.addToMany( - 'books', '42', 'publisher', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + test('404 on resource', () async { + try { + await client.addMany('books', '42', 'publisher', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); - test('404 when relationship not found', () async { - final r = await client - .addToMany('books', '1', 'sellers', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Relationship not found'); - expect(error.detail, - "There is no to-many relationship 'sellers' in this resource"); + test('404 on relationship', () async { + try { + await client.addMany('books', '1', 'sellers', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "There is no to-many relationship 'sellers' in this resource"); + } }); }); group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { - final r = await client.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '1')]); + final r = await client + .deleteMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -232,44 +238,45 @@ void main() async { expect(r1.decodeDocument().data.unwrap().many('authors').length, 1); }); - test('successfully deleting a non-present identifier', () async { - final r = await client.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '3')]); - expect(r.isSuccessful, isTrue); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.linkage.length, 2); - expect(r.decodeDocument().data.linkage.first.id, '1'); - expect(r.decodeDocument().data.linkage.last.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().many('authors').length, 2); + test('404 on collection', () async { + try { + await client.deleteMany('unicorns', '1', 'breed', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Collection not found'); + expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); + } }); - test('404 when collection not found', () async { - final r = await client.deleteFromToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); + test('404 on resource', () async { + try { + await client.deleteMany('books', '42', 'publisher', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect( + e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); - test('404 when resource not found', () async { - final r = await client.deleteFromToMany( - 'books', '42', 'publisher', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + test('404 on relationship', () async { + try { + await client.deleteMany('books', '1', 'sellers', []); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Relationship not found'); + expect(e.errors.first.detail, + "There is no to-many relationship 'sellers' in this resource"); + } }); }); } diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index f0d9c9f6..aeeeb8eb 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -62,15 +62,16 @@ void main() async { }); test('404 on the target resource', () async { - final r = await client.updateResource('books', '42'); - expect(r.isSuccessful, isFalse); - expect(r.http.statusCode, 404); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data, isNull); - final error = r.decodeDocument().errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); + try { + await client.updateResource('books', '42'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + expect(e.http.headers['content-type'], ContentType.jsonApi); + expect(e.errors.first.status, '404'); + expect(e.errors.first.title, 'Resource not found'); + expect(e.errors.first.detail, "Resource '42' does not exist in 'books'"); + } }); // // test('409 when the resource type does not match the collection', () async { diff --git a/test/helper/expect_same_json.dart b/test/helper/expect_same_json.dart index 4476e2a3..3efa266f 100644 --- a/test/helper/expect_same_json.dart +++ b/test/helper/expect_same_json.dart @@ -1,7 +1,6 @@ import 'dart:convert'; -import 'package:json_api/document.dart'; import 'package:test/test.dart'; -void expectSameJson(Object a, Object b) => - expect(jsonEncode(a), jsonEncode(b)); +void expectSameJson(Object actual, Object expected) => + expect(jsonDecode(jsonEncode(actual)), jsonDecode(jsonEncode(expected))); From 2ddc7ac0c7bdb26da39cc0a37a64d839159ce172 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 9 May 2020 20:08:07 -0700 Subject: [PATCH 66/99] Request doc moved to request --- lib/client.dart | 2 +- lib/src/client/content_type.dart | 3 + lib/src/client/document.dart | 370 ------------------ lib/src/client/json_api_client.dart | 208 ++++++++-- lib/src/client/request.dart | 191 +++++++-- lib/src/client/response.dart | 45 --- lib/src/maybe.dart | 14 +- test/e2e/browser_test.dart | 2 +- test/e2e/client_server_interaction_test.dart | 2 +- .../crud/creating_resources_test.dart | 11 +- .../crud/deleting_resources_test.dart | 6 +- .../crud/fetching_relationships_test.dart | 26 +- .../crud/fetching_resources_test.dart | 26 +- .../crud/updating_resources_test.dart | 9 +- 14 files changed, 373 insertions(+), 542 deletions(-) create mode 100644 lib/src/client/content_type.dart delete mode 100644 lib/src/client/document.dart diff --git a/lib/client.dart b/lib/client.dart index ea9d9938..e230616e 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -4,6 +4,6 @@ export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/request.dart'; -export 'package:json_api/src/client/document.dart'; +export 'package:json_api/src/client/content_type.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/src/client/content_type.dart b/lib/src/client/content_type.dart new file mode 100644 index 00000000..1e03a7d9 --- /dev/null +++ b/lib/src/client/content_type.dart @@ -0,0 +1,3 @@ +class ContentType { + static const jsonApi = 'application/vnd.api+json'; +} diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart deleted file mode 100644 index 0ec5eed7..00000000 --- a/lib/src/client/document.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/src/nullable.dart'; - -class ContentType { - static const jsonApi = 'application/vnd.api+json'; -} - -abstract class RequestDocument { - Api get api; - - Map get meta; - - Map toJson(); -} - -class ResourceDocument implements RequestDocument { - ResourceDocument( - this.resource, { - Api api, - Map meta, - }) : meta = Map.unmodifiable(meta ?? {}), - api = api ?? Api(); - - final MinimalResource resource; - @override - final Api api; - @override - final Map meta; - - @override - Map toJson() => { - 'data': resource.toJson(), - if (meta.isNotEmpty) 'meta': meta, - if (api.isNotEmpty) 'jsonapi': api.toJson() - }; -} - -class RelationshipDocument implements RequestDocument { - RelationshipDocument(this.relationship, {Api api}) : api = api ?? Api(); - - final Relationship relationship; - - @override - final Api api; - - @override - Map get meta => relationship.meta; - - @override - Map toJson() => - {...relationship.toJson(), if (api.isNotEmpty) 'jsonapi': api.toJson()}; -} - -abstract class MinimalResource { - MinimalResource(this.type, - {Map attributes, - Map meta, - Map relationships}) - : attributes = Map.unmodifiable(attributes ?? {}), - meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}); - - final String type; - final Map meta; - final Map attributes; - final Map relationships; - - Map toJson() => { - 'type': type, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (meta.isNotEmpty) 'meta': meta, - }; -} - -class NewResource extends MinimalResource { - NewResource(String type, - {Map attributes, - Map meta, - Map relationships}) - : super(type, - attributes: attributes, relationships: relationships, meta: meta); -} - -class Resource extends MinimalResource with Identity { - Resource(String type, this.id, - {Map attributes, - Map meta, - Map relationships}) - : super(type, - attributes: attributes, relationships: relationships, meta: meta); - - @override - final String id; - - @override - Map toJson() => super.toJson()..['id'] = id; -} - -abstract class Relationship implements Iterable { - Map toJson(); - - Map get meta; - -// static Relationship fromJson(Object json) { -// if (json is Map) { -// final data = json['data']; -// if (data is List) -// -// } -// -// throw ArgumentError('Can not parse Relationship'); -// } -} - -class One with IterableMixin implements Relationship { - One(Identifier identifier, {Map meta}) - : meta = Map.unmodifiable(meta ?? {}), - _ids = List.unmodifiable([identifier]); - - One.empty({Map meta}) - : meta = Map.unmodifiable(meta ?? {}), - _ids = const []; - - One.fromNullable(Identifier identifier, {Map meta}) - : meta = Map.unmodifiable(meta ?? {}), - _ids = identifier == null ? const [] : [identifier]; - - @override - final Map meta; - final List _ids; - - @override - Iterator get iterator => _ids.iterator; - - @override - Map toJson() => { - if (meta.isNotEmpty) 'meta': meta, - 'data': _ids.isEmpty ? null : _ids.first, - }; -} - -class Many with IterableMixin implements Relationship { - Many(Iterable identifiers, {Map meta}) - : meta = Map.unmodifiable(meta ?? {}), - _ids = List.unmodifiable(identifiers); - - @override - final Map meta; - final List _ids; - - @override - Iterator get iterator => _ids.iterator; - - @override - Map toJson() => { - if (meta.isNotEmpty) 'meta': meta, - 'data': _ids, - }; -} - -class Identifier with Identity { - Identifier(this.type, this.id, {Map meta}) - : meta = Map.unmodifiable(meta ?? {}) { - ArgumentError.checkNotNull(type, 'type'); - ArgumentError.checkNotNull(id, 'id'); - } - - static Identifier fromJson(Object json) { - if (json is Map) { - return Identifier(json['type'], json['id'], meta: json['meta']); - } - throw ArgumentError('A JSON:API identifier must be a JSON object'); - } - - @override - final String type; - - @override - final String id; - - final Map meta; - - Map toJson() => { - 'type': type, - 'id': id, - if (meta.isNotEmpty) 'meta': meta, - }; -} - -class Api { - Api({String version, Map meta}) - : meta = Map.unmodifiable(meta ?? {}), - version = version ?? ''; - - static const v1 = '1.0'; - - /// The JSON:API version. May be empty. - final String version; - - final Map meta; - - bool get isHigherVersion => version.isNotEmpty && version != v1; - - bool get isNotEmpty => version.isNotEmpty && meta.isNotEmpty; - - Map toJson() => { - if (version.isNotEmpty) 'version': version, - if (meta.isNotEmpty) 'meta': meta, - }; -} - -mixin Identity { - String get type; - - String get id; - - String get key => '$type:$id'; -} - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - ErrorSource source, - Map links, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - source = source ?? ErrorSource(), - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: nullable(ErrorSource.fromJson)(json['source']) ?? - ErrorSource(), - meta: json['meta'], - links: nullable(Link.mapFromJson)(json['links'])) ?? - {}; - } - throw ArgumentError('A JSON:API error must be a JSON object'); - } - - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// The `source` object. - final ErrorSource source; - - final Map meta; - final Map links; - - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } -} - -/// An object containing references to the source of the error, optionally including any of the following members: -/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, -/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. -/// - parameter: a string indicating which URI query parameter caused the error. -class ErrorSource { - ErrorSource({String pointer, String parameter}) - : pointer = pointer ?? '', - parameter = parameter ?? ''; - - static ErrorSource fromJson(Object json) { - if (json is Map) { - return ErrorSource( - pointer: json['pointer'], parameter: json['parameter']); - } - throw ArgumentError('Can not parse ErrorSource'); - } - - final String pointer; - - final String parameter; - - bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; - - Map toJson() => { - if (pointer.isNotEmpty) 'pointer': pointer, - if (parameter.isNotEmpty) 'parameter': parameter - }; -} - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { - ArgumentError.checkNotNull(uri, 'uri'); - } - - final Uri uri; - final Map meta; - - /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - return Link(Uri.parse(json['href']), meta: json['meta']); - } - throw ArgumentError( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); - } - throw ArgumentError('A JSON:API links object must be a JSON object'); - } - - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; - - @override - String toString() => uri.toString(); -} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index ff014d58..3a1c8ac4 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -3,9 +3,7 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/maybe.dart'; /// The JSON:API client @@ -19,7 +17,7 @@ class JsonApiClient { Future> fetchCollection(String type, {Map headers, Iterable include = const []}) => send( - Request.fetchCollection(parameters: Include(include)), + Request.fetchCollection(include: include), _uri.collection(type), headers: headers, ); @@ -28,22 +26,21 @@ class JsonApiClient { Future> fetchRelatedCollection( String type, String id, String relationship, {Map headers, Iterable include = const []}) => - send(Request.fetchCollection(parameters: Include(include)), + send(Request.fetchCollection(include: include), _uri.related(type, id, relationship), headers: headers); /// Fetches a primary resource by [type] and [id]. Future> fetchResource(String type, String id, {Map headers, Iterable include = const []}) => - send(Request.fetchResource(parameters: Include(include)), - _uri.resource(type, id), + send(Request.fetchResource(include: include), _uri.resource(type, id), headers: headers); /// Fetches a related resource by [type], [id], [relationship]. Future> fetchRelatedResource( String type, String id, String relationship, {Map headers, Iterable include = const []}) => - send(Request.fetchResource(parameters: Include(include)), + send(Request.fetchResource(include: include), _uri.related(type, id, relationship), headers: headers); @@ -51,7 +48,7 @@ class JsonApiClient { Future> fetchToOne( String type, String id, String relationship, {Map headers, Iterable include = const []}) => - send(Request.fetchToOne(parameters: Include(include)), + send(Request.fetchOne(include: include), _uri.relationship(type, id, relationship), headers: headers); @@ -60,7 +57,7 @@ class JsonApiClient { String type, String id, String relationship, {Map headers, Iterable include = const []}) => send( - Request.fetchToMany(parameters: Include(include)), + Request.fetchMany(include: include), _uri.relationship(type, id, relationship), headers: headers, ); @@ -70,7 +67,7 @@ class JsonApiClient { String type, String id, String relationship, {Map headers = const {}, Iterable include = const []}) => - send(Request.fetchRelationship(parameters: Include(include)), + send(Request.fetchRelationship(include: include), _uri.relationship(type, id, relationship), headers: headers); @@ -78,15 +75,12 @@ class JsonApiClient { /// The server is expected to assign the resource id. Future> createNewResource(String type, {Map attributes = const {}, - Map meta = const {}, Map one = const {}, Map> many = const {}, Map headers = const {}}) => send( - Request.createResource(ResourceDocument(NewResource(type, - attributes: attributes, - relationships: _rel(one: one, many: many), - meta: meta))), + Request.createNewResource(type, + attributes: attributes, one: one, many: many), _uri.collection(type), headers: headers); @@ -94,15 +88,12 @@ class JsonApiClient { /// The server is expected to accept the provided resource id. Future> createResource(String type, String id, {Map attributes = const {}, - Map meta = const {}, Map one = const {}, Map> many = const {}, Map headers = const {}}) => send( - Request.createResource(ResourceDocument(Resource(type, id, - attributes: attributes, - relationships: _rel(one: one, many: many), - meta: meta))), + Request.createResource(type, id, + attributes: attributes, one: one, many: many), _uri.collection(type), headers: headers); @@ -114,14 +105,12 @@ class JsonApiClient { /// Updates the [resource]. Future> updateResource(String type, String id, {Map attributes = const {}, - Map meta = const {}, - Map relationships = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( - Request.updateResource(ResourceDocument(Resource(type, id, - attributes: attributes, - relationships: relationships, - meta: meta))), + Request.updateResource(type, id, + attributes: attributes, one: one, many: many), _uri.resource(type, id), headers: headers); @@ -129,7 +118,7 @@ class JsonApiClient { Future> replaceOne( String type, String id, String relationship, Identifier identifier, {Map headers = const {}}) => - send(Request.replaceToOne(RelationshipDocument(One(identifier))), + send(Request.replaceOne(identifier), _uri.relationship(type, id, relationship), headers: headers); @@ -137,15 +126,14 @@ class JsonApiClient { Future> deleteOne( String type, String id, String relationship, {Map headers = const {}}) => - send(Request.replaceToOne(RelationshipDocument(One.empty())), - _uri.relationship(type, id, relationship), + send(Request.deleteOne(), _uri.relationship(type, id, relationship), headers: headers); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => - send(Request.deleteFromToMany(RelationshipDocument(Many(identifiers))), + send(Request.deleteMany(identifiers), _uri.relationship(type, id, relationship), headers: headers); @@ -153,7 +141,7 @@ class JsonApiClient { Future> replaceMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => - send(Request.replaceToMany(RelationshipDocument(Many(identifiers))), + send(Request.replaceMany(identifiers), _uri.relationship(type, id, relationship), headers: headers); @@ -161,7 +149,7 @@ class JsonApiClient { Future> addMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) => - send(Request.addToMany(RelationshipDocument(Many(identifiers))), + send(Request.addMany(identifiers), _uri.relationship(type, id, relationship), headers: headers); @@ -176,12 +164,6 @@ class JsonApiClient { return Response(response, request.decoder); } - Map _rel( - {Map one = const {}, - Map> many = const {}}) => - one.map((key, value) => MapEntry(key, One.fromNullable(value))) - ..addAll(many.map((key, value) => MapEntry(key, Many(value)))); - Future _call( Request request, Uri uri, Map headers) => _http.call(_toHttp(request, uri, headers)); @@ -208,3 +190,153 @@ class RequestFailure { final HttpResponse http; } + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + String id, + String status, + String code, + String title, + String detail, + Map meta, + ErrorSource source, + Map links, + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + source = source ?? ErrorSource(), + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); + + static ErrorObject fromJson(Object json) { + if (json is Map) { + return ErrorObject( + id: json['id'], + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + source: Maybe(json['source']) + .map(ErrorSource.fromJson) + .or(() => ErrorSource()), + meta: json['meta'], + links: Maybe(json['links']).map(Link.mapFromJson).or(() => {})); + } + throw ArgumentError('A JSON:API error must be a JSON object'); + } + + /// A unique identifier for this particular occurrence of the problem. + /// May be empty. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + /// May be empty. + final String status; + + /// An application-specific error code, expressed as a string value. + /// May be empty. + 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. + /// May be empty. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + /// May be empty. + final String detail; + + /// The `source` object. + final ErrorSource source; + + final Map meta; + final Map links; + + Map toJson() { + return { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (meta.isNotEmpty) 'meta': meta, + if (links.isNotEmpty) 'links': links, + if (source.isNotEmpty) 'source': source, + }; + } +} + +/// An object containing references to the source of the error, optionally including any of the following members: +/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, +/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. +/// - parameter: a string indicating which URI query parameter caused the error. +class ErrorSource { + ErrorSource({String pointer, String parameter}) + : pointer = pointer ?? '', + parameter = parameter ?? ''; + + static ErrorSource fromJson(Object json) { + if (json is Map) { + return ErrorSource( + pointer: json['pointer'], parameter: json['parameter']); + } + throw ArgumentError('Can not parse ErrorSource'); + } + + final String pointer; + + final String parameter; + + bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + + Map toJson() => { + if (pointer.isNotEmpty) 'pointer': pointer, + if (parameter.isNotEmpty) 'parameter': parameter + }; +} + +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { + ArgumentError.checkNotNull(uri, 'uri'); + } + + final Uri uri; + final Map meta; + + /// Reconstructs the link from the [json] object + static Link fromJson(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']), meta: json['meta']); + } + throw ArgumentError( + 'A JSON:API link must be a JSON string or a JSON object'); + } + + /// Reconstructs the document's `links` member into a map. + /// Details on the `links` member: https://jsonapi.org/format/#document-links + static Map mapFromJson(Object json) { + if (json is Map) { + return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); + } + throw ArgumentError('A JSON:API links object must be a JSON object'); + } + + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + + @override + String toString() => uri.toString(); +} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index ac755e3d..08ced244 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,70 +1,177 @@ import 'dart:convert'; -import 'package:json_api/document.dart'; +import 'package:json_api/document.dart' as d; import 'package:json_api/query.dart'; -import 'package:json_api/src/client/document.dart'; +import 'package:json_api/src/client/content_type.dart'; import 'package:json_api/src/http/http_method.dart'; /// A JSON:API request. -class Request { +class Request { Request(this.method, this.decoder, {QueryParameters parameters}) - : headers = const {'Accept': Document.contentType}, + : headers = const {'Accept': ContentType.jsonApi}, body = '', parameters = parameters ?? QueryParameters.empty(); - Request.withPayload(RequestDocument document, this.method, this.decoder, + Request.withDocument(Object document, this.method, this.decoder, {QueryParameters parameters}) : headers = const { - 'Accept': Document.contentType, - 'Content-Type': Document.contentType + 'Accept': ContentType.jsonApi, + 'Content-Type': ContentType.jsonApi }, body = jsonEncode(document), parameters = parameters ?? QueryParameters.empty(); - static Request fetchCollection( - {QueryParameters parameters}) => - Request(HttpMethod.GET, ResourceCollectionData.fromJson, - parameters: parameters); - - static Request fetchResource({QueryParameters parameters}) => - Request(HttpMethod.GET, ResourceData.fromJson, parameters: parameters); + static Request fetchCollection( + {Iterable include = const []}) => + Request(HttpMethod.GET, d.ResourceCollectionData.fromJson, + parameters: Include(include)); + + static Request fetchResource( + {Iterable include = const []}) => + Request(HttpMethod.GET, d.ResourceData.fromJson, + parameters: Include(include)); + + static Request fetchOne( + {Iterable include = const []}) => + Request(HttpMethod.GET, d.ToOneObject.fromJson, + parameters: Include(include)); + + static Request fetchMany( + {Iterable include = const []}) => + Request(HttpMethod.GET, d.ToManyObject.fromJson, + parameters: Include(include)); + + static Request fetchRelationship( + {Iterable include = const []}) => + Request(HttpMethod.GET, d.RelationshipObject.fromJson, + parameters: Include(include)); + + static Request createNewResource(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) => + Request.withDocument( + _Resource(type, attributes: attributes, one: one, many: many), + HttpMethod.POST, + d.ResourceData.fromJson); + + static Request createResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) => + Request.withDocument( + _Resource.withId(type, id, + attributes: attributes, one: one, many: many), + HttpMethod.POST, + d.ResourceData.fromJson); + + static Request updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) => + Request.withDocument( + _Resource.withId(type, id, + attributes: attributes, one: one, many: many), + HttpMethod.PATCH, + d.ResourceData.fromJson); + + static Request deleteResource() => + Request(HttpMethod.DELETE, d.ResourceData.fromJson); + + static Request replaceOne(Identifier identifier) => + Request.withDocument( + _One(identifier), HttpMethod.PATCH, d.ToOneObject.fromJson); + + static Request deleteOne() => Request.withDocument( + _One(null), HttpMethod.PATCH, d.ToOneObject.fromJson); + + static Request deleteMany(Iterable identifiers) => + Request.withDocument( + _Many(identifiers), HttpMethod.DELETE, d.ToManyObject.fromJson); + + static Request replaceMany( + Iterable identifiers) => + Request.withDocument( + _Many(identifiers), HttpMethod.PATCH, d.ToManyObject.fromJson); + + static Request addMany(Iterable identifiers) => + Request.withDocument( + _Many(identifiers), HttpMethod.POST, d.ToManyObject.fromJson); + + final d.PrimaryDataDecoder decoder; + final String method; + final String body; + final Map headers; + final QueryParameters parameters; +} - static Request fetchToOne({QueryParameters parameters}) => - Request(HttpMethod.GET, ToOneObject.fromJson, parameters: parameters); +class _Resource { + _Resource(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) + : _data = { + 'type': type, + if (attributes.isNotEmpty) 'attributes': attributes, + ...relationship(one, many) + }; + + _Resource.withId(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) + : _data = { + 'type': type, + 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + ...relationship(one, many) + }; + + static Map relationship(Map one, + Map> many) => + { + if (one.isNotEmpty || many.isNotEmpty) + 'relationships': { + ...one.map((key, value) => MapEntry(key, _One(value))), + ...many.map((key, value) => MapEntry(key, _Many(value))) + } + }; + + final Object _data; + + Map toJson() => {'data': _data}; +} - static Request fetchToMany({QueryParameters parameters}) => - Request(HttpMethod.GET, ToManyObject.fromJson, parameters: parameters); +class _One { + _One(this._identifier); - static Request fetchRelationship( - {QueryParameters parameters}) => - Request(HttpMethod.GET, RelationshipObject.fromJson, - parameters: parameters); + final Identifier _identifier; - static Request createResource(ResourceDocument document) => - Request.withPayload(document, HttpMethod.POST, ResourceData.fromJson); + Map toJson() => {'data': _identifier}; +} - static Request updateResource(ResourceDocument document) => - Request.withPayload(document, HttpMethod.PATCH, ResourceData.fromJson); +class _Many { + _Many(this._identifiers); - static Request deleteResource() => - Request(HttpMethod.DELETE, ResourceData.fromJson); + final Iterable _identifiers; - static Request replaceToOne(RelationshipDocument document) => - Request.withPayload(document, HttpMethod.PATCH, ToOneObject.fromJson); + Map toJson() => { + 'data': _identifiers.toList(), + }; +} - static Request deleteFromToMany( - RelationshipDocument document) => - Request.withPayload(document, HttpMethod.DELETE, ToManyObject.fromJson); +class Identifier { + Identifier(this.type, this.id) { + ArgumentError.checkNotNull(type, 'type'); + ArgumentError.checkNotNull(id, 'id'); + } - static Request replaceToMany(RelationshipDocument document) => - Request.withPayload(document, HttpMethod.PATCH, ToManyObject.fromJson); + final String type; - static Request addToMany(RelationshipDocument document) => - Request.withPayload(document, HttpMethod.POST, ToManyObject.fromJson); + final String id; - final PrimaryDataDecoder decoder; - final String method; - final String body; - final Map headers; - final QueryParameters parameters; + Map toJson() => { + 'type': type, + 'id': id, + }; } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index a5705e92..2db91e6a 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,4 +1,3 @@ -import 'dart:collection'; import 'dart:convert'; import 'package:json_api/client.dart'; @@ -6,50 +5,6 @@ import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/src/client/status_code.dart'; -// -// -//class ResourceCollection with IterableMixin { -// ResourceCollection(Iterable resources) { -// resources.forEach((element) => _map[element.key] = element); -// } -// -// ResourceCollection.empty() : this([]); -// -// final _map = {}; -// -// @override -// Iterator get iterator => _map.values.iterator; -// -// Resource getByKey(String key, {Resource Function() orElse}) { -// if (_map.containsKey(key)) return _map[key]; -// if (orElse != null) return orElse(); -// throw StateError('No element'); -// } -//} -// -//class FetchCollectionResponse with IterableMixin { -// FetchCollectionResponse(this.http, this.resources, -// {ResourceCollection included}) -// : included = included ?? ResourceCollection.empty(); -// -// static FetchCollectionResponse fromHttp(HttpResponse http) { -// final json = jsonDecode(http.body); -// if (json is Map) { -// final resources = json['data']; -// if (resources is List) -// } -// -// } -// -// /// The HTTP response. -// final HttpResponse http; -// final ResourceCollection included; -// final ResourceCollection resources; -// -// @override -// Iterator get iterator => resources.iterator; -//} - /// A JSON:API response class Response { Response(this.http, this._decoder); diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart index 4f77689c..7d6a7c6d 100644 --- a/lib/src/maybe.dart +++ b/lib/src/maybe.dart @@ -1,6 +1,6 @@ /// A variation of the Maybe monad with eager execution. abstract class Maybe { - factory Maybe(T t) => t == null ? Nothing() : Just(t); + factory Maybe(T t) => t == null ? Nothing() : Just(t); Maybe

map

(P Function(T t) f); @@ -34,24 +34,24 @@ class Just implements Maybe { @override Maybe where(bool Function(T t) f) { try { - return f(value) ? this : const Nothing(); + return f(value) ? this : Nothing(); } catch (e) { - return Failure(e); + return Failure(e); } } @override - Maybe

whereType

() => value is P ? Just(value as P) : const Nothing(); + Maybe

whereType

() => value is P ? Just(value as P) : Nothing

(); @override Maybe recover(T Function(E _) f) => this; } class Nothing implements Maybe { - const Nothing(); + Nothing(); @override - Maybe

map

(P Function(T t) map) => const Nothing(); + Maybe

map

(P Function(T t) map) => Nothing

(); @override T or(T Function() f) => f(); @@ -63,7 +63,7 @@ class Nothing implements Maybe { Maybe where(bool Function(T t) f) => this; @override - Maybe

whereType

() => const Nothing(); + Maybe

whereType

() => Nothing

(); @override Maybe recover(T Function(E _) f) => this; diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index c8c37a95..862b93e5 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -28,7 +28,7 @@ void main() async { await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client - .updateResource('books', '2', relationships: {'authors': Many([])}); + .updateResource('books', '2', many: {'authors': []}); await client .addMany('books', '2', 'authors', [Identifier('writers', '1')]); diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index 0fc55250..a2c6c011 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -39,7 +39,7 @@ void main() { await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client - .updateResource('books', '2', relationships: {'authors': Many([])}); + .updateResource('books', '2', many: {'authors': []}); await client .addMany('books', '2', 'authors', [Identifier('writers', '1')]); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 9d489ccc..c4611933 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -78,8 +78,11 @@ void main() async { final r1 = await client.fetchResource('people', '123'); expect(r1.isSuccessful, isTrue); expect(r1.http.statusCode, 200); - expectSameJson(r1.decodeDocument().data.unwrap(), - Resource('people', '123', attributes: {'name': 'Martin Fowler'})); + expectSameJson(r1.decodeDocument().data.unwrap(), { + 'type': 'people', + 'id': '123', + 'attributes': {'name': 'Martin Fowler'} + }); }); test('404 when the collection does not exist', () async { @@ -121,8 +124,8 @@ void main() async { expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); - expect(e.errors.first.detail, - "Resource '123' does not exist in 'people'"); + expect( + e.errors.first.detail, "Resource '123' does not exist in 'people'"); } }); // diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index 79de5f22..d95ff9e1 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -35,7 +35,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); } }); @@ -45,7 +45,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -58,7 +58,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect(e.errors.first.detail, "Resource '42' does not exist in 'books'"); diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 75b67e17..b247762c 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -30,7 +30,7 @@ void main() async { final r = await client.fetchToOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/relationships/publisher'); expect(r.decodeDocument().data.links['related'].uri.toString(), @@ -45,7 +45,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -58,7 +58,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -72,7 +72,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Relationship not found'); expect(e.errors.first.detail, @@ -86,7 +86,7 @@ void main() async { final r = await client.fetchToMany('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.linkage.length, 2); expect(r.decodeDocument().data.linkage.first.type, 'people'); expect(r.decodeDocument().data.links['self'].uri.toString(), @@ -101,7 +101,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -114,7 +114,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -128,7 +128,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Relationship not found'); expect(e.errors.first.detail, @@ -142,7 +142,7 @@ void main() async { final r = await client.fetchRelationship('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); final rel = r.decodeDocument().data; if (rel is ToOneObject) { expect(rel.linkage.type, 'companies'); @@ -156,7 +156,7 @@ void main() async { final r = await client.fetchRelationship('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); final rel = r.decodeDocument().data; if (rel is ToManyObject) { expect(rel.linkage.length, 2); @@ -175,7 +175,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -188,7 +188,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -202,7 +202,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Relationship not found'); expect(e.errors.first.detail, diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index a646ff4d..d1226f8c 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -31,7 +31,7 @@ void main() async { final r = await client.fetchResource('books', '1'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.unwrap().id, '1'); expect( r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); @@ -57,7 +57,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -70,7 +70,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -84,7 +84,7 @@ void main() async { final r = await client.fetchCollection('people'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.links['self'].uri.toString(), '/people'); expect(r.decodeDocument().data.collection.length, 3); expect(r.decodeDocument().data.collection.first.self.uri.toString(), @@ -104,7 +104,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -117,7 +117,7 @@ void main() async { final r = await client.fetchRelatedResource('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.unwrap().type, 'companies'); expect(r.decodeDocument().data.unwrap().id, '1'); expect(r.decodeDocument().data.links['self'].uri.toString(), @@ -133,7 +133,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -146,7 +146,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -160,7 +160,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Relationship not found'); expect(e.errors.first.detail, @@ -174,7 +174,7 @@ void main() async { final r = await client.fetchRelatedCollection('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], Document.contentType); + expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1/authors'); expect(r.decodeDocument().data.collection.length, 2); @@ -195,7 +195,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Collection not found'); expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); @@ -208,7 +208,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Resource not found'); expect( @@ -222,7 +222,7 @@ void main() async { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], Document.contentType); + expect(e.http.headers['content-type'], ContentType.jsonApi); expect(e.errors.first.status, '404'); expect(e.errors.first.title, 'Relationship not found'); expect(e.errors.first.detail, diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index aeeeb8eb..b022ee8c 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -30,10 +30,11 @@ void main() async { final r = await client.updateResource('books', '1', attributes: { 'title': 'Refactoring. Improving the Design of Existing Code', 'pages': 448 - }, relationships: { - 'publisher': One.empty(), - 'authors': Many([Identifier('people', '1')]), - 'reviewers': Many([Identifier('people', '2')]) + }, one: { + 'publisher': null, + }, many: { + 'authors': [Identifier('people', '1')], + 'reviewers': [Identifier('people', '2')] }); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); From 3e119788da86c923f8f55914263516faadff4305 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 10 May 2020 11:32:05 -0700 Subject: [PATCH 67/99] ip --- example/client.dart | 13 +- lib/src/client/json_api_client.dart | 251 ++-------- lib/src/client/request.dart | 57 ++- lib/src/client/response.dart | 439 ++++++++++++++++++ lib/src/document/resource_data.dart | 2 +- lib/src/maybe.dart | 51 +- lib/src/server/response_factory.dart | 3 +- test/e2e/browser_test.dart | 12 +- test/e2e/client_server_interaction_test.dart | 13 +- test/functional/compound_document_test.dart | 102 ++-- .../crud/creating_resources_test.dart | 11 +- .../crud/fetching_relationships_test.dart | 16 +- .../crud/fetching_resources_test.dart | 77 ++- test/functional/crud/seed_resources.dart | 5 +- .../crud/updating_relationships_test.dart | 40 +- .../crud/updating_resources_test.dart | 8 +- 16 files changed, 653 insertions(+), 447 deletions(-) diff --git a/example/client.dart b/example/client.dart index 8ac131c1..c71bbcb8 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,7 +1,6 @@ import 'package:http/http.dart' as http; import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; /// This example shows how to use the JSON:API client. @@ -30,20 +29,18 @@ void main() async { await client.createResource('books', '2', attributes: { 'title': 'Refactoring' }, many: { - 'authors': [Identifier('writers', '1')] + 'authors': [Ref('writers', '1')] }); /// Fetch the book, including its authors. - final response = await client.fetchResource('books', '2', - include: ['authors']); - - final document = response.decodeDocument(); + final response = + await client.fetchResource('books', '2', include: ['authors']); /// Extract the primary resource. - final book = document.data.unwrap(); + final book = response.resource; /// Extract the included resource. - final author = document.included.first.unwrap(); + final author = response.included.first; print('Book: $book'); print('Author: $author'); diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 3a1c8ac4..b7c3bdfd 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,10 +1,7 @@ -import 'dart:convert'; - import 'package:json_api/client.dart'; import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/maybe.dart'; /// The JSON:API client class JsonApiClient { @@ -14,38 +11,45 @@ class JsonApiClient { final UriFactory _uri; /// Fetches a primary resource collection by [type]. - Future> fetchCollection(String type, - {Map headers, Iterable include = const []}) => - send( - Request.fetchCollection(include: include), - _uri.collection(type), - headers: headers, - ); + Future fetchCollection(String type, + {Map headers, + Iterable include = const []}) async => + FetchCollectionResponse.fromHttp(await _call( + Request.fetchCollection(include: include), + _uri.collection(type), + headers)); - /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - Future> fetchRelatedCollection( + /// Fetches a related resource collection by [type], [id], [relationship]. + Future fetchRelatedCollection( String type, String id, String relationship, - {Map headers, Iterable include = const []}) => - send(Request.fetchCollection(include: include), + {Map headers, + Iterable include = const []}) async => + FetchCollectionResponse.fromHttp(await _call( + Request.fetchCollection(include: include), _uri.related(type, id, relationship), - headers: headers); + headers)); /// Fetches a primary resource by [type] and [id]. - Future> fetchResource(String type, String id, - {Map headers, Iterable include = const []}) => - send(Request.fetchResource(include: include), _uri.resource(type, id), - headers: headers); + Future fetchResource(String type, String id, + {Map headers, + Iterable include = const []}) async => + FetchPrimaryResourceResponse.fromHttp(await _call( + Request.fetchResource(include: include), + _uri.resource(type, id), + headers)); /// Fetches a related resource by [type], [id], [relationship]. - Future> fetchRelatedResource( + Future fetchRelatedResource( String type, String id, String relationship, - {Map headers, Iterable include = const []}) => - send(Request.fetchResource(include: include), + {Map headers, + Iterable include = const []}) async => + FetchRelatedResourceResponse.fromHttp(await _call( + Request.fetchResource(include: include), _uri.related(type, id, relationship), - headers: headers); + headers)); /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( + Future> fetchOne( String type, String id, String relationship, {Map headers, Iterable include = const []}) => send(Request.fetchOne(include: include), @@ -53,7 +57,7 @@ class JsonApiClient { headers: headers); /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( + Future> fetchMany( String type, String id, String relationship, {Map headers, Iterable include = const []}) => send( @@ -75,8 +79,8 @@ class JsonApiClient { /// The server is expected to assign the resource id. Future> createNewResource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( Request.createNewResource(type, @@ -88,8 +92,8 @@ class JsonApiClient { /// The server is expected to accept the provided resource id. Future> createResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( Request.createResource(type, id, @@ -105,8 +109,8 @@ class JsonApiClient { /// Updates the [resource]. Future> updateResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers = const {}}) => send( Request.updateResource(type, id, @@ -116,7 +120,7 @@ class JsonApiClient { /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceOne( - String type, String id, String relationship, Identifier identifier, + String type, String id, String relationship, Ref identifier, {Map headers = const {}}) => send(Request.replaceOne(identifier), _uri.relationship(type, id, relationship), @@ -131,7 +135,7 @@ class JsonApiClient { /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.deleteMany(identifiers), _uri.relationship(type, id, relationship), @@ -139,7 +143,7 @@ class JsonApiClient { /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.replaceMany(identifiers), _uri.relationship(type, id, relationship), @@ -147,7 +151,7 @@ class JsonApiClient { /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. Future> addMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers = const {}}) => send(Request.addMany(identifiers), _uri.relationship(type, id, relationship), @@ -165,178 +169,15 @@ class JsonApiClient { } Future _call( - Request request, Uri uri, Map headers) => - _http.call(_toHttp(request, uri, headers)); + Request request, Uri uri, Map headers) async { + final response = await _http.call(_toHttp(request, uri, headers)); + if (StatusCode(response.statusCode).isFailed) { + throw RequestFailure.decode(response); + } + return response; + } HttpRequest _toHttp(Request request, Uri uri, Map headers) => HttpRequest(request.method, request.parameters.addToUri(uri), body: request.body, headers: {...?headers, ...request.headers}); } - -class RequestFailure { - RequestFailure(this.http, {Iterable errors = const []}) - : errors = List.unmodifiable(errors ?? const []); - final List errors; - - static RequestFailure decode(HttpResponse http) => Maybe(http.body) - .where((_) => _.isNotEmpty) - .map(jsonDecode) - .whereType() - .map((_) => _['errors']) - .whereType() - .map((_) => _.map(ErrorObject.fromJson)) - .map((_) => RequestFailure(http, errors: _)) - .or(() => RequestFailure(http)); - - final HttpResponse http; -} - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - ErrorSource source, - Map links, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - source = source ?? ErrorSource(), - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: Maybe(json['source']) - .map(ErrorSource.fromJson) - .or(() => ErrorSource()), - meta: json['meta'], - links: Maybe(json['links']).map(Link.mapFromJson).or(() => {})); - } - throw ArgumentError('A JSON:API error must be a JSON object'); - } - - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// The `source` object. - final ErrorSource source; - - final Map meta; - final Map links; - - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } -} - -/// An object containing references to the source of the error, optionally including any of the following members: -/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, -/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. -/// - parameter: a string indicating which URI query parameter caused the error. -class ErrorSource { - ErrorSource({String pointer, String parameter}) - : pointer = pointer ?? '', - parameter = parameter ?? ''; - - static ErrorSource fromJson(Object json) { - if (json is Map) { - return ErrorSource( - pointer: json['pointer'], parameter: json['parameter']); - } - throw ArgumentError('Can not parse ErrorSource'); - } - - final String pointer; - - final String parameter; - - bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; - - Map toJson() => { - if (pointer.isNotEmpty) 'pointer': pointer, - if (parameter.isNotEmpty) 'parameter': parameter - }; -} - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { - ArgumentError.checkNotNull(uri, 'uri'); - } - - final Uri uri; - final Map meta; - - /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - return Link(Uri.parse(json['href']), meta: json['meta']); - } - throw ArgumentError( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); - } - throw ArgumentError('A JSON:API links object must be a JSON object'); - } - - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; - - @override - String toString() => uri.toString(); -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 08ced244..a2b46c28 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -48,8 +48,8 @@ class Request { static Request createNewResource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource(type, attributes: attributes, one: one, many: many), HttpMethod.POST, @@ -57,8 +57,8 @@ class Request { static Request createResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource.withId(type, id, attributes: attributes, one: one, many: many), @@ -67,8 +67,8 @@ class Request { static Request updateResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource.withId(type, id, attributes: attributes, one: one, many: many), @@ -78,23 +78,22 @@ class Request { static Request deleteResource() => Request(HttpMethod.DELETE, d.ResourceData.fromJson); - static Request replaceOne(Identifier identifier) => + static Request replaceOne(Ref identifier) => Request.withDocument( _One(identifier), HttpMethod.PATCH, d.ToOneObject.fromJson); static Request deleteOne() => Request.withDocument( _One(null), HttpMethod.PATCH, d.ToOneObject.fromJson); - static Request deleteMany(Iterable identifiers) => + static Request deleteMany(Iterable identifiers) => Request.withDocument( _Many(identifiers), HttpMethod.DELETE, d.ToManyObject.fromJson); - static Request replaceMany( - Iterable identifiers) => + static Request replaceMany(Iterable identifiers) => Request.withDocument( _Many(identifiers), HttpMethod.PATCH, d.ToManyObject.fromJson); - static Request addMany(Iterable identifiers) => + static Request addMany(Iterable identifiers) => Request.withDocument( _Many(identifiers), HttpMethod.POST, d.ToManyObject.fromJson); @@ -108,9 +107,9 @@ class Request { class _Resource { _Resource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : _data = { + Map one = const {}, + Map> many = const {}}) + : _resource = { 'type': type, if (attributes.isNotEmpty) 'attributes': attributes, ...relationship(one, many) @@ -118,17 +117,17 @@ class _Resource { _Resource.withId(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : _data = { + Map one = const {}, + Map> many = const {}}) + : _resource = { 'type': type, 'id': id, if (attributes.isNotEmpty) 'attributes': attributes, ...relationship(one, many) }; - static Map relationship(Map one, - Map> many) => + static Map relationship( + Map one, Map> many) => { if (one.isNotEmpty || many.isNotEmpty) 'relationships': { @@ -137,31 +136,31 @@ class _Resource { } }; - final Object _data; + final Object _resource; - Map toJson() => {'data': _data}; + Map toJson() => {'data': _resource}; } class _One { - _One(this._identifier); + _One(this._ref); - final Identifier _identifier; + final Ref _ref; - Map toJson() => {'data': _identifier}; + Map toJson() => {'data': _ref}; } class _Many { - _Many(this._identifiers); + _Many(this._refs); - final Iterable _identifiers; + final Iterable _refs; Map toJson() => { - 'data': _identifiers.toList(), + 'data': _refs.toList(), }; } -class Identifier { - Identifier(this.type, this.id) { +class Ref { + Ref(this.type, this.id) { ArgumentError.checkNotNull(type, 'type'); ArgumentError.checkNotNull(id, 'id'); } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 2db91e6a..fdd92113 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,9 +1,11 @@ +import 'dart:collection'; import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/src/client/status_code.dart'; +import 'package:json_api/src/maybe.dart'; /// A JSON:API response class Response { @@ -47,3 +49,440 @@ class Response { /// For failed requests, [document] is expected to contain [ErrorDocument] bool get isFailed => StatusCode(http.statusCode).isFailed; } + +class FetchCollectionResponse with IterableMixin { + FetchCollectionResponse(this.http, + {ResourceCollection resources, + ResourceCollection included, + Map links = const {}}) + : resources = resources ?? ResourceCollection(const []), + links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); + + static FetchCollectionResponse fromHttp(HttpResponse http) { + final json = jsonDecode(http.body); + if (json is Map) { + final resources = json['data']; + if (resources is List) { + final included = json['included']; + final links = json['links']; + return FetchCollectionResponse(http, + resources: ResourceCollection(resources.map(Resource.fromJson)), + included: ResourceCollection( + included is List ? included.map(Resource.fromJson) : const []), + links: links is Map ? Link.mapFromJson(links) : const {}); + } + } + throw ArgumentError('Can not parse Resource collection'); + } + + final HttpResponse http; + final ResourceCollection resources; + final ResourceCollection included; + final Map links; + + @override + Iterator get iterator => resources.iterator; +} + +class FetchPrimaryResourceResponse { + FetchPrimaryResourceResponse(this.http, this.resource, + {ResourceCollection included, Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); + + static FetchPrimaryResourceResponse fromHttp(HttpResponse http) { + final json = jsonDecode(http.body); + if (json is Map) { + final included = json['included']; + final links = json['links']; + return FetchPrimaryResourceResponse(http, Resource.fromJson(json['data']), + included: ResourceCollection( + included is List ? included.map(Resource.fromJson) : const []), + links: links is Map ? Link.mapFromJson(links) : const {}); + } + throw ArgumentError('Can not parse Resource response'); + } + + final HttpResponse http; + final Resource resource; + final ResourceCollection included; + final Map links; +} + +class FetchRelatedResourceResponse { + FetchRelatedResourceResponse(this.http, Resource resource, + {ResourceCollection included, Map links = const {}}) + : _resource = Just(resource), + links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); + + FetchRelatedResourceResponse.empty(this.http, + {ResourceCollection included, Map links = const {}}) + : _resource = Nothing(), + links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); + + static FetchRelatedResourceResponse fromHttp(HttpResponse http) { + final json = jsonDecode(http.body); + if (json is Map) { + final included = ResourceCollection(Maybe(json['included']) + .whereType() + .map((t) => t.map(Resource.fromJson)) + .or(const [])); + final links = Maybe(json['links']) + .whereType() + .map(Link.mapFromJson) + .or(const {}); + return Maybe(json['data']) + .map(Resource.fromJson) + .map((resource) => FetchRelatedResourceResponse(http, resource, + included: included, links: links)) + .orGet(() => FetchRelatedResourceResponse.empty(http, + included: included, links: links)); + } + throw ArgumentError('Can not parse Resource response'); + } + + final HttpResponse http; + final Maybe _resource; + + Resource resource({Resource Function() orElse}) => _resource.orGet(() => + Maybe(orElse).orThrow(() => StateError('Related resource is empty'))()); + final ResourceCollection included; + final Map links; +} + +class RequestFailure { + RequestFailure(this.http, {Iterable errors = const []}) + : errors = List.unmodifiable(errors ?? const []); + final List errors; + + static RequestFailure decode(HttpResponse http) => Maybe(http.body) + .where((_) => _.isNotEmpty) + .map(jsonDecode) + .whereType() + .map((_) => _['errors']) + .whereType() + .map((_) => _.map(ErrorObject.fromJson)) + .map((_) => RequestFailure(http, errors: _)) + .orGet(() => RequestFailure(http)); + + final HttpResponse http; +} + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + String id, + String status, + String code, + String title, + String detail, + Map meta, + ErrorSource source, + Map links, + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + source = source ?? ErrorSource(), + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); + + static ErrorObject fromJson(Object json) { + if (json is Map) { + return ErrorObject( + id: json['id'], + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + source: Maybe(json['source']) + .map(ErrorSource.fromJson) + .orGet(() => ErrorSource()), + meta: json['meta'], + links: Maybe(json['links']).map(Link.mapFromJson).orGet(() => {})); + } + throw ArgumentError('A JSON:API error must be a JSON object'); + } + + /// A unique identifier for this particular occurrence of the problem. + /// May be empty. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + /// May be empty. + final String status; + + /// An application-specific error code, expressed as a string value. + /// May be empty. + 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. + /// May be empty. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + /// May be empty. + final String detail; + + /// The `source` object. + final ErrorSource source; + + final Map meta; + final Map links; + + Map toJson() { + return { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (meta.isNotEmpty) 'meta': meta, + if (links.isNotEmpty) 'links': links, + if (source.isNotEmpty) 'source': source, + }; + } +} + +/// An object containing references to the source of the error, optionally including any of the following members: +/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, +/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. +/// - parameter: a string indicating which URI query parameter caused the error. +class ErrorSource { + ErrorSource({String pointer, String parameter}) + : pointer = pointer ?? '', + parameter = parameter ?? ''; + + static ErrorSource fromJson(Object json) { + if (json is Map) { + return ErrorSource( + pointer: json['pointer'], parameter: json['parameter']); + } + throw ArgumentError('Can not parse ErrorSource'); + } + + final String pointer; + + final String parameter; + + bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + + Map toJson() => { + if (pointer.isNotEmpty) 'pointer': pointer, + if (parameter.isNotEmpty) 'parameter': parameter + }; +} + +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { + ArgumentError.checkNotNull(uri, 'uri'); + } + + final Uri uri; + final Map meta; + + /// Reconstructs the link from the [json] object + static Link fromJson(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']), meta: json['meta']); + } + throw ArgumentError( + 'A JSON:API link must be a JSON string or a JSON object'); + } + + /// Reconstructs the document's `links` member into a map. + /// Details on the `links` member: https://jsonapi.org/format/#document-links + static Map mapFromJson(Object json) { + if (json is Map) { + return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); + } + throw ArgumentError('A JSON:API links object must be a JSON object'); + } + + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + + @override + String toString() => uri.toString(); +} + +class ResourceCollection with IterableMixin { + ResourceCollection(Iterable resources) + : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); + + final Map _map; + + @override + Iterator get iterator => _map.values.iterator; +} + +class Resource with Identity { + Resource(this.type, this.id, + {Map links, + Map meta, + Map attributes, + Map relationships}) + : links = Map.unmodifiable(links ?? {}), + meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}), + attributes = Map.unmodifiable(attributes ?? {}); + + static Resource fromJson(Object json) { + if (json is Map) { + final relationships = json['relationships']; + final attributes = json['attributes']; + final type = json['type']; + if ((relationships == null || relationships is Map) && + (attributes == null || attributes is Map) && + type is String && + type.isNotEmpty) { + return Resource(json['type'], json['id'], + attributes: attributes, + relationships: Maybe(relationships) + .whereType() + .map((t) => t.map((key, value) => + MapEntry(key.toString(), Relationship.fromJson(value)))) + .orGet(() => {}), + links: Link.mapFromJson(json['links'] ?? {}), + meta: json['meta']); + } + throw ArgumentError('Invalid JSON:API resource object'); + } + throw ArgumentError('A JSON:API resource must be a JSON object'); + } + + @override + final String type; + @override + final String id; + final Map links; + final Map meta; + final Map attributes; + final Map relationships; + + Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) + .whereType() + .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); + + One one(String key, {One Function() orElse}) => Maybe(relationships[key]) + .whereType() + .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); +} + +class Relationship { + Relationship({Map links, Map meta}) + : links = Map.unmodifiable(links ?? {}), + meta = Map.unmodifiable(meta ?? {}); + + /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. + static Relationship fromJson(Object json) { + if (json is Map) { + final links = Maybe(json['links']).map(Link.mapFromJson).or(const {}); + final meta = json['meta']; + if (json.containsKey('data')) { + final data = json['data']; + if (data == null) { + return One.empty(links: links, meta: meta); + } + if (data is Map) { + return One(Identifier.fromJson(data), links: links, meta: meta); + } + if (data is List) { + return Many(data.map(Identifier.fromJson), links: links, meta: meta); + } + } + return Relationship(links: links, meta: meta); + } + throw ArgumentError('A JSON:API relationship object must be a JSON object'); + } + + final Map links; + final Map meta; + + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +class One extends Relationship with IterableMixin { + One(Identifier identifier, + {Map links, Map meta}) + : _id = Just(identifier), + super(links: links, meta: meta); + + One.empty({Map links, Map meta}) + : _id = Nothing(), + super(links: links, meta: meta); + + final Maybe _id; + + @override + Map toJson() => {...super.toJson(), 'data': _id.or(null)}; + + Identifier identifier({Identifier Function() ifEmpty}) => _id.orGet( + () => Maybe(ifEmpty).orThrow(() => StateError('Empty relationship'))()); + + @override + Iterator get iterator => + _id.map((_) => [_]).or(const []).iterator; +} + +class Many extends Relationship with IterableMixin { + Many(Iterable identifiers, + {Map links, Map meta}) + : super(links: links, meta: meta) { + identifiers.forEach((_) => _map[_.key] = _); + } + + final _map = {}; + + @override + Map toJson() => {...super.toJson(), 'data': _map.values}; + + @override + Iterator get iterator => _map.values.iterator; +} + +class Identifier with Identity { + Identifier(this.type, this.id, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}); + + static Identifier fromJson(Object json) { + if (json is Map) { + return Identifier(json['type'], json['id'], meta: json['meta']); + } + throw ArgumentError('A JSON:API identifier must be a JSON object'); + } + + @override + final String type; + + @override + final String id; + + final Map meta; +} + +mixin Identity { + String get type; + + String get id; + + String get key => '$type:$id'; +} diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart index 7ea57665..495efdf8 100644 --- a/lib/src/document/resource_data.dart +++ b/lib/src/document/resource_data.dart @@ -27,7 +27,7 @@ class ResourceData extends PrimaryData { @override Map toJson() => { ...super.toJson(), - 'data': resourceObject.toJson(), + 'data': resourceObject?.toJson(), }; Resource unwrap() => resourceObject?.unwrap(); diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart index 7d6a7c6d..04f5185e 100644 --- a/lib/src/maybe.dart +++ b/lib/src/maybe.dart @@ -8,7 +8,11 @@ abstract class Maybe { Maybe where(bool Function(T t) f); - T or(T Function() f); + T or(T t); + + T orGet(T Function() f); + + T orThrow(Object Function() f); void ifPresent(Function(T t) f); @@ -26,19 +30,19 @@ class Just implements Maybe { Maybe

map

(P Function(T t) f) => Maybe(f(value)); @override - T or(T Function() f) => value; + T or(T t) => value; + + @override + T orGet(T Function() f) => value; + + @override + T orThrow(Object Function() f) => value; @override void ifPresent(Function(T t) f) => f(value); @override - Maybe where(bool Function(T t) f) { - try { - return f(value) ? this : Nothing(); - } catch (e) { - return Failure(e); - } - } + Maybe where(bool Function(T t) f) => f(value) ? this : Nothing(); @override Maybe

whereType

() => value is P ? Just(value as P) : Nothing

(); @@ -54,42 +58,23 @@ class Nothing implements Maybe { Maybe

map

(P Function(T t) map) => Nothing

(); @override - T or(T Function() f) => f(); - - @override - void ifPresent(Function(T t) f) {} - - @override - Maybe where(bool Function(T t) f) => this; + T or(T t) => t; @override - Maybe

whereType

() => Nothing

(); + T orGet(T Function() f) => f(); @override - Maybe recover(T Function(E _) f) => this; -} - -class Failure implements Maybe { - const Failure(this.exception); - - final Object exception; + T orThrow(Object Function() f) => throw f(); @override void ifPresent(Function(T t) f) {} - @override - Maybe

map

(P Function(T t) f) => this as Failure

; - - @override - T or(T Function() f) => f(); - @override Maybe where(bool Function(T t) f) => this; @override - Maybe

whereType

() => this as Failure

; + Maybe

whereType

() => Nothing

(); @override - Maybe recover(T Function(E _) f) => - exception is E ? Maybe(f(exception as E)) : this; + Maybe recover(T Function(E _) f) => this; } diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index f4bcb5f0..b19f53a1 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; @@ -125,7 +126,7 @@ class HttpResponseFactory implements ResponseFactory { HttpResponse(200, headers: {'Content-Type': Document.contentType}, body: jsonEncode(Document( - ResourceData(_resource(resource), links: { + ResourceData(nullable(_resource)(resource), links: { 'self': Link(_self(request)), 'related': Link(_uri.related(request.target.type, request.target.id, request.target.relationship)) diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 862b93e5..0b0b247d 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -27,17 +27,13 @@ void main() async { .createResource('writers', '1', attributes: {'name': 'Martin Fowler'}); await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); - await client - .updateResource('books', '2', many: {'authors': []}); - await client - .addMany('books', '2', 'authors', [Identifier('writers', '1')]); + await client.updateResource('books', '2', many: {'authors': []}); + await client.addMany('books', '2', 'authors', [Ref('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); - expect(response.decodeDocument().data.unwrap().attributes['title'], - 'Refactoring'); - expect(response.decodeDocument().included.first.unwrap().attributes['name'], - 'Martin Fowler'); + expect(response.resource.attributes['title'], 'Refactoring'); + expect(response.included.first.attributes['name'], 'Martin Fowler'); }, testOn: 'browser'); } diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index a2c6c011..4107bd7f 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -38,19 +38,14 @@ void main() { attributes: {'name': 'Martin Fowler'}); await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); - await client - .updateResource('books', '2', many: {'authors': []}); - await client - .addMany('books', '2', 'authors', [Identifier('writers', '1')]); + await client.updateResource('books', '2', many: {'authors': []}); + await client.addMany('books', '2', 'authors', [Ref('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); - expect(response.decodeDocument().data.unwrap().attributes['title'], - 'Refactoring'); - expect( - response.decodeDocument().included.first.unwrap().attributes['name'], - 'Martin Fowler'); + expect(response.resource.attributes['title'], 'Refactoring'); + expect(response.included.first.attributes['name'], 'Martin Fowler'); }); }, testOn: 'vm'); } diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart index cc8c80f5..ef65ca16 100644 --- a/test/functional/compound_document_test.dart +++ b/test/functional/compound_document_test.dart @@ -6,8 +6,6 @@ import 'package:json_api/src/server/in_memory_repository.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:test/test.dart'; -import '../helper/expect_same_json.dart'; - void main() async { JsonApiClient client; JsonApiServer server; @@ -54,116 +52,98 @@ void main() async { }); group('Single Resources', () { - test('not compound by default', () async { - final r = await client.fetchResource('posts', '1'); - final document = r.decodeDocument(); - expectSameJson(document.data.unwrap(), post); - expect(document.isCompound, isFalse); - }); - test('included == [] when requested but nothing to include', () async { final r = await client.fetchResource('posts', '1', include: ['tags']); - expectSameJson(r.decodeDocument().data.unwrap(), post); - expect(r.decodeDocument().included, []); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().data.links['self'].toString(), - '/posts/1?include=tags'); + expect(r.resource.key, 'posts:1'); + expect(r.included, []); + expect(r.links['self'].toString(), '/posts/1?include=tags'); }); test('can include first-level relatives', () async { final r = await client.fetchResource('posts', '1', include: ['comments']); - expectSameJson(r.decodeDocument().data.unwrap(), post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 2); - expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); - expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); + expect(r.resource.key, 'posts:1'); + expect(r.included.length, 2); + expect(r.included.first.key, 'comments:1'); + expect(r.included.last.key, 'comments:2'); }); test('can include second-level relatives', () async { final r = await client .fetchResource('posts', '1', include: ['comments.author']); - expectSameJson(r.decodeDocument().data.unwrap(), post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 2); - expectSameJson(r.decodeDocument().included.first.unwrap(), bob); - expectSameJson(r.decodeDocument().included.last.unwrap(), alice); + expect(r.resource.key, 'posts:1'); + expect(r.included.length, 2); + expect(r.included.first.attributes['name'], 'Bob'); + expect(r.included.last.attributes['name'], 'Alice'); }); test('can include third-level relatives', () async { final r = await client .fetchResource('posts', '1', include: ['comments.author.birthplace']); - expectSameJson(r.decodeDocument().data.unwrap(), post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 1); - expectSameJson(r.decodeDocument().included.first.unwrap(), wonderland); + expect(r.resource.key, 'posts:1'); + expect(r.included.length, 1); + expect(r.included.first.attributes['name'], 'Wonderland'); }); test('can include first- and second-level relatives', () async { final r = await client.fetchResource('posts', '1', include: ['comments', 'comments.author']); - expectSameJson(r.decodeDocument().data.unwrap(), post); - expect(r.decodeDocument().included.length, 4); - expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); - expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); - expectSameJson(r.decodeDocument().included[2].unwrap(), bob); - expectSameJson(r.decodeDocument().included[3].unwrap(), alice); - expect(r.decodeDocument().isCompound, isTrue); + expect(r.resource.key, 'posts:1'); + expect(r.included.length, 4); + expect(r.included.toList()[0].key, 'comments:1'); + expect(r.included.toList()[1].key, 'comments:2'); + expect(r.included.toList()[2].attributes['name'], 'Bob'); + expect(r.included.toList()[3].attributes['name'], 'Alice'); }); }); group('Resource Collection', () { test('not compound by default', () async { final r = await client.fetchCollection('posts'); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().isCompound, isFalse); + expect(r.first.key, 'posts:1'); + expect(r.included.isEmpty, true); }); test('document is compound when requested but nothing to include', () async { final r = await client.fetchCollection('posts', include: ['tags']); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().included, []); - expect(r.decodeDocument().isCompound, isTrue); + expect(r.first.key, 'posts:1'); + expect(r.included.isEmpty, true); }); test('can include first-level relatives', () async { final r = await client.fetchCollection('posts', include: ['comments']); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 2); - expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); - expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); + expect(r.first.type, 'posts'); + expect(r.included.length, 2); + expect(r.included.first.key, 'comments:1'); + expect(r.included.last.key, 'comments:2'); }); test('can include second-level relatives', () async { final r = await client.fetchCollection('posts', include: ['comments.author']); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().included.length, 2); - expectSameJson(r.decodeDocument().included.first.unwrap(), bob); - expectSameJson(r.decodeDocument().included.last.unwrap(), alice); - expect(r.decodeDocument().isCompound, isTrue); + expect(r.first.type, 'posts'); + expect(r.included.length, 2); + expect(r.included.first.attributes['name'], 'Bob'); + expect(r.included.last.attributes['name'], 'Alice'); }); test('can include third-level relatives', () async { final r = await client .fetchCollection('posts', include: ['comments.author.birthplace']); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 1); - expectSameJson(r.decodeDocument().included.first.unwrap(), wonderland); + expect(r.first.key, 'posts:1'); + expect(r.included.length, 1); + expect(r.included.first.attributes['name'], 'Wonderland'); }); test('can include first- and second-level relatives', () async { final r = await client .fetchCollection('posts', include: ['comments', 'comments.author']); - expectSameJson(r.decodeDocument().data.unwrap().first, post); - expect(r.decodeDocument().isCompound, isTrue); - expect(r.decodeDocument().included.length, 4); - expectSameJson(r.decodeDocument().included[0].unwrap(), comment1); - expectSameJson(r.decodeDocument().included[1].unwrap(), comment2); - expectSameJson(r.decodeDocument().included[2].unwrap(), bob); - expectSameJson(r.decodeDocument().included[3].unwrap(), alice); + expect(r.first.key, 'posts:1'); + expect(r.included.length, 4); + expect(r.included.toList()[0].key, 'comments:1'); + expect(r.included.toList()[1].key, 'comments:2'); + expect(r.included.toList()[2].attributes['name'], 'Bob'); + expect(r.included.toList()[3].attributes['name'], 'Alice'); }); }); } diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index c4611933..a1d020fd 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -76,13 +76,8 @@ void main() async { expect(r.http.statusCode, 204); expect(r.http.headers['location'], isNull); final r1 = await client.fetchResource('people', '123'); - expect(r1.isSuccessful, isTrue); expect(r1.http.statusCode, 200); - expectSameJson(r1.decodeDocument().data.unwrap(), { - 'type': 'people', - 'id': '123', - 'attributes': {'name': 'Martin Fowler'} - }); + expect(r1.resource.attributes['name'], 'Martin Fowler'); }); test('404 when the collection does not exist', () async { @@ -101,7 +96,7 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { try { await client.createNewResource('books', - one: {'publisher': Identifier('companies', '123')}); + one: {'publisher': Ref('companies', '123')}); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -116,7 +111,7 @@ void main() async { test('404 when the related resource does not exist (to-many)', () async { try { await client.createNewResource('books', many: { - 'authors': [Identifier('people', '123')] + 'authors': [Ref('people', '123')] }); fail('Exception expected'); } on RequestFailure catch (e) { diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index b247762c..27b9fb98 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -27,7 +27,7 @@ void main() async { }); group('To-one', () { test('200 OK', () async { - final r = await client.fetchToOne('books', '1', 'publisher'); + final r = await client.fetchOne('books', '1', 'publisher'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -41,7 +41,7 @@ void main() async { test('404 on collection', () async { try { - await client.fetchToOne('unicorns', '1', 'publisher'); + await client.fetchOne('unicorns', '1', 'publisher'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -54,7 +54,7 @@ void main() async { test('404 on resource', () async { try { - await client.fetchToOne('books', '42', 'publisher'); + await client.fetchOne('books', '42', 'publisher'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -68,7 +68,7 @@ void main() async { test('404 on relationship', () async { try { - await client.fetchToOne('books', '1', 'owner'); + await client.fetchOne('books', '1', 'owner'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -83,7 +83,7 @@ void main() async { group('To-many', () { test('200 OK', () async { - final r = await client.fetchToMany('books', '1', 'authors'); + final r = await client.fetchMany('books', '1', 'authors'); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -97,7 +97,7 @@ void main() async { test('404 on collection', () async { try { - await client.fetchToMany('unicorns', '1', 'corns'); + await client.fetchMany('unicorns', '1', 'corns'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -110,7 +110,7 @@ void main() async { test('404 on resource', () async { try { - await client.fetchToMany('books', '42', 'authors'); + await client.fetchMany('books', '42', 'authors'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -124,7 +124,7 @@ void main() async { test('404 on relationship', () async { try { - await client.fetchToMany('books', '1', 'readers'); + await client.fetchMany('books', '1', 'readers'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart index d1226f8c..ded0cf78 100644 --- a/test/functional/crud/fetching_resources_test.dart +++ b/test/functional/crud/fetching_resources_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -29,23 +28,17 @@ void main() async { group('Primary Resource', () { test('200 OK', () async { final r = await client.fetchResource('books', '1'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.unwrap().id, '1'); - expect( - r.decodeDocument().data.unwrap().attributes['title'], 'Refactoring'); - expect(r.decodeDocument().data.links['self'].uri.toString(), '/books/1'); - expect( - r.decodeDocument().data.resourceObject.links['self'].uri.toString(), - '/books/1'); - final authors = - r.decodeDocument().data.resourceObject.relationships['authors']; + expect(r.resource.id, '1'); + expect(r.resource.attributes['title'], 'Refactoring'); + expect(r.links['self'].toString(), '/books/1'); + expect(r.links['self'].toString(), '/books/1'); + final authors = r.resource.relationships['authors']; expect( authors.links['self'].toString(), '/books/1/relationships/authors'); expect(authors.links['related'].toString(), '/books/1/authors'); - final publisher = - r.decodeDocument().data.resourceObject.relationships['publisher']; + final publisher = r.resource.relationships['publisher']; expect(publisher.links['self'].toString(), '/books/1/relationships/publisher'); expect(publisher.links['related'].toString(), '/books/1/publisher'); @@ -82,20 +75,14 @@ void main() async { group('Primary collections', () { test('200 OK', () async { final r = await client.fetchCollection('people'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.links['self'].uri.toString(), '/people'); - expect(r.decodeDocument().data.collection.length, 3); - expect(r.decodeDocument().data.collection.first.self.uri.toString(), - '/people/1'); - expect(r.decodeDocument().data.collection.last.self.uri.toString(), - '/people/3'); - expect(r.decodeDocument().data.unwrap().length, 3); - expect(r.decodeDocument().data.unwrap().first.attributes['name'], - 'Martin Fowler'); - expect(r.decodeDocument().data.unwrap().last.attributes['name'], - 'Robert Martin'); + expect(r.links['self'].uri.toString(), '/people'); + expect(r.length, 3); + expect(r.first.links['self'].toString(), '/people/1'); + expect(r.last.links['self'].toString(), '/people/3'); + expect(r.first.attributes['name'], 'Martin Fowler'); + expect(r.last.attributes['name'], 'Robert Martin'); }); test('404 on collection', () async { @@ -115,16 +102,21 @@ void main() async { group('Related Resource', () { test('200 OK', () async { final r = await client.fetchRelatedResource('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.unwrap().type, 'companies'); - expect(r.decodeDocument().data.unwrap().id, '1'); - expect(r.decodeDocument().data.links['self'].uri.toString(), - '/books/1/publisher'); - expect( - r.decodeDocument().data.resourceObject.links['self'].uri.toString(), - '/companies/1'); + expect(r.resource().type, 'companies'); + expect(r.resource().id, '1'); + expect(r.links['self'].toString(), '/books/1/publisher'); + expect(r.resource().links['self'].toString(), '/companies/1'); + }); + + test('200 OK with empty resource', () async { + final r = await client.fetchRelatedResource('books', '1', 'reviewer'); + expect(r.http.statusCode, 200); + expect(r.http.headers['content-type'], ContentType.jsonApi); + expect(() => r.resource(), throwsStateError); + expect(r.resource(orElse: () => null), isNull); + expect(r.links['self'].toString(), '/books/1/reviewer'); }); test('404 on collection', () async { @@ -172,21 +164,14 @@ void main() async { group('Related Collection', () { test('successful', () async { final r = await client.fetchRelatedCollection('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.links['self'].uri.toString(), - '/books/1/authors'); - expect(r.decodeDocument().data.collection.length, 2); - expect(r.decodeDocument().data.collection.first.self.uri.toString(), - '/people/1'); - expect(r.decodeDocument().data.collection.last.self.uri.toString(), - '/people/2'); - expect(r.decodeDocument().data.unwrap().length, 2); - expect(r.decodeDocument().data.unwrap().first.attributes['name'], - 'Martin Fowler'); - expect(r.decodeDocument().data.unwrap().last.attributes['name'], - 'Kent Beck'); + expect(r.links['self'].uri.toString(), '/books/1/authors'); + expect(r.length, 2); + expect(r.first.links['self'].toString(), '/people/1'); + expect(r.last.links['self'].toString(), '/people/2'); + expect(r.first.attributes['name'], 'Martin Fowler'); + expect(r.last.attributes['name'], 'Kent Beck'); }); test('404 on collection', () async { diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index 9955aff5..4dbbba15 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -14,8 +14,9 @@ Future seedResources(JsonApiClient client) async { 'title': 'Refactoring', 'ISBN-10': '0134757599' }, one: { - 'publisher': Identifier('companies', '1'), + 'publisher': Ref('companies', '1'), + 'reviewer': null, }, many: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] + 'authors': [Ref('people', '1'), Ref('people', '2')] }); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index 4169b3b5..eb487709 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -28,23 +28,18 @@ void main() async { group('Updating a to-one relationship', () { test('204 No Content', () async { final r = await client.replaceOne( - 'books', '1', 'publisher', Identifier('companies', '2')); + 'books', '1', 'publisher', Ref('companies', '2')); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - r1 - .decodeDocument() - .data - .unwrap() - .one('publisher') - .mapIfExists((i) => expect(i.id, '2'), () => fail('No id')); + expect(r1.resource.one('publisher').identifier().id, '2'); }); test('404 on collection', () async { try { await client.replaceOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); + 'unicorns', '1', 'breed', Ref('companies', '2')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -58,7 +53,7 @@ void main() async { test('404 on resource', () async { try { await client.replaceOne( - 'books', '42', 'publisher', Identifier('companies', '2')); + 'books', '42', 'publisher', Ref('companies', '2')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -78,7 +73,7 @@ void main() async { expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().one('publisher').isEmpty, true); + expect(r1.resource.one('publisher').isEmpty, true); }); test('404 on collection', () async { @@ -112,16 +107,13 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { final r = await client - .replaceMany('books', '1', 'authors', [Identifier('people', '1')]); + .replaceMany('books', '1', 'authors', [Ref('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); - expect( - r1.decodeDocument().data.unwrap().many('authors').toList().length, 1); - expect( - r1.decodeDocument().data.unwrap().many('authors').toList().first.id, - '1'); + expect(r1.resource.many('authors').length, 1); + expect(r1.resource.many('authors').first.id, '1'); }); test('404 on collection', () async { @@ -154,8 +146,8 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { - final r = await client - .addMany('books', '1', 'authors', [Identifier('people', '3')]); + final r = + await client.addMany('books', '1', 'authors', [Ref('people', '3')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -164,12 +156,12 @@ void main() async { expect(r.decodeDocument().data.linkage.last.id, '3'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().many('authors').length, 3); + expect(r1.resource.many('authors').length, 3); }); test('successfully adding an existing identifier', () async { - final r = await client - .addMany('books', '1', 'authors', [Identifier('people', '2')]); + final r = + await client.addMany('books', '1', 'authors', [Ref('people', '2')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -178,7 +170,7 @@ void main() async { expect(r.decodeDocument().data.linkage.last.id, '2'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().many('authors').length, 2); + expect(r1.resource.many('authors').length, 2); expect(r1.http.headers['content-type'], ContentType.jsonApi); }); @@ -227,7 +219,7 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { final r = await client - .deleteMany('books', '1', 'authors', [Identifier('people', '1')]); + .deleteMany('books', '1', 'authors', [Ref('people', '1')]); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); @@ -235,7 +227,7 @@ void main() async { expect(r.decodeDocument().data.linkage.first.id, '2'); final r1 = await client.fetchResource('books', '1'); - expect(r1.decodeDocument().data.unwrap().many('authors').length, 1); + expect(r1.resource.many('authors').length, 1); }); test('404 on collection', () async { diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index b022ee8c..ace0fb0c 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -33,8 +33,8 @@ void main() async { }, one: { 'publisher': null, }, many: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] + 'authors': [Ref('people', '1')], + 'reviewers': [Ref('people', '2')] }); expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); @@ -52,8 +52,8 @@ void main() async { equals('people:2')); final r1 = await client.fetchResource('books', '1'); - expectSameJson( - r1.decodeDocument().data.unwrap(), r.decodeDocument().data.unwrap()); + expect( + r1.resource.attributes, r.decodeDocument().data.unwrap().attributes); }); test('204 No Content', () async { From 95386f4ac77f885365e53d7b70a8af8765f8c5aa Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 10 May 2020 12:30:33 -0700 Subject: [PATCH 68/99] All fetch --- lib/src/client/json_api_client.dart | 30 +--- lib/src/client/request.dart | 18 +-- lib/src/client/response.dart | 54 +++++-- lib/src/maybe.dart | 21 +-- .../crud/fetching_relationships_test.dart | 144 ++---------------- 5 files changed, 75 insertions(+), 192 deletions(-) diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index b7c3bdfd..5456f6d3 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -48,32 +48,14 @@ class JsonApiClient { _uri.related(type, id, relationship), headers)); - /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchOne( + /// Fetches a relationship by [type], [id], [relationship]. + Future fetchRelationship( String type, String id, String relationship, - {Map headers, Iterable include = const []}) => - send(Request.fetchOne(include: include), + {Map headers = const {}}) async => + FetchRelationshipResponse.fromHttp(await _call( + Request.fetchRelationship(), _uri.relationship(type, id, relationship), - headers: headers); - - /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchMany( - String type, String id, String relationship, - {Map headers, Iterable include = const []}) => - send( - Request.fetchMany(include: include), - _uri.relationship(type, id, relationship), - headers: headers, - ); - - /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers = const {}, - Iterable include = const []}) => - send(Request.fetchRelationship(include: include), - _uri.relationship(type, id, relationship), - headers: headers); + headers)); /// Creates a new [resource] on the server. /// The server is expected to assign the resource id. diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index a2b46c28..c55c8a56 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -31,20 +31,14 @@ class Request { Request(HttpMethod.GET, d.ResourceData.fromJson, parameters: Include(include)); - static Request fetchOne( - {Iterable include = const []}) => - Request(HttpMethod.GET, d.ToOneObject.fromJson, - parameters: Include(include)); + static Request fetchOne() => + Request(HttpMethod.GET, d.ToOneObject.fromJson); - static Request fetchMany( - {Iterable include = const []}) => - Request(HttpMethod.GET, d.ToManyObject.fromJson, - parameters: Include(include)); + static Request fetchMany() => + Request(HttpMethod.GET, d.ToManyObject.fromJson); - static Request fetchRelationship( - {Iterable include = const []}) => - Request(HttpMethod.GET, d.RelationshipObject.fromJson, - parameters: Include(include)); + static Request fetchRelationship() => + Request(HttpMethod.GET, d.RelationshipObject.fromJson); static Request createNewResource(String type, {Map attributes = const {}, diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index fdd92113..f1395b1f 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -110,6 +110,26 @@ class FetchPrimaryResourceResponse { final Map links; } +class FetchRelationshipResponse { + FetchRelationshipResponse(this.http, this.relationship); + + static FetchRelationshipResponse fromHttp(HttpResponse http) { + final json = jsonDecode(http.body); + if (json is Map) { + return FetchRelationshipResponse( + http, + Relationship.fromJson(json), + ); + } + throw ArgumentError('Can not parse Relationship response'); + } + + final HttpResponse http; + final Relationship relationship; + + Map get links => relationship.links; +} + class FetchRelatedResourceResponse { FetchRelatedResourceResponse(this.http, Resource resource, {ResourceCollection included, Map links = const {}}) @@ -127,11 +147,11 @@ class FetchRelatedResourceResponse { final json = jsonDecode(http.body); if (json is Map) { final included = ResourceCollection(Maybe(json['included']) - .whereType() + .map((t) => t is List ? t : throw ArgumentError('List expected')) .map((t) => t.map(Resource.fromJson)) .or(const [])); final links = Maybe(json['links']) - .whereType() + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) .map(Link.mapFromJson) .or(const {}); return Maybe(json['data']) @@ -159,11 +179,11 @@ class RequestFailure { final List errors; static RequestFailure decode(HttpResponse http) => Maybe(http.body) - .where((_) => _.isNotEmpty) + .filter((_) => _.isNotEmpty) .map(jsonDecode) - .whereType() + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) .map((_) => _['errors']) - .whereType() + .map((_) => _ is List ? _ : throw ArgumentError('List expected')) .map((_) => _.map(ErrorObject.fromJson)) .map((_) => RequestFailure(http, errors: _)) .orGet(() => RequestFailure(http)); @@ -354,7 +374,7 @@ class Resource with Identity { return Resource(json['type'], json['id'], attributes: attributes, relationships: Maybe(relationships) - .whereType() + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) .map((t) => t.map((key, value) => MapEntry(key.toString(), Relationship.fromJson(value)))) .orGet(() => {}), @@ -376,15 +396,15 @@ class Resource with Identity { final Map relationships; Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) - .whereType() + .filter((_) => _ is Many) .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); One one(String key, {One Function() orElse}) => Maybe(relationships[key]) - .whereType() + .filter((_) => _ is One) .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); } -class Relationship { +class Relationship with IterableMixin { Relationship({Map links, Map meta}) : links = Map.unmodifiable(links ?? {}), meta = Map.unmodifiable(meta ?? {}); @@ -413,14 +433,20 @@ class Relationship { final Map links; final Map meta; + final isSingular = false; + final isPlural = false; + final hasData = false; Map toJson() => { if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; + + @override + Iterator get iterator => const [].iterator; } -class One extends Relationship with IterableMixin { +class One extends Relationship { One(Identifier identifier, {Map links, Map meta}) : _id = Just(identifier), @@ -432,6 +458,9 @@ class One extends Relationship with IterableMixin { final Maybe _id; + @override + final isSingular = true; + @override Map toJson() => {...super.toJson(), 'data': _id.or(null)}; @@ -443,7 +472,7 @@ class One extends Relationship with IterableMixin { _id.map((_) => [_]).or(const []).iterator; } -class Many extends Relationship with IterableMixin { +class Many extends Relationship { Many(Iterable identifiers, {Map links, Map meta}) : super(links: links, meta: meta) { @@ -452,6 +481,9 @@ class Many extends Relationship with IterableMixin { final _map = {}; + @override + final isPlural = true; + @override Map toJson() => {...super.toJson(), 'data': _map.values}; diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart index 04f5185e..9d0b1c94 100644 --- a/lib/src/maybe.dart +++ b/lib/src/maybe.dart @@ -2,11 +2,10 @@ abstract class Maybe { factory Maybe(T t) => t == null ? Nothing() : Just(t); + /// Maps the value Maybe

map

(P Function(T t) f); - Maybe

whereType

(); - - Maybe where(bool Function(T t) f); + Maybe filter(bool Function(T t) f); T or(T t); @@ -14,7 +13,7 @@ abstract class Maybe { T orThrow(Object Function() f); - void ifPresent(Function(T t) f); + void ifPresent(void Function(T t) f); Maybe recover(T Function(E _) f); } @@ -39,13 +38,10 @@ class Just implements Maybe { T orThrow(Object Function() f) => value; @override - void ifPresent(Function(T t) f) => f(value); - - @override - Maybe where(bool Function(T t) f) => f(value) ? this : Nothing(); + void ifPresent(void Function(T t) f) => f(value); @override - Maybe

whereType

() => value is P ? Just(value as P) : Nothing

(); + Maybe filter(bool Function(T t) f) => f(value) ? this : Nothing(); @override Maybe recover(T Function(E _) f) => this; @@ -67,13 +63,10 @@ class Nothing implements Maybe { T orThrow(Object Function() f) => throw f(); @override - void ifPresent(Function(T t) f) {} - - @override - Maybe where(bool Function(T t) f) => this; + void ifPresent(void Function(T t) f) {} @override - Maybe

whereType

() => Nothing

(); + Maybe filter(bool Function(T t) f) => this; @override Maybe recover(T Function(E _) f) => this; diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart index 27b9fb98..eba8d014 100644 --- a/test/functional/crud/fetching_relationships_test.dart +++ b/test/functional/crud/fetching_relationships_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -25,148 +24,31 @@ void main() async { await seedResources(client); }); - group('To-one', () { - test('200 OK', () async { - final r = await client.fetchOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.links['self'].uri.toString(), - '/books/1/relationships/publisher'); - expect(r.decodeDocument().data.links['related'].uri.toString(), - '/books/1/publisher'); - expect(r.decodeDocument().data.linkage.type, 'companies'); - expect(r.decodeDocument().data.linkage.id, '1'); - }); - - test('404 on collection', () async { - try { - await client.fetchOne('unicorns', '1', 'publisher'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchOne('books', '42', 'publisher'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.fetchOne('books', '1', 'owner'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "Relationship 'owner' does not exist in this resource"); - } - }); - }); - - group('To-many', () { - test('200 OK', () async { - final r = await client.fetchMany('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.linkage.length, 2); - expect(r.decodeDocument().data.linkage.first.type, 'people'); - expect(r.decodeDocument().data.links['self'].uri.toString(), - '/books/1/relationships/authors'); - expect(r.decodeDocument().data.links['related'].uri.toString(), - '/books/1/authors'); - }); - - test('404 on collection', () async { - try { - await client.fetchMany('unicorns', '1', 'corns'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchMany('books', '42', 'authors'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.fetchMany('books', '1', 'readers'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "Relationship 'readers' does not exist in this resource"); - } - }); - }); group('Generic', () { test('200 OK to-one', () async { final r = await client.fetchRelationship('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - final rel = r.decodeDocument().data; - if (rel is ToOneObject) { - expect(rel.linkage.type, 'companies'); - expect(rel.linkage.id, '1'); - } else { - fail('Not a ToOne relationship'); - } + final rel = r.relationship; + expect(rel.isSingular, true); + expect(rel.isPlural, false); + expect(rel.first.type, 'companies'); + expect(rel.first.id, '1'); }); test('200 OK to-many', () async { final r = await client.fetchRelationship('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - final rel = r.decodeDocument().data; - if (rel is ToManyObject) { - expect(rel.linkage.length, 2); - expect(rel.linkage.first.id, '1'); - expect(rel.linkage.first.type, 'people'); - expect(rel.linkage.last.id, '2'); - expect(rel.linkage.last.type, 'people'); - } else { - fail('Not a ToMany relationship'); - } + final rel = r.relationship; + expect(rel.isSingular, false); + expect(rel.isPlural, true); + expect(rel.length, 2); + expect(rel.first.id, '1'); + expect(rel.first.type, 'people'); + expect(rel.last.id, '2'); + expect(rel.last.type, 'people'); }); test('404 on collection', () async { From 7d4421b3eac10b203d7dff87ee7a55944e490e16 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 10 May 2020 15:16:30 -0700 Subject: [PATCH 69/99] wip --- example/client.dart | 2 +- lib/client.dart | 3 +- lib/src/client/document.dart | 389 +++++++++++++ lib/src/client/json_api_client.dart | 145 +++-- lib/src/client/request.dart | 115 ++-- lib/src/client/response.dart | 524 ++++-------------- lib/src/client/status_code.dart | 2 + test/e2e/browser_test.dart | 2 +- test/e2e/client_server_interaction_test.dart | 2 +- .../crud/creating_resources_test.dart | 20 +- .../crud/deleting_resources_test.dart | 2 - test/functional/crud/seed_resources.dart | 4 +- .../crud/updating_relationships_test.dart | 36 +- .../crud/updating_resources_test.dart | 25 +- test/unit/client/async_processing_test.dart | 26 - 15 files changed, 629 insertions(+), 668 deletions(-) create mode 100644 lib/src/client/document.dart delete mode 100644 test/unit/client/async_processing_test.dart diff --git a/example/client.dart b/example/client.dart index c71bbcb8..f00d11e7 100644 --- a/example/client.dart +++ b/example/client.dart @@ -29,7 +29,7 @@ void main() async { await client.createResource('books', '2', attributes: { 'title': 'Refactoring' }, many: { - 'authors': [Ref('writers', '1')] + 'authors': [Identifier('writers', '1')] }); /// Fetch the book, including its authors. diff --git a/lib/client.dart b/lib/client.dart index e230616e..a0e0ff39 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,9 +1,10 @@ library client; +export 'package:json_api/src/client/content_type.dart'; export 'package:json_api/src/client/dart_http.dart'; +export 'package:json_api/src/client/document.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/request.dart'; -export 'package:json_api/src/client/content_type.dart'; export 'package:json_api/src/client/response.dart'; export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart new file mode 100644 index 00000000..b7567140 --- /dev/null +++ b/lib/src/client/document.dart @@ -0,0 +1,389 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:json_api/src/maybe.dart'; + +/// Generic response document parser +class ResponseDocument { + ResponseDocument(this._json); + + static ResponseDocument decode(String body) => Just(body) + .filter((t) => t.isNotEmpty) + .map(jsonDecode) + .map((t) => t is Map + ? t + : throw ArgumentError('Response document must be a JSON map')) + .map((t) => ResponseDocument(t)) + .orThrow(() => ArgumentError('Empty response body')); + + final Map _json; + + Map get links => Maybe(_json['links']) + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) + .map(Link.mapFromJson) + .or(const {}); + + ResourceCollection get included => ResourceCollection(Maybe(_json['included']) + .map((t) => t is List ? t : throw ArgumentError('List expected')) + .map((t) => t.map(Resource.fromJson)) + .or(const [])); + + ResourceCollection get resources => ResourceCollection(Maybe(_json['data']) + .map((t) => t is List ? t : throw ArgumentError('List expected')) + .map((t) => t.map(Resource.fromJson)) + .or(const [])); + + Resource get resource => Resource.fromJson(_json['data']); + + Relationship get relationship => Relationship.fromJson(_json); + + bool get hasData => _json['data'] != null; + + Iterable get errors => Maybe(_json['errors']) + .map((_) => _ is List ? _ : throw ArgumentError('List expected')) + .map((_) => _.map(ErrorObject.fromJson)) + .or(const []); + + Map get meta => Maybe(_json['meta']) + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) + .or(const {}); +} + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + String id, + String status, + String code, + String title, + String detail, + Map meta, + ErrorSource source, + Map links, + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + source = source ?? ErrorSource(), + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); + + static ErrorObject fromJson(Object json) { + if (json is Map) { + return ErrorObject( + id: json['id'], + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + source: Maybe(json['source']) + .map(ErrorSource.fromJson) + .orGet(() => ErrorSource()), + meta: json['meta'], + links: Maybe(json['links']).map(Link.mapFromJson).orGet(() => {})); + } + throw ArgumentError('A JSON:API error must be a JSON object'); + } + + /// A unique identifier for this particular occurrence of the problem. + /// May be empty. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + /// May be empty. + final String status; + + /// An application-specific error code, expressed as a string value. + /// May be empty. + 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. + /// May be empty. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + /// May be empty. + final String detail; + + /// The `source` object. + final ErrorSource source; + + final Map meta; + final Map links; + + Map toJson() { + return { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (meta.isNotEmpty) 'meta': meta, + if (links.isNotEmpty) 'links': links, + if (source.isNotEmpty) 'source': source, + }; + } +} + +/// An object containing references to the source of the error, optionally including any of the following members: +/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, +/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. +/// - parameter: a string indicating which URI query parameter caused the error. +class ErrorSource { + ErrorSource({String pointer, String parameter}) + : pointer = pointer ?? '', + parameter = parameter ?? ''; + + static ErrorSource fromJson(Object json) { + if (json is Map) { + return ErrorSource( + pointer: json['pointer'], parameter: json['parameter']); + } + throw ArgumentError('Can not parse ErrorSource'); + } + + final String pointer; + + final String parameter; + + bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + + Map toJson() => { + if (pointer.isNotEmpty) 'pointer': pointer, + if (parameter.isNotEmpty) 'parameter': parameter + }; +} + +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { + ArgumentError.checkNotNull(uri, 'uri'); + } + + final Uri uri; + final Map meta; + + /// Reconstructs the link from the [json] object + static Link fromJson(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']), meta: json['meta']); + } + throw ArgumentError( + 'A JSON:API link must be a JSON string or a JSON object'); + } + + /// Reconstructs the document's `links` member into a map. + /// Details on the `links` member: https://jsonapi.org/format/#document-links + static Map mapFromJson(Object json) { + if (json is Map) { + return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); + } + throw ArgumentError('A JSON:API links object must be a JSON object'); + } + + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + + @override + String toString() => uri.toString(); +} + +class ResourceCollection with IterableMixin { + ResourceCollection(Iterable resources) + : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); + + final Map _map; + + @override + Iterator get iterator => _map.values.iterator; +} + +class Resource with Identity { + Resource(this.type, this.id, + {Map links, + Map meta, + Map attributes, + Map relationships}) + : links = Map.unmodifiable(links ?? {}), + meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}), + attributes = Map.unmodifiable(attributes ?? {}); + + static Resource fromJson(Object json) { + if (json is Map) { + final relationships = json['relationships']; + final attributes = json['attributes']; + final type = json['type']; + if ((relationships == null || relationships is Map) && + (attributes == null || attributes is Map) && + type is String && + type.isNotEmpty) { + return Resource(json['type'], json['id'], + attributes: attributes, + relationships: Maybe(relationships) + .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) + .map((t) => t.map((key, value) => + MapEntry(key.toString(), Relationship.fromJson(value)))) + .orGet(() => {}), + links: Link.mapFromJson(json['links'] ?? {}), + meta: json['meta']); + } + throw ArgumentError('Invalid JSON:API resource object'); + } + throw ArgumentError('A JSON:API resource must be a JSON object'); + } + + @override + final String type; + @override + final String id; + final Map links; + final Map meta; + final Map attributes; + final Map relationships; + + Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) + .filter((_) => _ is Many) + .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); + + One one(String key, {One Function() orElse}) => Maybe(relationships[key]) + .filter((_) => _ is One) + .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); +} + +abstract class Relationship with IterableMixin { + Relationship({Map links, Map meta}) + : links = Map.unmodifiable(links ?? {}), + meta = Map.unmodifiable(meta ?? {}); + + /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. + static Relationship fromJson(Object json) { + if (json is Map) { + final links = Maybe(json['links']).map(Link.mapFromJson).or(const {}); + final meta = json['meta']; + if (json.containsKey('data')) { + final data = json['data']; + if (data == null) { + return One.empty(links: links, meta: meta); + } + if (data is Map) { + return One(Identifier.fromJson(data), links: links, meta: meta); + } + if (data is List) { + return Many(data.map(Identifier.fromJson), links: links, meta: meta); + } + } + return IncompleteRelationship(links: links, meta: meta); + } + throw ArgumentError('A JSON:API relationship object must be a JSON object'); + } + + final Map links; + final Map meta; + final isSingular = false; + final isPlural = false; + final hasData = false; + + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; + + @override + Iterator get iterator => const [].iterator; + + /// Narrows the type down to R if possible. Otherwise throws the [TypeError]. + R as() => this is R ? this : throw TypeError(); +} + +class IncompleteRelationship extends Relationship { + IncompleteRelationship({Map links, Map meta}) + : super(links: links, meta: meta); +} + +class One extends Relationship { + One(Identifier identifier, + {Map links, Map meta}) + : _id = Just(identifier), + super(links: links, meta: meta); + + One.empty({Map links, Map meta}) + : _id = Nothing(), + super(links: links, meta: meta); + + final Maybe _id; + + @override + final isSingular = true; + + @override + Map toJson() => {...super.toJson(), 'data': _id.or(null)}; + + Identifier identifier({Identifier Function() ifEmpty}) => _id.orGet( + () => Maybe(ifEmpty).orThrow(() => StateError('Empty relationship'))()); + + @override + Iterator get iterator => + _id.map((_) => [_]).or(const []).iterator; +} + +class Many extends Relationship { + Many(Iterable identifiers, + {Map links, Map meta}) + : super(links: links, meta: meta) { + identifiers.forEach((_) => _map[_.key] = _); + } + + final _map = {}; + + @override + final isPlural = true; + + @override + Map toJson() => {...super.toJson(), 'data': _map.values}; + + @override + Iterator get iterator => _map.values.iterator; +} + +class Identifier with Identity { + Identifier(this.type, this.id, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}); + + static Identifier fromJson(Object json) { + if (json is Map) { + return Identifier(json['type'], json['id'], meta: json['meta']); + } + throw ArgumentError('A JSON:API identifier must be a JSON object'); + } + + @override + final String type; + + @override + final String id; + + final Map meta; + + Map toJson() => + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; +} + +mixin Identity { + String get type; + + String get id; + + String get key => '$type:$id'; +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 5456f6d3..f5f6fbc1 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/document.dart'; /// The JSON:API client class JsonApiClient { @@ -14,18 +14,16 @@ class JsonApiClient { Future fetchCollection(String type, {Map headers, Iterable include = const []}) async => - FetchCollectionResponse.fromHttp(await _call( - Request.fetchCollection(include: include), - _uri.collection(type), - headers)); + FetchCollectionResponse.fromHttp(await call( + Request.fetch(include: include), _uri.collection(type), headers)); /// Fetches a related resource collection by [type], [id], [relationship]. Future fetchRelatedCollection( String type, String id, String relationship, {Map headers, Iterable include = const []}) async => - FetchCollectionResponse.fromHttp(await _call( - Request.fetchCollection(include: include), + FetchCollectionResponse.fromHttp(await call( + Request.fetch(include: include), _uri.related(type, id, relationship), headers)); @@ -33,128 +31,119 @@ class JsonApiClient { Future fetchResource(String type, String id, {Map headers, Iterable include = const []}) async => - FetchPrimaryResourceResponse.fromHttp(await _call( - Request.fetchResource(include: include), - _uri.resource(type, id), - headers)); + FetchPrimaryResourceResponse.fromHttp(await call( + Request.fetch(include: include), _uri.resource(type, id), headers)); /// Fetches a related resource by [type], [id], [relationship]. Future fetchRelatedResource( String type, String id, String relationship, {Map headers, Iterable include = const []}) async => - FetchRelatedResourceResponse.fromHttp(await _call( - Request.fetchResource(include: include), + FetchRelatedResourceResponse.fromHttp(await call( + Request.fetch(include: include), _uri.related(type, id, relationship), headers)); /// Fetches a relationship by [type], [id], [relationship]. - Future fetchRelationship( - String type, String id, String relationship, - {Map headers = const {}}) async => - FetchRelationshipResponse.fromHttp(await _call( - Request.fetchRelationship(), - _uri.relationship(type, id, relationship), - headers)); + Future> + fetchRelationship( + String type, String id, String relationship, + {Map headers = const {}}) async => + FetchRelationshipResponse.fromHttp(await call(Request.fetch(), + _uri.relationship(type, id, relationship), headers)); /// Creates a new [resource] on the server. /// The server is expected to assign the resource id. - Future> createNewResource(String type, + Future createNewResource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) => - send( + Map one = const {}, + Map> many = const {}, + Map headers = const {}}) async => + CreateResourceResponse.fromHttp(await call( Request.createNewResource(type, attributes: attributes, one: one, many: many), _uri.collection(type), - headers: headers); + headers)); /// Creates a new [resource] on the server. /// The server is expected to accept the provided resource id. - Future> createResource(String type, String id, + Future createResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) => - send( + Map one = const {}, + Map> many = const {}, + Map headers = const {}}) async => + ResourceResponse.fromHttp(await call( Request.createResource(type, id, attributes: attributes, one: one, many: many), _uri.collection(type), - headers: headers); + headers)); /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, - {Map headers = const {}}) => - send(Request.deleteResource(), _uri.resource(type, id), headers: headers); + Future deleteResource(String type, String id, + {Map headers = const {}}) async => + DeleteResourceResponse.fromHttp(await call( + Request.deleteResource(), _uri.resource(type, id), headers)); /// Updates the [resource]. - Future> updateResource(String type, String id, + Future updateResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) => - send( + Map one = const {}, + Map> many = const {}, + Map headers = const {}}) async => + ResourceResponse.fromHttp(await call( Request.updateResource(type, id, attributes: attributes, one: one, many: many), _uri.resource(type, id), - headers: headers); + headers)); /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceOne( - String type, String id, String relationship, Ref identifier, - {Map headers = const {}}) => - send(Request.replaceOne(identifier), + Future> replaceOne( + String type, String id, String relationship, Identifier identifier, + {Map headers = const {}}) async => + RelationshipResponse.fromHttp(await call( + Request.replaceOne(identifier), _uri.relationship(type, id, relationship), - headers: headers); + headers)); /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteOne( + Future> deleteOne( String type, String id, String relationship, - {Map headers = const {}}) => - send(Request.deleteOne(), _uri.relationship(type, id, relationship), - headers: headers); + {Map headers = const {}}) async => + RelationshipResponse.fromHttp(await call(Request.deleteOne(), + _uri.relationship(type, id, relationship), headers)); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) => - send(Request.deleteMany(identifiers), + Future> deleteMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers = const {}}) async => + RelationshipResponse.fromHttp(await call( + Request.deleteMany(identifiers), _uri.relationship(type, id, relationship), - headers: headers); + headers)); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) => - send(Request.replaceMany(identifiers), + Future> replaceMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers = const {}}) async => + RelationshipResponse.fromHttp(await call( + Request.replaceMany(identifiers), _uri.relationship(type, id, relationship), - headers: headers); + headers)); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) => - send(Request.addMany(identifiers), + Future> addMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers = const {}}) async => + RelationshipResponse.fromHttp(await call( + Request.addMany(identifiers), _uri.relationship(type, id, relationship), - headers: headers); - - /// Sends the request to the [uri] via [handler] and returns the response. - /// Extra [headers] may be added to the request. - Future> send(Request request, Uri uri, - {Map headers = const {}}) async { - final response = await _call(request, uri, headers); - if (StatusCode(response.statusCode).isFailed) { - throw RequestFailure.decode(response); - } - return Response(response, request.decoder); - } + headers)); - Future _call( + Future call( Request request, Uri uri, Map headers) async { final response = await _http.call(_toHttp(request, uri, headers)); if (StatusCode(response.statusCode).isFailed) { - throw RequestFailure.decode(response); + throw RequestFailure.fromHttp(response); } return response; } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index c55c8a56..f3a06478 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,18 +1,18 @@ import 'dart:convert'; -import 'package:json_api/document.dart' as d; import 'package:json_api/query.dart'; import 'package:json_api/src/client/content_type.dart'; +import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/http/http_method.dart'; /// A JSON:API request. -class Request { - Request(this.method, this.decoder, {QueryParameters parameters}) +class Request { + Request(this.method, {QueryParameters parameters}) : headers = const {'Accept': ContentType.jsonApi}, body = '', parameters = parameters ?? QueryParameters.empty(); - Request.withDocument(Object document, this.method, this.decoder, + Request.withDocument(Object document, this.method, {QueryParameters parameters}) : headers = const { 'Accept': ContentType.jsonApi, @@ -21,77 +21,52 @@ class Request { body = jsonEncode(document), parameters = parameters ?? QueryParameters.empty(); - static Request fetchCollection( - {Iterable include = const []}) => - Request(HttpMethod.GET, d.ResourceCollectionData.fromJson, - parameters: Include(include)); + static Request fetch({Iterable include = const []}) => + Request(HttpMethod.GET, parameters: Include(include)); - static Request fetchResource( - {Iterable include = const []}) => - Request(HttpMethod.GET, d.ResourceData.fromJson, - parameters: Include(include)); - - static Request fetchOne() => - Request(HttpMethod.GET, d.ToOneObject.fromJson); - - static Request fetchMany() => - Request(HttpMethod.GET, d.ToManyObject.fromJson); - - static Request fetchRelationship() => - Request(HttpMethod.GET, d.RelationshipObject.fromJson); - - static Request createNewResource(String type, + static Request createNewResource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource(type, attributes: attributes, one: one, many: many), - HttpMethod.POST, - d.ResourceData.fromJson); + HttpMethod.POST); - static Request createResource(String type, String id, + static Request createResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource.withId(type, id, attributes: attributes, one: one, many: many), - HttpMethod.POST, - d.ResourceData.fromJson); + HttpMethod.POST); - static Request updateResource(String type, String id, + static Request updateResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => + Map one = const {}, + Map> many = const {}}) => Request.withDocument( _Resource.withId(type, id, attributes: attributes, one: one, many: many), - HttpMethod.PATCH, - d.ResourceData.fromJson); + HttpMethod.PATCH); - static Request deleteResource() => - Request(HttpMethod.DELETE, d.ResourceData.fromJson); + static Request deleteResource() => Request(HttpMethod.DELETE); - static Request replaceOne(Ref identifier) => - Request.withDocument( - _One(identifier), HttpMethod.PATCH, d.ToOneObject.fromJson); + static Request replaceOne(Identifier identifier) => + Request.withDocument(_One(identifier), HttpMethod.PATCH); - static Request deleteOne() => Request.withDocument( - _One(null), HttpMethod.PATCH, d.ToOneObject.fromJson); + static Request deleteOne() => + Request.withDocument(_One(null), HttpMethod.PATCH); - static Request deleteMany(Iterable identifiers) => - Request.withDocument( - _Many(identifiers), HttpMethod.DELETE, d.ToManyObject.fromJson); + static Request deleteMany(Iterable identifiers) => + Request.withDocument(_Many(identifiers), HttpMethod.DELETE); - static Request replaceMany(Iterable identifiers) => - Request.withDocument( - _Many(identifiers), HttpMethod.PATCH, d.ToManyObject.fromJson); + static Request replaceMany(Iterable identifiers) => + Request.withDocument(_Many(identifiers), HttpMethod.PATCH); - static Request addMany(Iterable identifiers) => - Request.withDocument( - _Many(identifiers), HttpMethod.POST, d.ToManyObject.fromJson); + static Request addMany(Iterable identifiers) => + Request.withDocument(_Many(identifiers), HttpMethod.POST); - final d.PrimaryDataDecoder decoder; final String method; final String body; final Map headers; @@ -101,8 +76,8 @@ class Request { class _Resource { _Resource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) + Map one = const {}, + Map> many = const {}}) : _resource = { 'type': type, if (attributes.isNotEmpty) 'attributes': attributes, @@ -111,8 +86,8 @@ class _Resource { _Resource.withId(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) + Map one = const {}, + Map> many = const {}}) : _resource = { 'type': type, 'id': id, @@ -120,8 +95,8 @@ class _Resource { ...relationship(one, many) }; - static Map relationship( - Map one, Map> many) => + static Map relationship(Map one, + Map> many) => { if (one.isNotEmpty || many.isNotEmpty) 'relationships': { @@ -138,7 +113,7 @@ class _Resource { class _One { _One(this._ref); - final Ref _ref; + final Identifier _ref; Map toJson() => {'data': _ref}; } @@ -146,25 +121,9 @@ class _One { class _Many { _Many(this._refs); - final Iterable _refs; + final Iterable _refs; Map toJson() => { 'data': _refs.toList(), }; } - -class Ref { - Ref(this.type, this.id) { - ArgumentError.checkNotNull(type, 'type'); - ArgumentError.checkNotNull(id, 'id'); - } - - final String type; - - final String id; - - Map toJson() => { - 'type': type, - 'id': id, - }; -} diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index f1395b1f..389f468b 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,55 +1,11 @@ import 'dart:collection'; -import 'dart:convert'; import 'package:json_api/client.dart'; -import 'package:json_api/document.dart' as d; import 'package:json_api/http.dart'; +import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/maybe.dart'; -/// A JSON:API response -class Response { - Response(this.http, this._decoder); - - final d.PrimaryDataDecoder _decoder; - - /// The HTTP response. - final HttpResponse http; - - /// Returns the Document parsed from the response body. - /// Throws a [StateError] if the HTTP response contains empty body. - /// Throws a [DocumentException] if the received document structure is invalid. - /// Throws a [FormatException] if the received JSON is invalid. - d.Document decodeDocument() { - if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); - return d.Document.fromJson(jsonDecode(http.body), _decoder); - } - - /// Returns the async Document parsed from the response body. - /// Throws a [StateError] if the HTTP response contains empty body. - /// Throws a [DocumentException] if the received document structure is invalid. - /// Throws a [FormatException] if the received JSON is invalid. - d.Document decodeAsyncDocument() { - if (http.body.isEmpty) throw StateError('The HTTP response has empty body'); - return d.Document.fromJson(jsonDecode(http.body), d.ResourceData.fromJson); - } - - /// Was the query successful? - /// - /// For pending (202 Accepted) requests both [isSuccessful] and [isFailed] - /// are always false. - bool get isSuccessful => StatusCode(http.statusCode).isSuccessful; - - /// This property is an equivalent of `202 Accepted` HTTP status. - /// It indicates that the query is accepted but not finished yet (e.g. queued). - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - bool get isAsync => StatusCode(http.statusCode).isPending; - - /// Any non 2** status code is considered a failed operation. - /// For failed requests, [document] is expected to contain [ErrorDocument] - bool get isFailed => StatusCode(http.statusCode).isFailed; -} - class FetchCollectionResponse with IterableMixin { FetchCollectionResponse(this.http, {ResourceCollection resources, @@ -60,20 +16,11 @@ class FetchCollectionResponse with IterableMixin { included = included ?? ResourceCollection(const []); static FetchCollectionResponse fromHttp(HttpResponse http) { - final json = jsonDecode(http.body); - if (json is Map) { - final resources = json['data']; - if (resources is List) { - final included = json['included']; - final links = json['links']; - return FetchCollectionResponse(http, - resources: ResourceCollection(resources.map(Resource.fromJson)), - included: ResourceCollection( - included is List ? included.map(Resource.fromJson) : const []), - links: links is Map ? Link.mapFromJson(links) : const {}); - } - } - throw ArgumentError('Can not parse Resource collection'); + final document = ResponseDocument.decode(http.body); + return FetchCollectionResponse(http, + resources: document.resources, + included: document.included, + links: document.links); } final HttpResponse http; @@ -92,16 +39,9 @@ class FetchPrimaryResourceResponse { included = included ?? ResourceCollection(const []); static FetchPrimaryResourceResponse fromHttp(HttpResponse http) { - final json = jsonDecode(http.body); - if (json is Map) { - final included = json['included']; - final links = json['links']; - return FetchPrimaryResourceResponse(http, Resource.fromJson(json['data']), - included: ResourceCollection( - included is List ? included.map(Resource.fromJson) : const []), - links: links is Map ? Link.mapFromJson(links) : const {}); - } - throw ArgumentError('Can not parse Resource response'); + final document = ResponseDocument.decode(http.body); + return FetchPrimaryResourceResponse(http, document.resource, + included: document.included, links: document.links); } final HttpResponse http; @@ -110,411 +50,137 @@ class FetchPrimaryResourceResponse { final Map links; } -class FetchRelationshipResponse { - FetchRelationshipResponse(this.http, this.relationship); +class CreateResourceResponse { + CreateResourceResponse(this.http, this.resource, + {Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}); - static FetchRelationshipResponse fromHttp(HttpResponse http) { - final json = jsonDecode(http.body); - if (json is Map) { - return FetchRelationshipResponse( - http, - Relationship.fromJson(json), - ); - } - throw ArgumentError('Can not parse Relationship response'); + static CreateResourceResponse fromHttp(HttpResponse http) { + final document = ResponseDocument.decode(http.body); + return CreateResourceResponse(http, document.resource, + links: document.links); } final HttpResponse http; - final Relationship relationship; - - Map get links => relationship.links; + final Map links; + final Resource resource; } -class FetchRelatedResourceResponse { - FetchRelatedResourceResponse(this.http, Resource resource, - {ResourceCollection included, Map links = const {}}) +class ResourceResponse { + ResourceResponse(this.http, Resource resource, + {Map links = const {}}) : _resource = Just(resource), - links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + links = Map.unmodifiable(links ?? const {}); - FetchRelatedResourceResponse.empty(this.http, - {ResourceCollection included, Map links = const {}}) + ResourceResponse.empty(this.http) : _resource = Nothing(), - links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + links = const {}; - static FetchRelatedResourceResponse fromHttp(HttpResponse http) { - final json = jsonDecode(http.body); - if (json is Map) { - final included = ResourceCollection(Maybe(json['included']) - .map((t) => t is List ? t : throw ArgumentError('List expected')) - .map((t) => t.map(Resource.fromJson)) - .or(const [])); - final links = Maybe(json['links']) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .map(Link.mapFromJson) - .or(const {}); - return Maybe(json['data']) - .map(Resource.fromJson) - .map((resource) => FetchRelatedResourceResponse(http, resource, - included: included, links: links)) - .orGet(() => FetchRelatedResourceResponse.empty(http, - included: included, links: links)); + static ResourceResponse fromHttp(HttpResponse http) { + if (StatusCode(http.statusCode).isNoContent) { + return ResourceResponse.empty(http); } - throw ArgumentError('Can not parse Resource response'); + final document = ResponseDocument.decode(http.body); + return ResourceResponse(http, document.resource, links: document.links); } final HttpResponse http; + final Map links; final Maybe _resource; - Resource resource({Resource Function() orElse}) => _resource.orGet(() => - Maybe(orElse).orThrow(() => StateError('Related resource is empty'))()); - final ResourceCollection included; - final Map links; + Resource resource({Resource Function() orElse}) => _resource.orGet( + () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); } -class RequestFailure { - RequestFailure(this.http, {Iterable errors = const []}) - : errors = List.unmodifiable(errors ?? const []); - final List errors; +class DeleteResourceResponse { + DeleteResourceResponse(this.http, Map meta) + : meta = Map.unmodifiable(meta); - static RequestFailure decode(HttpResponse http) => Maybe(http.body) - .filter((_) => _.isNotEmpty) - .map(jsonDecode) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .map((_) => _['errors']) - .map((_) => _ is List ? _ : throw ArgumentError('List expected')) - .map((_) => _.map(ErrorObject.fromJson)) - .map((_) => RequestFailure(http, errors: _)) - .orGet(() => RequestFailure(http)); + DeleteResourceResponse.empty(this.http) : meta = const {}; - final HttpResponse http; -} - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - ErrorSource source, - Map links, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - source = source ?? ErrorSource(), - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: Maybe(json['source']) - .map(ErrorSource.fromJson) - .orGet(() => ErrorSource()), - meta: json['meta'], - links: Maybe(json['links']).map(Link.mapFromJson).orGet(() => {})); + static DeleteResourceResponse fromHttp(HttpResponse http) { + if (StatusCode(http.statusCode).isNoContent) { + return DeleteResourceResponse.empty(http); } - throw ArgumentError('A JSON:API error must be a JSON object'); + final document = ResponseDocument.decode(http.body); + return DeleteResourceResponse(http, document.meta); } - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// The `source` object. - final ErrorSource source; - + final HttpResponse http; final Map meta; - final Map links; - - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } } -/// An object containing references to the source of the error, optionally including any of the following members: -/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, -/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. -/// - parameter: a string indicating which URI query parameter caused the error. -class ErrorSource { - ErrorSource({String pointer, String parameter}) - : pointer = pointer ?? '', - parameter = parameter ?? ''; - - static ErrorSource fromJson(Object json) { - if (json is Map) { - return ErrorSource( - pointer: json['pointer'], parameter: json['parameter']); - } - throw ArgumentError('Can not parse ErrorSource'); - } - - final String pointer; - - final String parameter; +class FetchRelationshipResponse { + FetchRelationshipResponse(this.http, this.relationship); - bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + static FetchRelationshipResponse fromHttp( + HttpResponse http) => + FetchRelationshipResponse( + http, ResponseDocument.decode(http.body).relationship.as()); - Map toJson() => { - if (pointer.isNotEmpty) 'pointer': pointer, - if (parameter.isNotEmpty) 'parameter': parameter - }; + final HttpResponse http; + final R relationship; } -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { - ArgumentError.checkNotNull(uri, 'uri'); - } +class RelationshipResponse { + RelationshipResponse(this.http, R relationship) + : _relationship = Just(relationship); - final Uri uri; - final Map meta; + RelationshipResponse.empty(this.http) : _relationship = Nothing(); - /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - return Link(Uri.parse(json['href']), meta: json['meta']); - } - throw ArgumentError( - 'A JSON:API link must be a JSON string or a JSON object'); - } + static RelationshipResponse fromHttp( + HttpResponse http) => + http.body.isEmpty + ? RelationshipResponse.empty(http) + : RelationshipResponse( + http, ResponseDocument.decode(http.body).relationship.as()); - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); - } - throw ArgumentError('A JSON:API links object must be a JSON object'); - } - - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + final HttpResponse http; + final Maybe _relationship; - @override - String toString() => uri.toString(); + R relationship({R Function() orElse}) => _relationship.orGet( + () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); } -class ResourceCollection with IterableMixin { - ResourceCollection(Iterable resources) - : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); - - final Map _map; +class FetchRelatedResourceResponse { + FetchRelatedResourceResponse(this.http, Resource resource, + {ResourceCollection included, Map links = const {}}) + : _resource = Just(resource), + links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); - @override - Iterator get iterator => _map.values.iterator; -} + FetchRelatedResourceResponse.empty(this.http, + {ResourceCollection included, Map links = const {}}) + : _resource = Nothing(), + links = Map.unmodifiable(links ?? const {}), + included = included ?? ResourceCollection(const []); -class Resource with Identity { - Resource(this.type, this.id, - {Map links, - Map meta, - Map attributes, - Map relationships}) - : links = Map.unmodifiable(links ?? {}), - meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}), - attributes = Map.unmodifiable(attributes ?? {}); - - static Resource fromJson(Object json) { - if (json is Map) { - final relationships = json['relationships']; - final attributes = json['attributes']; - final type = json['type']; - if ((relationships == null || relationships is Map) && - (attributes == null || attributes is Map) && - type is String && - type.isNotEmpty) { - return Resource(json['type'], json['id'], - attributes: attributes, - relationships: Maybe(relationships) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .map((t) => t.map((key, value) => - MapEntry(key.toString(), Relationship.fromJson(value)))) - .orGet(() => {}), - links: Link.mapFromJson(json['links'] ?? {}), - meta: json['meta']); - } - throw ArgumentError('Invalid JSON:API resource object'); + static FetchRelatedResourceResponse fromHttp(HttpResponse http) { + final document = ResponseDocument.decode(http.body); + if (document.hasData) { + return FetchRelatedResourceResponse(http, document.resource, + included: document.included, links: document.links); } - throw ArgumentError('A JSON:API resource must be a JSON object'); + return FetchRelatedResourceResponse.empty(http, + included: document.included, links: document.links); } - @override - final String type; - @override - final String id; - final Map links; - final Map meta; - final Map attributes; - final Map relationships; - - Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) - .filter((_) => _ is Many) - .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); - - One one(String key, {One Function() orElse}) => Maybe(relationships[key]) - .filter((_) => _ is One) - .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); -} - -class Relationship with IterableMixin { - Relationship({Map links, Map meta}) - : links = Map.unmodifiable(links ?? {}), - meta = Map.unmodifiable(meta ?? {}); - - /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(Object json) { - if (json is Map) { - final links = Maybe(json['links']).map(Link.mapFromJson).or(const {}); - final meta = json['meta']; - if (json.containsKey('data')) { - final data = json['data']; - if (data == null) { - return One.empty(links: links, meta: meta); - } - if (data is Map) { - return One(Identifier.fromJson(data), links: links, meta: meta); - } - if (data is List) { - return Many(data.map(Identifier.fromJson), links: links, meta: meta); - } - } - return Relationship(links: links, meta: meta); - } - throw ArgumentError('A JSON:API relationship object must be a JSON object'); - } + final HttpResponse http; + final Maybe _resource; + Resource resource({Resource Function() orElse}) => _resource.orGet(() => + Maybe(orElse).orThrow(() => StateError('Related resource is empty'))()); + final ResourceCollection included; final Map links; - final Map meta; - final isSingular = false; - final isPlural = false; - final hasData = false; - - Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; - - @override - Iterator get iterator => const [].iterator; -} - -class One extends Relationship { - One(Identifier identifier, - {Map links, Map meta}) - : _id = Just(identifier), - super(links: links, meta: meta); - - One.empty({Map links, Map meta}) - : _id = Nothing(), - super(links: links, meta: meta); - - final Maybe _id; - - @override - final isSingular = true; - - @override - Map toJson() => {...super.toJson(), 'data': _id.or(null)}; - - Identifier identifier({Identifier Function() ifEmpty}) => _id.orGet( - () => Maybe(ifEmpty).orThrow(() => StateError('Empty relationship'))()); - - @override - Iterator get iterator => - _id.map((_) => [_]).or(const []).iterator; -} - -class Many extends Relationship { - Many(Iterable identifiers, - {Map links, Map meta}) - : super(links: links, meta: meta) { - identifiers.forEach((_) => _map[_.key] = _); - } - - final _map = {}; - - @override - final isPlural = true; - - @override - Map toJson() => {...super.toJson(), 'data': _map.values}; - - @override - Iterator get iterator => _map.values.iterator; } -class Identifier with Identity { - Identifier(this.type, this.id, {Map meta}) - : meta = Map.unmodifiable(meta ?? {}); - - static Identifier fromJson(Object json) { - if (json is Map) { - return Identifier(json['type'], json['id'], meta: json['meta']); - } - throw ArgumentError('A JSON:API identifier must be a JSON object'); - } - - @override - final String type; - - @override - final String id; - - final Map meta; -} - -mixin Identity { - String get type; +class RequestFailure { + RequestFailure(this.http, {Iterable errors = const []}) + : errors = List.unmodifiable(errors ?? const []); + final List errors; - String get id; + static RequestFailure fromHttp(HttpResponse http) => + RequestFailure(http, errors: ResponseDocument.decode(http.body).errors); - String get key => '$type:$id'; + final HttpResponse http; } diff --git a/lib/src/client/status_code.dart b/lib/src/client/status_code.dart index e2971bfe..50c37df1 100644 --- a/lib/src/client/status_code.dart +++ b/lib/src/client/status_code.dart @@ -14,4 +14,6 @@ class StatusCode { /// True for failed requests bool get isFailed => !isSuccessful && !isPending; + + bool get isNoContent => code == 204; } diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 0b0b247d..43267180 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -28,7 +28,7 @@ void main() async { await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client.updateResource('books', '2', many: {'authors': []}); - await client.addMany('books', '2', 'authors', [Ref('writers', '1')]); + await client.addMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart index 4107bd7f..c0b0d812 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/test/e2e/client_server_interaction_test.dart @@ -39,7 +39,7 @@ void main() { await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client.updateResource('books', '2', many: {'authors': []}); - await client.addMany('books', '2', 'authors', [Ref('writers', '1')]); + await client.addMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index a1d020fd..15db1032 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -4,8 +4,6 @@ import 'package:json_api/server.dart'; import 'package:test/test.dart'; import 'package:uuid/uuid.dart'; -import '../../helper/expect_same_json.dart'; - void main() async { final host = 'localhost'; final port = 80; @@ -25,16 +23,15 @@ void main() async { expect(r.http.statusCode, 201); expect(r.http.headers['content-type'], ContentType.jsonApi); expect(r.http.headers['location'], isNotNull); - expect(r.http.headers['location'], - r.decodeDocument().data.links['self'].uri.toString()); - final created = r.decodeDocument().data.unwrap(); + expect(r.http.headers['location'], r.links['self'].uri.toString()); + final created = r.resource; expect(created.type, 'people'); expect(created.id, isNotNull); expect(created.attributes, equals({'name': 'Martin Fowler'})); - final r1 = await client.send( - Request.fetchResource(), Uri.parse(r.http.headers['location'])); - expect(r1.http.statusCode, 200); - expectSameJson(r1.decodeDocument().data.unwrap(), created); + final r1 = await client.fetchResource(created.type, created.id); + expect(r1.resource.type, 'people'); + expect(r1.resource.id, isNotNull); + expect(r1.resource.attributes, equals({'name': 'Martin Fowler'})); }); test('403 when the id can not be generated', () async { @@ -72,7 +69,6 @@ void main() async { test('204 No Content', () async { final r = await client.createResource('people', '123', attributes: {'name': 'Martin Fowler'}); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); expect(r.http.headers['location'], isNull); final r1 = await client.fetchResource('people', '123'); @@ -96,7 +92,7 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { try { await client.createNewResource('books', - one: {'publisher': Ref('companies', '123')}); + one: {'publisher': Identifier('companies', '123')}); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -111,7 +107,7 @@ void main() async { test('404 when the related resource does not exist (to-many)', () async { try { await client.createNewResource('books', many: { - 'authors': [Ref('people', '123')] + 'authors': [Identifier('people', '123')] }); fail('Exception expected'); } on RequestFailure catch (e) { diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart index d95ff9e1..64933c25 100644 --- a/test/functional/crud/deleting_resources_test.dart +++ b/test/functional/crud/deleting_resources_test.dart @@ -1,5 +1,4 @@ import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/in_memory_repository.dart'; @@ -28,7 +27,6 @@ void main() async { test('successful', () async { final r = await client.deleteResource('books', '1'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); try { await client.fetchResource('books', '1'); diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index 4dbbba15..3954de5c 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -14,9 +14,9 @@ Future seedResources(JsonApiClient client) async { 'title': 'Refactoring', 'ISBN-10': '0134757599' }, one: { - 'publisher': Ref('companies', '1'), + 'publisher': Identifier('companies', '1'), 'reviewer': null, }, many: { - 'authors': [Ref('people', '1'), Ref('people', '2')] + 'authors': [Identifier('people', '1'), Identifier('people', '2')] }); } diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart index eb487709..06e93ab5 100644 --- a/test/functional/crud/updating_relationships_test.dart +++ b/test/functional/crud/updating_relationships_test.dart @@ -28,8 +28,7 @@ void main() async { group('Updating a to-one relationship', () { test('204 No Content', () async { final r = await client.replaceOne( - 'books', '1', 'publisher', Ref('companies', '2')); - expect(r.isSuccessful, isTrue); + 'books', '1', 'publisher', Identifier('companies', '2')); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); @@ -39,7 +38,7 @@ void main() async { test('404 on collection', () async { try { await client.replaceOne( - 'unicorns', '1', 'breed', Ref('companies', '2')); + 'unicorns', '1', 'breed', Identifier('companies', '2')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -53,7 +52,7 @@ void main() async { test('404 on resource', () async { try { await client.replaceOne( - 'books', '42', 'publisher', Ref('companies', '2')); + 'books', '42', 'publisher', Identifier('companies', '2')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -69,7 +68,6 @@ void main() async { group('Deleting a to-one relationship', () { test('204 No Content', () async { final r = await client.deleteOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); @@ -107,8 +105,7 @@ void main() async { group('Replacing a to-many relationship', () { test('204 No Content', () async { final r = await client - .replaceMany('books', '1', 'authors', [Ref('people', '1')]); - expect(r.isSuccessful, isTrue); + .replaceMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.http.statusCode, 204); final r1 = await client.fetchResource('books', '1'); @@ -147,13 +144,12 @@ void main() async { group('Adding to a to-many relationship', () { test('successfully adding a new identifier', () async { final r = - await client.addMany('books', '1', 'authors', [Ref('people', '3')]); - expect(r.isSuccessful, isTrue); + await client.addMany('books', '1', 'authors', [Identifier('people', '3')]); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.linkage.length, 3); - expect(r.decodeDocument().data.linkage.first.id, '1'); - expect(r.decodeDocument().data.linkage.last.id, '3'); + expect(r.relationship().length, 3); + expect(r.relationship().first.id, '1'); + expect(r.relationship().last.id, '3'); final r1 = await client.fetchResource('books', '1'); expect(r1.resource.many('authors').length, 3); @@ -161,13 +157,12 @@ void main() async { test('successfully adding an existing identifier', () async { final r = - await client.addMany('books', '1', 'authors', [Ref('people', '2')]); - expect(r.isSuccessful, isTrue); + await client.addMany('books', '1', 'authors', [Identifier('people', '2')]); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.linkage.length, 2); - expect(r.decodeDocument().data.linkage.first.id, '1'); - expect(r.decodeDocument().data.linkage.last.id, '2'); + expect(r.relationship().length, 2); + expect(r.relationship().first.id, '1'); + expect(r.relationship().last.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.resource.many('authors').length, 2); @@ -219,12 +214,11 @@ void main() async { group('Deleting from a to-many relationship', () { test('successfully deleting an identifier', () async { final r = await client - .deleteMany('books', '1', 'authors', [Ref('people', '1')]); - expect(r.isSuccessful, isTrue); + .deleteMany('books', '1', 'authors', [Identifier('people', '1')]); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.linkage.length, 1); - expect(r.decodeDocument().data.linkage.first.id, '2'); + expect(r.relationship().length, 1); + expect(r.relationship().first.id, '2'); final r1 = await client.fetchResource('books', '1'); expect(r1.resource.many('authors').length, 1); diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index ace0fb0c..5417e9df 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -6,7 +6,6 @@ import 'package:json_api/src/server/json_api_server.dart'; import 'package:json_api/src/server/repository_controller.dart'; import 'package:test/test.dart'; -import '../../helper/expect_same_json.dart'; import 'seed_resources.dart'; void main() async { @@ -33,32 +32,26 @@ void main() async { }, one: { 'publisher': null, }, many: { - 'authors': [Ref('people', '1')], - 'reviewers': [Ref('people', '2')] + 'authors': [Identifier('people', '1')], + 'reviewers': [Identifier('people', '2')] }); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.decodeDocument().data.unwrap().attributes['title'], + expect(r.resource().attributes['title'], 'Refactoring. Improving the Design of Existing Code'); - expect(r.decodeDocument().data.unwrap().attributes['pages'], 448); + expect(r.resource().attributes['pages'], 448); + expect(r.resource().attributes['ISBN-10'], '0134757599'); + expect(r.resource().one('publisher').isEmpty, true); + expect(r.resource().many('authors').toList().first.key, equals('people:1')); expect( - r.decodeDocument().data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.decodeDocument().data.unwrap().one('publisher').isEmpty, true); - expect(r.decodeDocument().data.unwrap().many('authors').toList().first.key, - equals('people:1')); - expect( - r.decodeDocument().data.unwrap().many('reviewers').toList().first.key, - equals('people:2')); + r.resource().many('reviewers').toList().first.key, equals('people:2')); final r1 = await client.fetchResource('books', '1'); - expect( - r1.resource.attributes, r.decodeDocument().data.unwrap().attributes); + expect(r1.resource.attributes, r.resource().attributes); }); test('204 No Content', () async { final r = await client.updateResource('books', '1'); - expect(r.isSuccessful, isTrue); expect(r.http.statusCode, 204); }); diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart deleted file mode 100644 index 98dd9899..00000000 --- a/test/unit/client/async_processing_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart' as d; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/response_factory.dart'; -import 'package:test/test.dart'; - -import '../../helper/test_http_handler.dart'; - -void main() { - final handler = TestHttpHandler(); - final routing = StandardRouting(); - final client = JsonApiClient(handler, routing); - final responseFactory = HttpResponseFactory(routing); - - test('Client understands async responses', () async { - handler.response = responseFactory.accepted(d.Resource('jobs', '42')); - - final r = await client.createResource('books', '1'); - expect(r.isAsync, true); - expect(r.isSuccessful, false); - expect(r.isFailed, false); - expect(r.decodeAsyncDocument().data.unwrap().type, 'jobs'); - expect(r.decodeAsyncDocument().data.unwrap().id, '42'); - expect(r.http.headers['content-location'], '/jobs/42'); - }); -} From 439fe4e7a0e1eb867c8b9d7ae9e981118ef1daec Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 10 May 2020 16:29:12 -0700 Subject: [PATCH 70/99] wip --- lib/src/client/json_api_client.dart | 207 +++++++++++++++------------- lib/src/client/request.dart | 133 +++++++++--------- lib/src/query/fields.dart | 4 + lib/src/query/page.dart | 3 + lib/src/query/query_parameters.dart | 6 +- 5 files changed, 190 insertions(+), 163 deletions(-) diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index f5f6fbc1..e1aaec07 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -2,6 +2,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/client/document.dart'; +import 'package:json_api/src/maybe.dart'; /// The JSON:API client class JsonApiClient { @@ -12,143 +13,165 @@ class JsonApiClient { /// Fetches a primary resource collection by [type]. Future fetchCollection(String type, - {Map headers, - Iterable include = const []}) async => - FetchCollectionResponse.fromHttp(await call( - Request.fetch(include: include), _uri.collection(type), headers)); + {Map headers, Iterable include}) async { + final request = JsonApiRequest.fetch(); + Maybe(headers).ifPresent(request.headers); + Maybe(include).ifPresent(request.include); + return FetchCollectionResponse.fromHttp( + await call(request, _uri.collection(type))); + } /// Fetches a related resource collection by [type], [id], [relationship]. Future fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, - Iterable include = const []}) async => - FetchCollectionResponse.fromHttp(await call( - Request.fetch(include: include), - _uri.related(type, id, relationship), - headers)); + String type, String id, String relationship, + {Map headers, Iterable include}) async { + final request = JsonApiRequest.fetch(); + Maybe(headers).ifPresent(request.headers); + Maybe(include).ifPresent(request.include); + return FetchCollectionResponse.fromHttp( + await call(request, _uri.related(type, id, relationship))); + } /// Fetches a primary resource by [type] and [id]. Future fetchResource(String type, String id, - {Map headers, - Iterable include = const []}) async => - FetchPrimaryResourceResponse.fromHttp(await call( - Request.fetch(include: include), _uri.resource(type, id), headers)); + {Map headers, Iterable include}) async { + final request = JsonApiRequest.fetch(); + Maybe(headers).ifPresent(request.headers); + Maybe(include).ifPresent(request.include); + return FetchPrimaryResourceResponse.fromHttp( + await call(request, _uri.resource(type, id))); + } /// Fetches a related resource by [type], [id], [relationship]. Future fetchRelatedResource( - String type, String id, String relationship, - {Map headers, - Iterable include = const []}) async => - FetchRelatedResourceResponse.fromHttp(await call( - Request.fetch(include: include), - _uri.related(type, id, relationship), - headers)); + String type, String id, String relationship, + {Map headers, Iterable include}) async { + final request = JsonApiRequest.fetch(); + Maybe(headers).ifPresent(request.headers); + Maybe(include).ifPresent(request.include); + return FetchRelatedResourceResponse.fromHttp( + await call(request, _uri.related(type, id, relationship))); + } /// Fetches a relationship by [type], [id], [relationship]. Future> fetchRelationship( - String type, String id, String relationship, - {Map headers = const {}}) async => - FetchRelationshipResponse.fromHttp(await call(Request.fetch(), - _uri.relationship(type, id, relationship), headers)); + String type, String id, String relationship, + {Map headers = const {}}) async { + final request = JsonApiRequest.fetch(); + Maybe(headers).ifPresent(request.headers); + return FetchRelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } /// Creates a new [resource] on the server. /// The server is expected to assign the resource id. Future createNewResource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) async => - CreateResourceResponse.fromHttp(await call( - Request.createNewResource(type, - attributes: attributes, one: one, many: many), - _uri.collection(type), - headers)); + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async { + final request = JsonApiRequest.createNewResource(type, + attributes: attributes, one: one, many: many); + Maybe(headers).ifPresent(request.headers); + return CreateResourceResponse.fromHttp( + await call(request, _uri.collection(type))); + } /// Creates a new [resource] on the server. /// The server is expected to accept the provided resource id. Future createResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) async => - ResourceResponse.fromHttp(await call( - Request.createResource(type, id, - attributes: attributes, one: one, many: many), - _uri.collection(type), - headers)); + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async { + final request = JsonApiRequest.createResource(type, id, + attributes: attributes, one: one, many: many); + Maybe(headers).ifPresent(request.headers); + return ResourceResponse.fromHttp( + await call(request, _uri.collection(type))); + } /// Deletes the resource by [type] and [id]. Future deleteResource(String type, String id, - {Map headers = const {}}) async => - DeleteResourceResponse.fromHttp(await call( - Request.deleteResource(), _uri.resource(type, id), headers)); + {Map headers}) async { + final request = JsonApiRequest.deleteResource(); + Maybe(headers).ifPresent(request.headers); + return DeleteResourceResponse.fromHttp( + await call(request, _uri.resource(type, id))); + } /// Updates the [resource]. Future updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers = const {}}) async => - ResourceResponse.fromHttp(await call( - Request.updateResource(type, id, - attributes: attributes, one: one, many: many), - _uri.resource(type, id), - headers)); + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async { + final request = JsonApiRequest.updateResource(type, id, + attributes: attributes, one: one, many: many); + Maybe(headers).ifPresent(request.headers); + return ResourceResponse.fromHttp( + await call(request, _uri.resource(type, id))); + } /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceOne( - String type, String id, String relationship, Identifier identifier, - {Map headers = const {}}) async => - RelationshipResponse.fromHttp(await call( - Request.replaceOne(identifier), - _uri.relationship(type, id, relationship), - headers)); + String type, String id, String relationship, Identifier identifier, + {Map headers}) async { + final request = JsonApiRequest.replaceOne(identifier); + Maybe(headers).ifPresent(request.headers); + return RelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } /// Deletes the to-one [relationship] of [type] : [id]. Future> deleteOne( - String type, String id, String relationship, - {Map headers = const {}}) async => - RelationshipResponse.fromHttp(await call(Request.deleteOne(), - _uri.relationship(type, id, relationship), headers)); + String type, String id, String relationship, + {Map headers}) async { + final request = JsonApiRequest.deleteOne(); + Maybe(headers).ifPresent(request.headers); + return RelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) async => - RelationshipResponse.fromHttp(await call( - Request.deleteMany(identifiers), - _uri.relationship(type, id, relationship), - headers)); + String relationship, Iterable identifiers, + {Map headers}) async { + final request = JsonApiRequest.deleteMany(identifiers); + Maybe(headers).ifPresent(request.headers); + return RelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) async => - RelationshipResponse.fromHttp(await call( - Request.replaceMany(identifiers), - _uri.relationship(type, id, relationship), - headers)); + String relationship, Iterable identifiers, + {Map headers}) async { + final request = JsonApiRequest.replaceMany(identifiers); + Maybe(headers).ifPresent(request.headers); + return RelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. Future> addMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) async => - RelationshipResponse.fromHttp(await call( - Request.addMany(identifiers), - _uri.relationship(type, id, relationship), - headers)); - - Future call( - Request request, Uri uri, Map headers) async { - final response = await _http.call(_toHttp(request, uri, headers)); + String relationship, Iterable identifiers, + {Map headers = const {}}) async { + final request = JsonApiRequest.addMany(identifiers); + Maybe(headers).ifPresent(request.headers); + return RelationshipResponse.fromHttp( + await call(request, _uri.relationship(type, id, relationship))); + } + + /// Sends the [request] to [uri]. + /// If the response is successful, returns the [HttpResponse]. + /// Otherwise, throws a [RequestFailure]. + Future call(JsonApiRequest request, Uri uri) async { + final response = await _http.call(request.toHttp(uri)); if (StatusCode(response.statusCode).isFailed) { throw RequestFailure.fromHttp(response); } return response; } - - HttpRequest _toHttp(Request request, Uri uri, Map headers) => - HttpRequest(request.method, request.parameters.addToUri(uri), - body: request.body, headers: {...?headers, ...request.headers}); } diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index f3a06478..b9a75fa9 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,76 +1,77 @@ import 'dart:convert'; +import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/client/content_type.dart'; import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/http/http_method.dart'; +import 'package:json_api/src/maybe.dart'; -/// A JSON:API request. -class Request { - Request(this.method, {QueryParameters parameters}) - : headers = const {'Accept': ContentType.jsonApi}, - body = '', - parameters = parameters ?? QueryParameters.empty(); - - Request.withDocument(Object document, this.method, - {QueryParameters parameters}) - : headers = const { - 'Accept': ContentType.jsonApi, - 'Content-Type': ContentType.jsonApi - }, - body = jsonEncode(document), - parameters = parameters ?? QueryParameters.empty(); - - static Request fetch({Iterable include = const []}) => - Request(HttpMethod.GET, parameters: Include(include)); - - static Request createNewResource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => - Request.withDocument( - _Resource(type, attributes: attributes, one: one, many: many), - HttpMethod.POST); - - static Request createResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => - Request.withDocument( - _Resource.withId(type, id, - attributes: attributes, one: one, many: many), - HttpMethod.POST); - - static Request updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) => - Request.withDocument( - _Resource.withId(type, id, - attributes: attributes, one: one, many: many), - HttpMethod.PATCH); - - static Request deleteResource() => Request(HttpMethod.DELETE); - - static Request replaceOne(Identifier identifier) => - Request.withDocument(_One(identifier), HttpMethod.PATCH); - - static Request deleteOne() => - Request.withDocument(_One(null), HttpMethod.PATCH); - - static Request deleteMany(Iterable identifiers) => - Request.withDocument(_Many(identifiers), HttpMethod.DELETE); - - static Request replaceMany(Iterable identifiers) => - Request.withDocument(_Many(identifiers), HttpMethod.PATCH); - - static Request addMany(Iterable identifiers) => - Request.withDocument(_Many(identifiers), HttpMethod.POST); - - final String method; - final String body; - final Map headers; - final QueryParameters parameters; +class JsonApiRequest { + JsonApiRequest(this._method, {Object document}) + : _body = Maybe(document).map(jsonEncode).or(''); + + JsonApiRequest.fetch() : this(HttpMethod.GET); + + JsonApiRequest.createNewResource(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) + : this(HttpMethod.POST, + document: + _Resource(type, attributes: attributes, one: one, many: many)); + + JsonApiRequest.createResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) + : this(HttpMethod.POST, + document: _Resource.withId(type, id, + attributes: attributes, one: one, many: many)); + + JsonApiRequest.updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}}) + : this(HttpMethod.PATCH, + document: _Resource.withId(type, id, + attributes: attributes, one: one, many: many)); + + JsonApiRequest.deleteResource() : this(HttpMethod.DELETE); + + JsonApiRequest.replaceOne(Identifier identifier) + : this(HttpMethod.PATCH, document: _One(identifier)); + + JsonApiRequest.deleteOne() : this(HttpMethod.PATCH, document: _One(null)); + + JsonApiRequest.deleteMany(Iterable identifiers) + : this(HttpMethod.DELETE, document: _Many(identifiers)); + + JsonApiRequest.replaceMany(Iterable identifiers) + : this(HttpMethod.PATCH, document: _Many(identifiers)); + + JsonApiRequest.addMany(Iterable identifiers) + : this(HttpMethod.POST, document: _Many(identifiers)); + + final String _method; + final String _body; + final _headers = {}; + QueryParameters _parameters = QueryParameters.empty(); + + void headers(Map headers) { + _headers.addAll(headers); + } + + void include(Iterable items) { + _parameters &= Include(items); + } + + HttpRequest toHttp(Uri uri) => + HttpRequest(_method, _parameters.addToUri(uri), body: _body, headers: { + ..._headers, + 'Accept': ContentType.jsonApi, + if (_body.isNotEmpty) 'Content-Type': ContentType.jsonApi + }); } class _Resource { diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index e36a5cf7..3238cc4d 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -30,6 +30,10 @@ class Fields extends QueryParameters { List operator [](String key) => _fields[key]; + bool get isEmpty => _fields.isEmpty; + + bool get isNotEmpty => _fields.isNotEmpty; + static final _regex = RegExp(r'^fields\[(.+)\]$'); final Map> _fields; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index b9d9b224..dceac7f4 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -27,5 +27,8 @@ class Page extends QueryParameters { static final _regex = RegExp(r'^page\[(.+)\]$'); + bool get isEmpty => _parameters.isEmpty; + + bool get isNotEmpty => _parameters.isNotEmpty; final Map _parameters; } diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart index 6875e1db..106971cd 100644 --- a/lib/src/query/query_parameters.dart +++ b/lib/src/query/query_parameters.dart @@ -8,12 +8,8 @@ class QueryParameters { final Map _parameters; - bool get isEmpty => _parameters.isEmpty; - - bool get isNotEmpty => _parameters.isNotEmpty; - /// Adds (or replaces) this parameters to the [uri]. - Uri addToUri(Uri uri) => isEmpty + Uri addToUri(Uri uri) => _parameters.isEmpty ? uri : uri.replace(queryParameters: {...uri.queryParameters, ..._parameters}); From 1b2480113b049c1cbb66a99c4c99197df39c315f Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 13 May 2020 23:12:21 -0700 Subject: [PATCH 71/99] wip --- lib/src/client/document.dart | 63 ++++++++--- lib/src/client/json_api_client.dart | 60 ++++++---- lib/src/client/request.dart | 107 ++---------------- lib/src/client/response.dart | 24 ++-- lib/src/http/http_request.dart | 12 +- .../http/{http_method.dart => method.dart} | 2 +- 6 files changed, 115 insertions(+), 153 deletions(-) rename lib/src/http/{http_method.dart => method.dart} (89%) diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart index b7567140..51aa96c5 100644 --- a/lib/src/client/document.dart +++ b/lib/src/client/document.dart @@ -25,15 +25,16 @@ class ResponseDocument { ResourceCollection get included => ResourceCollection(Maybe(_json['included']) .map((t) => t is List ? t : throw ArgumentError('List expected')) - .map((t) => t.map(Resource.fromJson)) + .map((t) => t.map(ResourceWithIdentity.fromJson)) .or(const [])); ResourceCollection get resources => ResourceCollection(Maybe(_json['data']) .map((t) => t is List ? t : throw ArgumentError('List expected')) - .map((t) => t.map(Resource.fromJson)) + .map((t) => t.map(ResourceWithIdentity.fromJson)) .or(const [])); - Resource get resource => Resource.fromJson(_json['data']); + ResourceWithIdentity get resource => + ResourceWithIdentity.fromJson(_json['data']); Relationship get relationship => Relationship.fromJson(_json); @@ -199,28 +200,49 @@ class Link { String toString() => uri.toString(); } -class ResourceCollection with IterableMixin { - ResourceCollection(Iterable resources) +class ResourceCollection with IterableMixin { + ResourceCollection(Iterable resources) : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); - final Map _map; + final Map _map; @override - Iterator get iterator => _map.values.iterator; + Iterator get iterator => _map.values.iterator; } -class Resource with Identity { - Resource(this.type, this.id, +class Resource { + Resource(this.type, + {Map meta, + Map attributes, + Map relationships}) + : meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}), + attributes = Map.unmodifiable(attributes ?? {}); + + final String type; + final Map meta; + final Map attributes; + final Map relationships; + + Map toJson() => { + 'type': type, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +class ResourceWithIdentity extends Resource with Identity { + ResourceWithIdentity(this.type, this.id, {Map links, Map meta, Map attributes, Map relationships}) : links = Map.unmodifiable(links ?? {}), - meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}), - attributes = Map.unmodifiable(attributes ?? {}); + super(type, + attributes: attributes, relationships: relationships, meta: meta); - static Resource fromJson(Object json) { + static ResourceWithIdentity fromJson(Object json) { if (json is Map) { final relationships = json['relationships']; final attributes = json['attributes']; @@ -229,7 +251,7 @@ class Resource with Identity { (attributes == null || attributes is Map) && type is String && type.isNotEmpty) { - return Resource(json['type'], json['id'], + return ResourceWithIdentity(json['type'], json['id'], attributes: attributes, relationships: Maybe(relationships) .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) @@ -249,9 +271,6 @@ class Resource with Identity { @override final String id; final Map links; - final Map meta; - final Map attributes; - final Map relationships; Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) .filter((_) => _ is Many) @@ -260,6 +279,13 @@ class Resource with Identity { One one(String key, {One Function() orElse}) => Maybe(relationships[key]) .filter((_) => _ is One) .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); + + @override + Map toJson() => { + 'id': id, + ...super.toJson(), + if (links.isNotEmpty) 'links': links, + }; } abstract class Relationship with IterableMixin { @@ -351,7 +377,8 @@ class Many extends Relationship { final isPlural = true; @override - Map toJson() => {...super.toJson(), 'data': _map.values}; + Map toJson() => + {...super.toJson(), 'data': _map.values.toList()}; @override Iterator get iterator => _map.values.iterator; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index e1aaec07..d1e8284f 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -14,7 +14,7 @@ class JsonApiClient { /// Fetches a primary resource collection by [type]. Future fetchCollection(String type, {Map headers, Iterable include}) async { - final request = JsonApiRequest.fetch(); + final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); return FetchCollectionResponse.fromHttp( @@ -25,7 +25,7 @@ class JsonApiClient { Future fetchRelatedCollection( String type, String id, String relationship, {Map headers, Iterable include}) async { - final request = JsonApiRequest.fetch(); + final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); return FetchCollectionResponse.fromHttp( @@ -35,7 +35,7 @@ class JsonApiClient { /// Fetches a primary resource by [type] and [id]. Future fetchResource(String type, String id, {Map headers, Iterable include}) async { - final request = JsonApiRequest.fetch(); + final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); return FetchPrimaryResourceResponse.fromHttp( @@ -46,7 +46,7 @@ class JsonApiClient { Future fetchRelatedResource( String type, String id, String relationship, {Map headers, Iterable include}) async { - final request = JsonApiRequest.fetch(); + final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); return FetchRelatedResourceResponse.fromHttp( @@ -57,36 +57,37 @@ class JsonApiClient { Future> fetchRelationship( String type, String id, String relationship, - {Map headers = const {}}) async { - final request = JsonApiRequest.fetch(); + {Map headers}) async { + final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); return FetchRelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); } - /// Creates a new [resource] on the server. + /// Creates a new [_resource] on the server. /// The server is expected to assign the resource id. Future createNewResource(String type, {Map attributes = const {}, Map one = const {}, Map> many = const {}, Map headers}) async { - final request = JsonApiRequest.createNewResource(type, - attributes: attributes, one: one, many: many); + final request = JsonApiRequest.post(ResourceDocument(Resource(type, + attributes: attributes, relationships: _relationships(one, many)))); Maybe(headers).ifPresent(request.headers); return CreateResourceResponse.fromHttp( await call(request, _uri.collection(type))); } - /// Creates a new [resource] on the server. + /// Creates a new [_resource] on the server. /// The server is expected to accept the provided resource id. Future createResource(String type, String id, {Map attributes = const {}, Map one = const {}, Map> many = const {}, Map headers}) async { - final request = JsonApiRequest.createResource(type, id, - attributes: attributes, one: one, many: many); + final request = JsonApiRequest.post(ResourceDocument(ResourceWithIdentity( + type, id, + attributes: attributes, relationships: _relationships(one, many)))); Maybe(headers).ifPresent(request.headers); return ResourceResponse.fromHttp( await call(request, _uri.collection(type))); @@ -95,20 +96,21 @@ class JsonApiClient { /// Deletes the resource by [type] and [id]. Future deleteResource(String type, String id, {Map headers}) async { - final request = JsonApiRequest.deleteResource(); + final request = JsonApiRequest.delete(); Maybe(headers).ifPresent(request.headers); return DeleteResourceResponse.fromHttp( await call(request, _uri.resource(type, id))); } - /// Updates the [resource]. + /// Updates the [_resource]. Future updateResource(String type, String id, {Map attributes = const {}, Map one = const {}, Map> many = const {}, Map headers}) async { - final request = JsonApiRequest.updateResource(type, id, - attributes: attributes, one: one, many: many); + final request = JsonApiRequest.patch(ResourceDocument(ResourceWithIdentity( + type, id, + attributes: attributes, relationships: _relationships(one, many)))); Maybe(headers).ifPresent(request.headers); return ResourceResponse.fromHttp( await call(request, _uri.resource(type, id))); @@ -118,7 +120,7 @@ class JsonApiClient { Future> replaceOne( String type, String id, String relationship, Identifier identifier, {Map headers}) async { - final request = JsonApiRequest.replaceOne(identifier); + final request = JsonApiRequest.patch(One(identifier)); Maybe(headers).ifPresent(request.headers); return RelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); @@ -128,7 +130,7 @@ class JsonApiClient { Future> deleteOne( String type, String id, String relationship, {Map headers}) async { - final request = JsonApiRequest.deleteOne(); + final request = JsonApiRequest.patch(One.empty()); Maybe(headers).ifPresent(request.headers); return RelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); @@ -138,7 +140,7 @@ class JsonApiClient { Future> deleteMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async { - final request = JsonApiRequest.deleteMany(identifiers); + final request = JsonApiRequest.delete(Many(identifiers)); Maybe(headers).ifPresent(request.headers); return RelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); @@ -148,7 +150,7 @@ class JsonApiClient { Future> replaceMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async { - final request = JsonApiRequest.replaceMany(identifiers); + final request = JsonApiRequest.patch(Many(identifiers)); Maybe(headers).ifPresent(request.headers); return RelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); @@ -158,7 +160,7 @@ class JsonApiClient { Future> addMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) async { - final request = JsonApiRequest.addMany(identifiers); + final request = JsonApiRequest.post(Many(identifiers)); Maybe(headers).ifPresent(request.headers); return RelationshipResponse.fromHttp( await call(request, _uri.relationship(type, id, relationship))); @@ -175,3 +177,19 @@ class JsonApiClient { return response; } } + +class ResourceDocument { + ResourceDocument(this._resource); + + final Resource _resource; + + Map toJson() => {'data': _resource.toJson()}; +} + +Map _relationships( + Map one, Map> many) => + { + ...one.map((key, value) => MapEntry( + key, Maybe(value).map((t) => One(value)).orGet(() => One.empty()))), + ...many.map((key, value) => MapEntry(key, Many(value))) + }; diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index b9a75fa9..8a759b7d 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -3,69 +3,41 @@ import 'dart:convert'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/src/client/content_type.dart'; -import 'package:json_api/src/client/document.dart'; -import 'package:json_api/src/http/http_method.dart'; +import 'package:json_api/src/http/method.dart'; import 'package:json_api/src/maybe.dart'; +/// A JSON:API HTTP request builder class JsonApiRequest { JsonApiRequest(this._method, {Object document}) : _body = Maybe(document).map(jsonEncode).or(''); - JsonApiRequest.fetch() : this(HttpMethod.GET); + JsonApiRequest.get() : this(Method.GET); - JsonApiRequest.createNewResource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : this(HttpMethod.POST, - document: - _Resource(type, attributes: attributes, one: one, many: many)); + JsonApiRequest.post([Object document]) + : this(Method.POST, document: document); - JsonApiRequest.createResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : this(HttpMethod.POST, - document: _Resource.withId(type, id, - attributes: attributes, one: one, many: many)); + JsonApiRequest.patch([Object document]) + : this(Method.PATCH, document: document); - JsonApiRequest.updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : this(HttpMethod.PATCH, - document: _Resource.withId(type, id, - attributes: attributes, one: one, many: many)); - - JsonApiRequest.deleteResource() : this(HttpMethod.DELETE); - - JsonApiRequest.replaceOne(Identifier identifier) - : this(HttpMethod.PATCH, document: _One(identifier)); - - JsonApiRequest.deleteOne() : this(HttpMethod.PATCH, document: _One(null)); - - JsonApiRequest.deleteMany(Iterable identifiers) - : this(HttpMethod.DELETE, document: _Many(identifiers)); - - JsonApiRequest.replaceMany(Iterable identifiers) - : this(HttpMethod.PATCH, document: _Many(identifiers)); - - JsonApiRequest.addMany(Iterable identifiers) - : this(HttpMethod.POST, document: _Many(identifiers)); + JsonApiRequest.delete([Object document]) + : this(Method.DELETE, document: document); final String _method; final String _body; final _headers = {}; QueryParameters _parameters = QueryParameters.empty(); + /// Adds headers to the request. void headers(Map headers) { _headers.addAll(headers); } + /// Adds the "include" query parameter void include(Iterable items) { _parameters &= Include(items); } + /// Converts to an HTTP request HttpRequest toHttp(Uri uri) => HttpRequest(_method, _parameters.addToUri(uri), body: _body, headers: { ..._headers, @@ -73,58 +45,3 @@ class JsonApiRequest { if (_body.isNotEmpty) 'Content-Type': ContentType.jsonApi }); } - -class _Resource { - _Resource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : _resource = { - 'type': type, - if (attributes.isNotEmpty) 'attributes': attributes, - ...relationship(one, many) - }; - - _Resource.withId(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}}) - : _resource = { - 'type': type, - 'id': id, - if (attributes.isNotEmpty) 'attributes': attributes, - ...relationship(one, many) - }; - - static Map relationship(Map one, - Map> many) => - { - if (one.isNotEmpty || many.isNotEmpty) - 'relationships': { - ...one.map((key, value) => MapEntry(key, _One(value))), - ...many.map((key, value) => MapEntry(key, _Many(value))) - } - }; - - final Object _resource; - - Map toJson() => {'data': _resource}; -} - -class _One { - _One(this._ref); - - final Identifier _ref; - - Map toJson() => {'data': _ref}; -} - -class _Many { - _Many(this._refs); - - final Iterable _refs; - - Map toJson() => { - 'data': _refs.toList(), - }; -} diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 389f468b..768b1e47 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -6,7 +6,7 @@ import 'package:json_api/src/client/document.dart'; import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/maybe.dart'; -class FetchCollectionResponse with IterableMixin { +class FetchCollectionResponse with IterableMixin { FetchCollectionResponse(this.http, {ResourceCollection resources, ResourceCollection included, @@ -29,7 +29,7 @@ class FetchCollectionResponse with IterableMixin { final Map links; @override - Iterator get iterator => resources.iterator; + Iterator get iterator => resources.iterator; } class FetchPrimaryResourceResponse { @@ -45,7 +45,7 @@ class FetchPrimaryResourceResponse { } final HttpResponse http; - final Resource resource; + final ResourceWithIdentity resource; final ResourceCollection included; final Map links; } @@ -63,17 +63,17 @@ class CreateResourceResponse { final HttpResponse http; final Map links; - final Resource resource; + final ResourceWithIdentity resource; } class ResourceResponse { - ResourceResponse(this.http, Resource resource, + ResourceResponse(this.http, ResourceWithIdentity resource, {Map links = const {}}) : _resource = Just(resource), links = Map.unmodifiable(links ?? const {}); ResourceResponse.empty(this.http) - : _resource = Nothing(), + : _resource = Nothing(), links = const {}; static ResourceResponse fromHttp(HttpResponse http) { @@ -86,9 +86,9 @@ class ResourceResponse { final HttpResponse http; final Map links; - final Maybe _resource; + final Maybe _resource; - Resource resource({Resource Function() orElse}) => _resource.orGet( + ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => _resource.orGet( () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); } @@ -143,7 +143,7 @@ class RelationshipResponse { } class FetchRelatedResourceResponse { - FetchRelatedResourceResponse(this.http, Resource resource, + FetchRelatedResourceResponse(this.http, ResourceWithIdentity resource, {ResourceCollection included, Map links = const {}}) : _resource = Just(resource), links = Map.unmodifiable(links ?? const {}), @@ -151,7 +151,7 @@ class FetchRelatedResourceResponse { FetchRelatedResourceResponse.empty(this.http, {ResourceCollection included, Map links = const {}}) - : _resource = Nothing(), + : _resource = Nothing(), links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); @@ -166,9 +166,9 @@ class FetchRelatedResourceResponse { } final HttpResponse http; - final Maybe _resource; + final Maybe _resource; - Resource resource({Resource Function() orElse}) => _resource.orGet(() => + ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => _resource.orGet(() => Maybe(orElse).orThrow(() => StateError('Related resource is empty'))()); final ResourceCollection included; final Map links; diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 95da8919..42cd2cd5 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/http/http_method.dart'; +import 'package:json_api/src/http/method.dart'; /// The request which is sent by the client and received by the server class HttpRequest { @@ -29,13 +29,13 @@ class HttpRequest { HttpRequest withUri(Uri uri) => HttpRequest._(method, uri, headers, body); - bool get isGet => method == HttpMethod.GET; + bool get isGet => method == Method.GET; - bool get isPost => method == HttpMethod.POST; + bool get isPost => method == Method.POST; - bool get isDelete => method == HttpMethod.DELETE; + bool get isDelete => method == Method.DELETE; - bool get isPatch => method == HttpMethod.PATCH; + bool get isPatch => method == Method.PATCH; - bool get isOptions => method == HttpMethod.OPTIONS; + bool get isOptions => method == Method.OPTIONS; } diff --git a/lib/src/http/http_method.dart b/lib/src/http/method.dart similarity index 89% rename from lib/src/http/http_method.dart rename to lib/src/http/method.dart index bfcd7674..770d404d 100644 --- a/lib/src/http/http_method.dart +++ b/lib/src/http/method.dart @@ -1,4 +1,4 @@ -class HttpMethod { +class Method { static final DELETE = 'DELETE'; static final GET = 'GET'; static final OPTIONS = 'OPTIONS'; From 3b7d2c1304920b93a6a0564d83cf5e9eb7703bb0 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 16 May 2020 18:13:53 -0700 Subject: [PATCH 72/99] wip --- example/client.dart | 2 +- lib/src/client/document.dart | 110 +++++++---- lib/src/client/json_api_client.dart | 139 ++++++++------ lib/src/client/request.dart | 30 ++- lib/src/client/response.dart | 173 +++++++++--------- lib/src/maybe.dart | 34 ++-- lib/src/query/fields.dart | 4 +- lib/src/server/json_api_server.dart | 3 +- lib/src/server/response_factory.dart | 48 +---- .../crud/creating_resources_test.dart | 6 +- test/functional/crud/seed_resources.dart | 4 +- .../crud/updating_resources_test.dart | 4 +- 12 files changed, 293 insertions(+), 264 deletions(-) diff --git a/example/client.dart b/example/client.dart index f00d11e7..e9b84550 100644 --- a/example/client.dart +++ b/example/client.dart @@ -29,7 +29,7 @@ void main() async { await client.createResource('books', '2', attributes: { 'title': 'Refactoring' }, many: { - 'authors': [Identifier('writers', '1')] + 'authors': ['writers', '1'] }); /// Fetch the book, including its authors. diff --git a/lib/src/client/document.dart b/lib/src/client/document.dart index 51aa96c5..b49e2086 100644 --- a/lib/src/client/document.dart +++ b/lib/src/client/document.dart @@ -1,53 +1,70 @@ import 'dart:collection'; -import 'dart:convert'; import 'package:json_api/src/maybe.dart'; -/// Generic response document parser -class ResponseDocument { - ResponseDocument(this._json); +class ResourceDocument { + ResourceDocument(this._resource); - static ResponseDocument decode(String body) => Just(body) - .filter((t) => t.isNotEmpty) - .map(jsonDecode) - .map((t) => t is Map - ? t - : throw ArgumentError('Response document must be a JSON map')) - .map((t) => ResponseDocument(t)) - .orThrow(() => ArgumentError('Empty response body')); + final Resource _resource; - final Map _json; - - Map get links => Maybe(_json['links']) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .map(Link.mapFromJson) - .or(const {}); + Map toJson() => {'data': _resource.toJson()}; +} - ResourceCollection get included => ResourceCollection(Maybe(_json['included']) - .map((t) => t is List ? t : throw ArgumentError('List expected')) - .map((t) => t.map(ResourceWithIdentity.fromJson)) - .or(const [])); +class Document { + Document({Map meta}) { + Maybe(meta).ifPresent(this.meta.addAll); + } - ResourceCollection get resources => ResourceCollection(Maybe(_json['data']) - .map((t) => t is List ? t : throw ArgumentError('List expected')) - .map((t) => t.map(ResourceWithIdentity.fromJson)) - .or(const [])); + static Document fromJson(Object json) { + if (json is Map) { + return Document(meta: json.containsKey('meta') ? json['meta'] : null); + } + throw ArgumentError('Map expected'); + } - ResourceWithIdentity get resource => - ResourceWithIdentity.fromJson(_json['data']); + final meta = {}; +} - Relationship get relationship => Relationship.fromJson(_json); +/// Generic JSON:API document with data +class DataDocument extends Document { + DataDocument(this.data, + {Map meta, + Map links, + Iterable included}) + : _included = Maybe(included), + super(meta: meta) { + Maybe(links).ifPresent(this.links.addAll); + } - bool get hasData => _json['data'] != null; + static DataDocument fromJson(Object json) { + if (json is Map) { + if (!json.containsKey('data')) throw ArgumentError('No "data" key found'); + final meta = json.containsKey('meta') ? json['meta'] : null; + final links = + json.containsKey('links') ? Link.mapFromJson(json['links']) : null; + + if (json.containsKey('included')) { + final error = ArgumentError('Invalid "included" value'); + final included = Maybe(json['included']) + .map((_) => _ is List ? _ : throw error) + .map((_) => _.map(ResourceWithIdentity.fromJson)) + .orThrow(() => error); + return DataDocument(json['data'], + meta: meta, links: links, included: included); + } + return DataDocument(json['data'], meta: meta, links: links); + } + throw ArgumentError('Map expected'); + } - Iterable get errors => Maybe(_json['errors']) - .map((_) => _ is List ? _ : throw ArgumentError('List expected')) - .map((_) => _.map(ErrorObject.fromJson)) - .or(const []); + final Object data; + final Maybe> _included; + final links = {}; - Map get meta => Maybe(_json['meta']) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .or(const {}); + Iterable included( + {Iterable Function() orElse}) => + _included.orGet(() => + Maybe(orElse).orThrow(() => StateError('No "included" key found'))()); } /// [ErrorObject] represents an error occurred on the server. @@ -204,6 +221,12 @@ class ResourceCollection with IterableMixin { ResourceCollection(Iterable resources) : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); + static ResourceCollection fromJson(Object json) => + ResourceCollection(Maybe(json) + .map((_) => _ is List ? _ : throw ArgumentError('List expected')) + .map((_) => _.map(ResourceWithIdentity.fromJson)) + .orThrow(() => ArgumentError('Invalid json'))); + final Map _map; @override @@ -395,6 +418,12 @@ class Identifier with Identity { throw ArgumentError('A JSON:API identifier must be a JSON object'); } + static Identifier fromKey(String key) { + final i = key.indexOf(Identity.delimiter); + if (i < 1) throw ArgumentError('Invalid key'); + return Identifier(key.substring(0, i), key.substring(i + 1)); + } + @override final String type; @@ -408,9 +437,14 @@ class Identifier with Identity { } mixin Identity { + static final delimiter = ':'; + String get type; String get id; - String get key => '$type:$id'; + String get key => '$type$delimiter$id'; + + @override + String toString() => key; } diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index d1e8284f..eeb43434 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -13,43 +13,70 @@ class JsonApiClient { /// Fetches a primary resource collection by [type]. Future fetchCollection(String type, - {Map headers, Iterable include}) async { + {Map headers, + Iterable include, + Map> fields, + Iterable sort, + Map page, + Map parameters}) async { final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); - return FetchCollectionResponse.fromHttp( - await call(request, _uri.collection(type))); + Maybe(fields).ifPresent(request.fields); + Maybe(sort).ifPresent(request.sort); + Maybe(page).ifPresent(request.page); + Maybe(parameters).ifPresent(request.parameters); + return FetchCollectionResponse(await call(request, _uri.collection(type))); } /// Fetches a related resource collection by [type], [id], [relationship]. Future fetchRelatedCollection( String type, String id, String relationship, - {Map headers, Iterable include}) async { + {Map headers, + Iterable include, + Map> fields, + Iterable sort, + Map page, + Map parameters}) async { final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); - return FetchCollectionResponse.fromHttp( + Maybe(fields).ifPresent(request.fields); + Maybe(sort).ifPresent(request.sort); + Maybe(page).ifPresent(request.page); + Maybe(parameters).ifPresent(request.parameters); + return FetchCollectionResponse( await call(request, _uri.related(type, id, relationship))); } /// Fetches a primary resource by [type] and [id]. Future fetchResource(String type, String id, - {Map headers, Iterable include}) async { + {Map headers, + Iterable include, + Map> fields, + Map parameters}) async { final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); - return FetchPrimaryResourceResponse.fromHttp( + Maybe(fields).ifPresent(request.fields); + Maybe(parameters).ifPresent(request.parameters); + return FetchPrimaryResourceResponse( await call(request, _uri.resource(type, id))); } /// Fetches a related resource by [type], [id], [relationship]. Future fetchRelatedResource( String type, String id, String relationship, - {Map headers, Iterable include}) async { + {Map headers, + Iterable include, + Map> fields, + Map parameters}) async { final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); Maybe(include).ifPresent(request.include); - return FetchRelatedResourceResponse.fromHttp( + Maybe(fields).ifPresent(request.fields); + Maybe(parameters).ifPresent(request.parameters); + return FetchRelatedResourceResponse( await call(request, _uri.related(type, id, relationship))); } @@ -60,7 +87,7 @@ class JsonApiClient { {Map headers}) async { final request = JsonApiRequest.get(); Maybe(headers).ifPresent(request.headers); - return FetchRelationshipResponse.fromHttp( + return FetchRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } @@ -68,29 +95,26 @@ class JsonApiClient { /// The server is expected to assign the resource id. Future createNewResource(String type, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers}) async { final request = JsonApiRequest.post(ResourceDocument(Resource(type, attributes: attributes, relationships: _relationships(one, many)))); Maybe(headers).ifPresent(request.headers); - return CreateResourceResponse.fromHttp( - await call(request, _uri.collection(type))); + return CreateResourceResponse(await call(request, _uri.collection(type))); } /// Creates a new [_resource] on the server. /// The server is expected to accept the provided resource id. Future createResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers}) async { - final request = JsonApiRequest.post(ResourceDocument(ResourceWithIdentity( - type, id, - attributes: attributes, relationships: _relationships(one, many)))); + final request = + JsonApiRequest.post(_resource(type, id, attributes, one, many)); Maybe(headers).ifPresent(request.headers); - return ResourceResponse.fromHttp( - await call(request, _uri.collection(type))); + return ResourceResponse(await call(request, _uri.collection(type))); } /// Deletes the resource by [type] and [id]. @@ -98,71 +122,68 @@ class JsonApiClient { {Map headers}) async { final request = JsonApiRequest.delete(); Maybe(headers).ifPresent(request.headers); - return DeleteResourceResponse.fromHttp( - await call(request, _uri.resource(type, id))); + return DeleteResourceResponse(await call(request, _uri.resource(type, id))); } - /// Updates the [_resource]. + /// Updates the resource by [type] and [id]. Future updateResource(String type, String id, {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, + Map one = const {}, + Map> many = const {}, Map headers}) async { - final request = JsonApiRequest.patch(ResourceDocument(ResourceWithIdentity( - type, id, - attributes: attributes, relationships: _relationships(one, many)))); + final request = + JsonApiRequest.patch(_resource(type, id, attributes, one, many)); Maybe(headers).ifPresent(request.headers); - return ResourceResponse.fromHttp( - await call(request, _uri.resource(type, id))); + return ResourceResponse(await call(request, _uri.resource(type, id))); } /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceOne( + Future> replaceOne( String type, String id, String relationship, Identifier identifier, {Map headers}) async { final request = JsonApiRequest.patch(One(identifier)); Maybe(headers).ifPresent(request.headers); - return RelationshipResponse.fromHttp( + return UpdateRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteOne( + Future> deleteOne( String type, String id, String relationship, {Map headers}) async { final request = JsonApiRequest.patch(One.empty()); Maybe(headers).ifPresent(request.headers); - return RelationshipResponse.fromHttp( + return UpdateRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteMany(String type, String id, + Future> deleteMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async { final request = JsonApiRequest.delete(Many(identifiers)); Maybe(headers).ifPresent(request.headers); - return RelationshipResponse.fromHttp( + return UpdateRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceMany(String type, String id, + Future> replaceMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async { final request = JsonApiRequest.patch(Many(identifiers)); Maybe(headers).ifPresent(request.headers); - return RelationshipResponse.fromHttp( + return UpdateRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addMany(String type, String id, + Future> addMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) async { final request = JsonApiRequest.post(Many(identifiers)); Maybe(headers).ifPresent(request.headers); - return RelationshipResponse.fromHttp( + return UpdateRelationshipResponse( await call(request, _uri.relationship(type, id, relationship))); } @@ -176,20 +197,26 @@ class JsonApiClient { } return response; } -} - -class ResourceDocument { - ResourceDocument(this._resource); - final Resource _resource; - - Map toJson() => {'data': _resource.toJson()}; + ResourceDocument _resource( + String type, + String id, + Map attributes, + Map one, + Map> many) => + ResourceDocument(ResourceWithIdentity(type, id, + attributes: attributes, relationships: _relationships(one, many))); + + Map _relationships( + Map one, Map> many) => + { + ...one.map((key, value) => MapEntry( + key, + Maybe(value) + .filter((_) => _.isNotEmpty) + .map((_) => One(Identifier.fromKey(_))) + .orGet(() => One.empty()))), + ...many.map( + (key, value) => MapEntry(key, Many(value.map(Identifier.fromKey)))) + }; } - -Map _relationships( - Map one, Map> many) => - { - ...one.map((key, value) => MapEntry( - key, Maybe(value).map((t) => One(value)).orGet(() => One.empty()))), - ...many.map((key, value) => MapEntry(key, Many(value))) - }; diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 8a759b7d..faee2a3c 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -32,11 +32,39 @@ class JsonApiRequest { _headers.addAll(headers); } - /// Adds the "include" query parameter + /// Requests inclusion of related resources. + /// + /// See https://jsonapi.org/format/#fetching-includes void include(Iterable items) { _parameters &= Include(items); } + /// Requests collection sorting. + /// + /// See https://jsonapi.org/format/#fetching-sorting + void sort(Iterable sort) { + _parameters &= Sort(sort.map(SortField.parse)); + } + + /// Requests a specific page. + /// + /// See https://jsonapi.org/format/#fetching-pagination + void page(Map page){ + _parameters &= Page(page); + } + + /// Requests sparse fieldsets. + /// + /// See https://jsonapi.org/format/#fetching-sparse-fieldsets + void fields(Map> fields) { + _parameters &= Fields(fields); + } + + /// Sets arbitrary query parameters. + void parameters(Map parameters) { + _parameters &= QueryParameters(parameters); + } + /// Converts to an HTTP request HttpRequest toHttp(Uri uri) => HttpRequest(_method, _parameters.addToUri(uri), body: _body, headers: { diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 768b1e47..2df797db 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,13 +1,21 @@ import 'dart:collection'; +import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; import 'package:json_api/src/client/document.dart'; -import 'package:json_api/src/client/status_code.dart'; import 'package:json_api/src/maybe.dart'; class FetchCollectionResponse with IterableMixin { - FetchCollectionResponse(this.http, + factory FetchCollectionResponse(HttpResponse http) { + final document = DataDocument.fromJson(jsonDecode(http.body)); + return FetchCollectionResponse._(http, + resources: ResourceCollection.fromJson(document.data), + included: ResourceCollection(document.included(orElse: () => [])), + links: document.links); + } + + FetchCollectionResponse._(this.http, {ResourceCollection resources, ResourceCollection included, Map links = const {}}) @@ -15,14 +23,6 @@ class FetchCollectionResponse with IterableMixin { links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); - static FetchCollectionResponse fromHttp(HttpResponse http) { - final document = ResponseDocument.decode(http.body); - return FetchCollectionResponse(http, - resources: document.resources, - included: document.included, - links: document.links); - } - final HttpResponse http; final ResourceCollection resources; final ResourceCollection included; @@ -33,17 +33,19 @@ class FetchCollectionResponse with IterableMixin { } class FetchPrimaryResourceResponse { - FetchPrimaryResourceResponse(this.http, this.resource, + factory FetchPrimaryResourceResponse(HttpResponse http) { + final document = DataDocument.fromJson(jsonDecode(http.body)); + return FetchPrimaryResourceResponse._( + http, ResourceWithIdentity.fromJson(document.data), + included: ResourceCollection(document.included(orElse: () => [])), + links: document.links); + } + + FetchPrimaryResourceResponse._(this.http, this.resource, {ResourceCollection included, Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); - static FetchPrimaryResourceResponse fromHttp(HttpResponse http) { - final document = ResponseDocument.decode(http.body); - return FetchPrimaryResourceResponse(http, document.resource, - included: document.included, links: document.links); - } - final HttpResponse http; final ResourceWithIdentity resource; final ResourceCollection included; @@ -51,89 +53,76 @@ class FetchPrimaryResourceResponse { } class CreateResourceResponse { - CreateResourceResponse(this.http, this.resource, - {Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}); - - static CreateResourceResponse fromHttp(HttpResponse http) { - final document = ResponseDocument.decode(http.body); - return CreateResourceResponse(http, document.resource, + factory CreateResourceResponse(HttpResponse http) { + final document = DataDocument.fromJson(jsonDecode(http.body)); + return CreateResourceResponse._( + http, ResourceWithIdentity.fromJson(document.data), links: document.links); } + CreateResourceResponse._(this.http, this.resource, + {Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}); + final HttpResponse http; final Map links; final ResourceWithIdentity resource; } class ResourceResponse { - ResourceResponse(this.http, ResourceWithIdentity resource, + factory ResourceResponse(HttpResponse http) { + if (http.body.isEmpty) { + return ResourceResponse._empty(http); + } + final document = DataDocument.fromJson(jsonDecode(http.body)); + return ResourceResponse._( + http, ResourceWithIdentity.fromJson(document.data), + links: document.links); + } + + ResourceResponse._(this.http, ResourceWithIdentity resource, {Map links = const {}}) : _resource = Just(resource), links = Map.unmodifiable(links ?? const {}); - ResourceResponse.empty(this.http) + ResourceResponse._empty(this.http) : _resource = Nothing(), links = const {}; - static ResourceResponse fromHttp(HttpResponse http) { - if (StatusCode(http.statusCode).isNoContent) { - return ResourceResponse.empty(http); - } - final document = ResponseDocument.decode(http.body); - return ResourceResponse(http, document.resource, links: document.links); - } - final HttpResponse http; final Map links; final Maybe _resource; - ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => _resource.orGet( - () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); + ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => + _resource.orGet(() => + Maybe(orElse).orThrow(() => StateError('No content returned'))()); } class DeleteResourceResponse { - DeleteResourceResponse(this.http, Map meta) - : meta = Map.unmodifiable(meta); - - DeleteResourceResponse.empty(this.http) : meta = const {}; - - static DeleteResourceResponse fromHttp(HttpResponse http) { - if (StatusCode(http.statusCode).isNoContent) { - return DeleteResourceResponse.empty(http); - } - final document = ResponseDocument.decode(http.body); - return DeleteResourceResponse(http, document.meta); - } + DeleteResourceResponse(this.http) + : meta = http.body.isEmpty + ? const {} + : Document.fromJson(jsonDecode(http.body)).meta; final HttpResponse http; final Map meta; } class FetchRelationshipResponse { - FetchRelationshipResponse(this.http, this.relationship); - - static FetchRelationshipResponse fromHttp( - HttpResponse http) => - FetchRelationshipResponse( - http, ResponseDocument.decode(http.body).relationship.as()); + FetchRelationshipResponse(this.http) + : relationship = Relationship.fromJson(jsonDecode(http.body)).as(); final HttpResponse http; final R relationship; } -class RelationshipResponse { - RelationshipResponse(this.http, R relationship) - : _relationship = Just(relationship); - - RelationshipResponse.empty(this.http) : _relationship = Nothing(); - - static RelationshipResponse fromHttp( - HttpResponse http) => - http.body.isEmpty - ? RelationshipResponse.empty(http) - : RelationshipResponse( - http, ResponseDocument.decode(http.body).relationship.as()); +class UpdateRelationshipResponse { + UpdateRelationshipResponse(this.http) + : _relationship = Maybe(http.body) + .filter((_) => _.isNotEmpty) + .map(jsonDecode) + .map(Relationship.fromJson) + .map((_) => _.as()); final HttpResponse http; final Maybe _relationship; @@ -143,35 +132,27 @@ class RelationshipResponse { } class FetchRelatedResourceResponse { - FetchRelatedResourceResponse(this.http, ResourceWithIdentity resource, - {ResourceCollection included, Map links = const {}}) - : _resource = Just(resource), - links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + factory FetchRelatedResourceResponse(HttpResponse http) { + final document = DataDocument.fromJson(jsonDecode(http.body)); + return FetchRelatedResourceResponse._( + http, Maybe(document.data).map(ResourceWithIdentity.fromJson), + included: ResourceCollection(document.included(orElse: () => [])), + links: document.links); + } - FetchRelatedResourceResponse.empty(this.http, + FetchRelatedResourceResponse._(this.http, this._resource, {ResourceCollection included, Map links = const {}}) - : _resource = Nothing(), - links = Map.unmodifiable(links ?? const {}), + : links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); - static FetchRelatedResourceResponse fromHttp(HttpResponse http) { - final document = ResponseDocument.decode(http.body); - if (document.hasData) { - return FetchRelatedResourceResponse(http, document.resource, - included: document.included, links: document.links); - } - return FetchRelatedResourceResponse.empty(http, - included: document.included, links: document.links); - } - - final HttpResponse http; final Maybe _resource; - - ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => _resource.orGet(() => - Maybe(orElse).orThrow(() => StateError('Related resource is empty'))()); + final HttpResponse http; final ResourceCollection included; final Map links; + + ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => + _resource.orGet(() => Maybe(orElse) + .orThrow(() => StateError('Related resource is empty'))()); } class RequestFailure { @@ -179,8 +160,20 @@ class RequestFailure { : errors = List.unmodifiable(errors ?? const []); final List errors; - static RequestFailure fromHttp(HttpResponse http) => - RequestFailure(http, errors: ResponseDocument.decode(http.body).errors); + static RequestFailure fromHttp(HttpResponse http) { + if (http.body.isEmpty || + http.headers['content-type'] != ContentType.jsonApi) { + return RequestFailure(http); + } + final errors = Maybe(jsonDecode(http.body)) + .map((_) => _ is Map ? _ : throw ArgumentError('Invalid json')) + .map((_) => _['errors']) + .map((_) => _ is List ? _ : throw ArgumentError('Invalid json')) + .map((_) => _.map(ErrorObject.fromJson)) + .or([]); + + return RequestFailure(http, errors: errors); + } final HttpResponse http; } diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart index 9d0b1c94..8d98325e 100644 --- a/lib/src/maybe.dart +++ b/lib/src/maybe.dart @@ -1,21 +1,19 @@ /// A variation of the Maybe monad with eager execution. abstract class Maybe { - factory Maybe(T t) => t == null ? Nothing() : Just(t); + factory Maybe(T value) => value == null ? Nothing() : Just(value); /// Maps the value - Maybe

map

(P Function(T t) f); + Maybe

map

(P Function(T _) f); - Maybe filter(bool Function(T t) f); + Maybe filter(bool Function(T _) f); - T or(T t); + T or(T _); T orGet(T Function() f); T orThrow(Object Function() f); - void ifPresent(void Function(T t) f); - - Maybe recover(T Function(E _) f); + void ifPresent(void Function(T _) f); } class Just implements Maybe { @@ -26,10 +24,10 @@ class Just implements Maybe { final T value; @override - Maybe

map

(P Function(T t) f) => Maybe(f(value)); + Maybe

map

(P Function(T _) f) => Maybe(f(value)); @override - T or(T t) => value; + T or(T _) => value; @override T orGet(T Function() f) => value; @@ -38,23 +36,20 @@ class Just implements Maybe { T orThrow(Object Function() f) => value; @override - void ifPresent(void Function(T t) f) => f(value); - - @override - Maybe filter(bool Function(T t) f) => f(value) ? this : Nothing(); + void ifPresent(void Function(T _) f) => f(value); @override - Maybe recover(T Function(E _) f) => this; + Maybe filter(bool Function(T _) f) => f(value) ? this : Nothing(); } class Nothing implements Maybe { Nothing(); @override - Maybe

map

(P Function(T t) map) => Nothing

(); + Maybe

map

(P Function(T _) map) => Nothing

(); @override - T or(T t) => t; + T or(T _) => _; @override T orGet(T Function() f) => f(); @@ -63,11 +58,8 @@ class Nothing implements Maybe { T orThrow(Object Function() f) => throw f(); @override - void ifPresent(void Function(T t) f) {} - - @override - Maybe filter(bool Function(T t) f) => this; + void ifPresent(void Function(T _) f) {} @override - Maybe recover(T Function(E _) f) => this; + Maybe filter(bool Function(T _) f) => this; } diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 3238cc4d..ef63c7bf 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -13,7 +13,7 @@ class Fields extends QueryParameters { /// ``` /// ?fields[articles]=title,body&fields[people]=name /// ``` - Fields(Map> fields) + Fields(Map> fields) : _fields = {...fields}, super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); @@ -22,7 +22,7 @@ class Fields extends QueryParameters { /// Extracts the requested fields from [queryParameters]. static Fields fromQueryParameters( - Map> queryParameters) => + Map> queryParameters) => Fields(queryParameters.map((k, v) => MapEntry( _regex.firstMatch(k)?.group(1), v.expand((_) => _.split(',')).toList())) diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart index 92184dd7..5628d8dd 100644 --- a/lib/src/server/json_api_server.dart +++ b/lib/src/server/json_api_server.dart @@ -12,8 +12,7 @@ class JsonApiServer implements HttpHandler { JsonApiServer(this._controller, {Routing routing, ResponseFactory responseFactory}) : _routing = routing ?? StandardRouting(), - _rf = responseFactory ?? - HttpResponseFactory(routing ?? StandardRouting()); + _rf = responseFactory ?? ResponseFactory(routing ?? StandardRouting()); final Routing _routing; final ResponseFactory _rf; diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart index b19f53a1..26df6535 100644 --- a/lib/src/server/response_factory.dart +++ b/lib/src/server/response_factory.dart @@ -7,56 +7,19 @@ import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/collection.dart'; import 'package:json_api/src/server/request.dart'; -abstract class ResponseFactory { - HttpResponse error(int status, - {Iterable errors, Map headers}); - - HttpResponse noContent(); - - HttpResponse accepted(Resource resource); - - HttpResponse primaryResource( - Request request, Resource resource, - {Iterable include}); - - HttpResponse relatedResource( - Request request, Resource resource, - {Iterable include}); - - HttpResponse createdResource( - Request request, Resource resource); - - HttpResponse primaryCollection( - Request request, Collection collection, - {Iterable include}); - - HttpResponse relatedCollection( - Request request, Collection collection, - {List include}); - - HttpResponse relationshipToOne( - Request request, Identifier identifier); - - HttpResponse relationshipToMany( - Request request, Iterable identifiers); -} - -class HttpResponseFactory implements ResponseFactory { - HttpResponseFactory(this._uri); +class ResponseFactory { + ResponseFactory(this._uri); final UriFactory _uri; - @override HttpResponse error(int status, {Iterable errors, Map headers}) => HttpResponse(status, body: jsonEncode(Document.error(errors ?? [])), headers: {...(headers ?? {}), 'Content-Type': Document.contentType}); - @override HttpResponse noContent() => HttpResponse(204); - @override HttpResponse accepted(Resource resource) => HttpResponse(202, headers: { 'Content-Type': Document.contentType, @@ -65,7 +28,6 @@ class HttpResponseFactory implements ResponseFactory { body: jsonEncode(Document(ResourceData(_resource(resource), links: {'self': Link(_uri.resource(resource.type, resource.id))})))); - @override HttpResponse primaryResource( Request request, Resource resource, {Iterable include}) => @@ -77,7 +39,6 @@ class HttpResponseFactory implements ResponseFactory { included: request.isCompound ? (include ?? []).map(_resource) : null))); - @override HttpResponse createdResource( Request request, Resource resource) => HttpResponse(201, @@ -89,7 +50,6 @@ class HttpResponseFactory implements ResponseFactory { 'self': Link(_uri.resource(resource.type, resource.id)) })))); - @override HttpResponse primaryCollection( Request request, Collection collection, {Iterable include}) => @@ -103,7 +63,6 @@ class HttpResponseFactory implements ResponseFactory { included: request.isCompound ? (include ?? []).map(_resource) : null))); - @override HttpResponse relatedCollection( Request request, Collection collection, {List include}) => @@ -119,7 +78,6 @@ class HttpResponseFactory implements ResponseFactory { included: request.isCompound ? (include ?? []).map(_resource) : null))); - @override HttpResponse relatedResource( Request request, Resource resource, {Iterable include}) => @@ -134,7 +92,6 @@ class HttpResponseFactory implements ResponseFactory { included: request.isCompound ? (include ?? []).map(_resource) : null))); - @override HttpResponse relationshipToMany(Request request, Iterable identifiers) => HttpResponse(200, @@ -148,7 +105,6 @@ class HttpResponseFactory implements ResponseFactory { }, )))); - @override HttpResponse relationshipToOne( Request request, Identifier identifier) => HttpResponse(200, diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart index 15db1032..5e0f1b99 100644 --- a/test/functional/crud/creating_resources_test.dart +++ b/test/functional/crud/creating_resources_test.dart @@ -91,8 +91,8 @@ void main() async { test('404 when the related resource does not exist (to-one)', () async { try { - await client.createNewResource('books', - one: {'publisher': Identifier('companies', '123')}); + await client + .createNewResource('books', one: {'publisher': 'companies:123'}); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 404); @@ -107,7 +107,7 @@ void main() async { test('404 when the related resource does not exist (to-many)', () async { try { await client.createNewResource('books', many: { - 'authors': [Identifier('people', '123')] + 'authors': ['people:123'] }); fail('Exception expected'); } on RequestFailure catch (e) { diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart index 3954de5c..a9ab59ca 100644 --- a/test/functional/crud/seed_resources.dart +++ b/test/functional/crud/seed_resources.dart @@ -14,9 +14,9 @@ Future seedResources(JsonApiClient client) async { 'title': 'Refactoring', 'ISBN-10': '0134757599' }, one: { - 'publisher': Identifier('companies', '1'), + 'publisher': 'companies:1', 'reviewer': null, }, many: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] + 'authors': ['people:1', 'people:2'] }); } diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart index 5417e9df..185ddfbe 100644 --- a/test/functional/crud/updating_resources_test.dart +++ b/test/functional/crud/updating_resources_test.dart @@ -32,8 +32,8 @@ void main() async { }, one: { 'publisher': null, }, many: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] + 'authors': ['people:1'], + 'reviewers': ['people:2'] }); expect(r.http.statusCode, 200); expect(r.http.headers['content-type'], ContentType.jsonApi); From 3b344f980b8bad94b1dc9e6ee78156c16f589373 Mon Sep 17 00:00:00 2001 From: f3ath Date: Tue, 26 May 2020 09:09:51 -0700 Subject: [PATCH 73/99] WIP --- .travis.yml | 2 + analysis_options.yaml | 3 + example/README.md | 30 -- example/client.dart | 50 ---- example/server.dart | 39 --- lib/client.dart | 10 - lib/document.dart | 14 - lib/http.dart | 8 - lib/json_api.dart | 10 + lib/query.dart | 7 - lib/routing.dart | 4 - lib/server.dart | 8 - lib/src/client/json_api_client.dart | 222 --------------- lib/src/client/request.dart | 75 ----- lib/src/{client => }/content_type.dart | 0 lib/src/{client => }/dart_http.dart | 2 +- lib/src/{client => }/document.dart | 6 +- lib/src/document/api.dart | 28 -- lib/src/document/document.dart | 106 ------- lib/src/document/document_exception.dart | 7 - lib/src/document/error_object.dart | 116 -------- lib/src/document/identifier.dart | 38 --- lib/src/document/identity.dart | 7 - lib/src/document/link.dart | 38 --- lib/src/document/links.dart | 8 - lib/src/document/meta.dart | 4 - lib/src/document/primary_data.dart | 19 -- lib/src/document/relationship.dart | 65 ----- lib/src/document/relationship_object.dart | 125 -------- lib/src/document/resource.dart | 117 -------- .../document/resource_collection_data.dart | 40 --- lib/src/document/resource_data.dart | 34 --- lib/src/document/resource_object.dart | 102 ------- lib/src/http/http_handler.dart | 23 -- lib/src/http/http_request.dart | 41 --- lib/src/http/http_response.dart | 16 -- lib/src/http/logging_http_handler.dart | 25 -- lib/src/http/method.dart | 7 - lib/src/http/transforming_http_handler.dart | 22 -- lib/src/json_api_client.dart | 209 ++++++++++++++ lib/src/maybe.dart | 65 ----- lib/src/nullable.dart | 3 - lib/src/query/fields.dart | 40 --- lib/src/query/include.dart | 30 -- lib/src/query/page.dart | 34 --- lib/src/query/query_parameters.dart | 23 -- lib/src/query/sort.dart | 65 ----- lib/src/request.dart | 53 ++++ lib/src/{client => }/response.dart | 66 +++-- lib/src/routing/composite_routing.dart | 33 --- lib/src/routing/contract.dart | 77 ----- lib/src/routing/standard.dart | 110 ------- lib/src/routing/target.dart | 51 ---- lib/src/server/collection.dart | 10 - lib/src/server/controller.dart | 58 ---- lib/src/server/dart_server.dart | 25 -- lib/src/server/in_memory_repository.dart | 91 ------ lib/src/server/json_api_server.dart | 30 -- lib/src/server/pagination.dart | 87 ------ lib/src/server/repository.dart | 85 ------ lib/src/server/repository_controller.dart | 248 ---------------- lib/src/server/request.dart | 18 -- lib/src/server/response.dart | 122 -------- lib/src/server/response_factory.dart | 140 --------- lib/src/server/route.dart | 201 ------------- lib/src/server/route_matcher.dart | 28 -- lib/src/{client => }/status_code.dart | 0 pubspec.yaml | 9 +- test/client_test.dart | 204 +++++++++++++ test/functional/compound_document_test.dart | 149 ---------- .../crud/creating_resources_test.dart | 154 ---------- .../crud/deleting_resources_test.dart | 65 ----- .../crud/fetching_relationships_test.dart | 95 ------- .../crud/fetching_resources_test.dart | 218 -------------- test/functional/crud/seed_resources.dart | 22 -- .../crud/updating_relationships_test.dart | 268 ------------------ .../crud/updating_resources_test.dart | 85 ------ test/helper/expect_same_json.dart | 6 - test/helper/test_http_handler.dart | 12 - test/performance/encode_decode.dart | 97 ------- test/responses.dart | 109 +++++++ test/unit/document/api_test.dart | 23 -- test/unit/document/document_test.dart | 9 - .../unit/document/identifier_object_test.dart | 10 - test/unit/document/json_api_error_test.dart | 34 --- test/unit/document/link_test.dart | 36 --- test/unit/document/meta_members_test.dart | 99 ------- test/unit/document/relationship_test.dart | 37 --- .../resource_collection_data_test.dart | 43 --- test/unit/document/resource_data_test.dart | 58 ---- test/unit/document/resource_object_test.dart | 64 ----- test/unit/document/resource_test.dart | 15 - test/unit/document/to_many_test.dart | 37 --- test/unit/document/to_one_test.dart | 37 --- test/unit/http/logging_http_handler_test.dart | 18 -- test/unit/query/fields_test.dart | 46 --- test/unit/query/include_test.dart | 31 -- test/unit/query/merge_test.dart | 14 - test/unit/query/page_test.dart | 26 -- test/unit/query/sort_test.dart | 37 --- test/unit/routing/standard_routing_test.dart | 46 --- test/unit/server/json_api_server_test.dart | 117 -------- test/unit/server/numbered_page_test.dart | 30 -- {test => tmp}/e2e/browser_test.dart | 6 +- .../e2e/client_server_interaction_test.dart | 12 +- {test => tmp}/e2e/hybrid_server.dart | 0 106 files changed, 641 insertions(+), 5217 deletions(-) delete mode 100644 example/README.md delete mode 100644 example/client.dart delete mode 100644 example/server.dart delete mode 100644 lib/client.dart delete mode 100644 lib/document.dart delete mode 100644 lib/http.dart create mode 100644 lib/json_api.dart delete mode 100644 lib/query.dart delete mode 100644 lib/routing.dart delete mode 100644 lib/server.dart delete mode 100644 lib/src/client/json_api_client.dart delete mode 100644 lib/src/client/request.dart rename lib/src/{client => }/content_type.dart (100%) rename lib/src/{client => }/dart_http.dart (93%) rename lib/src/{client => }/document.dart (99%) delete mode 100644 lib/src/document/api.dart delete mode 100644 lib/src/document/document.dart delete mode 100644 lib/src/document/document_exception.dart delete mode 100644 lib/src/document/error_object.dart delete mode 100644 lib/src/document/identifier.dart delete mode 100644 lib/src/document/identity.dart delete mode 100644 lib/src/document/link.dart delete mode 100644 lib/src/document/links.dart delete mode 100644 lib/src/document/meta.dart delete mode 100644 lib/src/document/primary_data.dart delete mode 100644 lib/src/document/relationship.dart delete mode 100644 lib/src/document/relationship_object.dart delete mode 100644 lib/src/document/resource.dart delete mode 100644 lib/src/document/resource_collection_data.dart delete mode 100644 lib/src/document/resource_data.dart delete mode 100644 lib/src/document/resource_object.dart delete mode 100644 lib/src/http/http_handler.dart delete mode 100644 lib/src/http/http_request.dart delete mode 100644 lib/src/http/http_response.dart delete mode 100644 lib/src/http/logging_http_handler.dart delete mode 100644 lib/src/http/method.dart delete mode 100644 lib/src/http/transforming_http_handler.dart create mode 100644 lib/src/json_api_client.dart delete mode 100644 lib/src/maybe.dart delete mode 100644 lib/src/nullable.dart delete mode 100644 lib/src/query/fields.dart delete mode 100644 lib/src/query/include.dart delete mode 100644 lib/src/query/page.dart delete mode 100644 lib/src/query/query_parameters.dart delete mode 100644 lib/src/query/sort.dart create mode 100644 lib/src/request.dart rename lib/src/{client => }/response.dart (74%) delete mode 100644 lib/src/routing/composite_routing.dart delete mode 100644 lib/src/routing/contract.dart delete mode 100644 lib/src/routing/standard.dart delete mode 100644 lib/src/routing/target.dart delete mode 100644 lib/src/server/collection.dart delete mode 100644 lib/src/server/controller.dart delete mode 100644 lib/src/server/dart_server.dart delete mode 100644 lib/src/server/in_memory_repository.dart delete mode 100644 lib/src/server/json_api_server.dart delete mode 100644 lib/src/server/pagination.dart delete mode 100644 lib/src/server/repository.dart delete mode 100644 lib/src/server/repository_controller.dart delete mode 100644 lib/src/server/request.dart delete mode 100644 lib/src/server/response.dart delete mode 100644 lib/src/server/response_factory.dart delete mode 100644 lib/src/server/route.dart delete mode 100644 lib/src/server/route_matcher.dart rename lib/src/{client => }/status_code.dart (100%) create mode 100644 test/client_test.dart delete mode 100644 test/functional/compound_document_test.dart delete mode 100644 test/functional/crud/creating_resources_test.dart delete mode 100644 test/functional/crud/deleting_resources_test.dart delete mode 100644 test/functional/crud/fetching_relationships_test.dart delete mode 100644 test/functional/crud/fetching_resources_test.dart delete mode 100644 test/functional/crud/seed_resources.dart delete mode 100644 test/functional/crud/updating_relationships_test.dart delete mode 100644 test/functional/crud/updating_resources_test.dart delete mode 100644 test/helper/expect_same_json.dart delete mode 100644 test/helper/test_http_handler.dart delete mode 100644 test/performance/encode_decode.dart create mode 100644 test/responses.dart delete mode 100644 test/unit/document/api_test.dart delete mode 100644 test/unit/document/document_test.dart delete mode 100644 test/unit/document/identifier_object_test.dart delete mode 100644 test/unit/document/json_api_error_test.dart delete mode 100644 test/unit/document/link_test.dart delete mode 100644 test/unit/document/meta_members_test.dart delete mode 100644 test/unit/document/relationship_test.dart delete mode 100644 test/unit/document/resource_collection_data_test.dart delete mode 100644 test/unit/document/resource_data_test.dart delete mode 100644 test/unit/document/resource_object_test.dart delete mode 100644 test/unit/document/resource_test.dart delete mode 100644 test/unit/document/to_many_test.dart delete mode 100644 test/unit/document/to_one_test.dart delete mode 100644 test/unit/http/logging_http_handler_test.dart delete mode 100644 test/unit/query/fields_test.dart delete mode 100644 test/unit/query/include_test.dart delete mode 100644 test/unit/query/merge_test.dart delete mode 100644 test/unit/query/page_test.dart delete mode 100644 test/unit/query/sort_test.dart delete mode 100644 test/unit/routing/standard_routing_test.dart delete mode 100644 test/unit/server/json_api_server_test.dart delete mode 100644 test/unit/server/numbered_page_test.dart rename {test => tmp}/e2e/browser_test.dart (86%) rename {test => tmp}/e2e/client_server_interaction_test.dart (82%) rename {test => tmp}/e2e/hybrid_server.dart (100%) diff --git a/.travis.yml b/.travis.yml index b9c114ce..1bad916c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,8 @@ dart: - "2.6.0" - "2.6.1" - "2.7.0" + - "2.8.0" + - "2.8.1" dart_task: - test: --platform vm - test: --platform chrome diff --git a/analysis_options.yaml b/analysis_options.yaml index d9406f33..fd5782a0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,7 @@ include: package:pedantic/analysis_options.yaml +analyzer: + enable-experiment: + - non-nullable linter: rules: - sort_constructors_first diff --git a/example/README.md b/example/README.md deleted file mode 100644 index 9205e925..00000000 --- a/example/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Client-server interaction example -Run the server: -``` -$ dart example/server.dart -Listening on http://localhost:8080 - -``` -This will start a simple JSON:API server at localhost:8080. It supports 2 resource types: -- [writers](http://localhost:8080/writers) -- [books](http://localhost:8080/books) - -Try opening these links in your browser, you should see empty collections. - -While the server is running, try the client script: -``` -$ dart example/client.dart -POST http://localhost:8080/writers -204 -POST http://localhost:8080/books -204 -GET http://localhost:8080/books/2?include=authors -200 -Book: Resource(books:2 {title: Refactoring}) -Author: Resource(writers:1 {name: Martin Fowler}) -``` -This will create resources in those collections. Try the the following links: - -- [writer](http://localhost:8080/writers/1) -- [book](http://localhost:8080/books/2) -- [book and its author](http://localhost:8080/books/2?include=authors) \ No newline at end of file diff --git a/example/client.dart b/example/client.dart deleted file mode 100644 index e9b84550..00000000 --- a/example/client.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; - -/// This example shows how to use the JSON:API client. -/// Run the server first! -void main() async { - /// Use the standard routing - final routing = StandardRouting(Uri.parse('http://localhost:8080')); - - /// Create the HTTP client. We're using Dart's native client. - /// Do not forget to call [Client.close] when you're done using it. - final httpClient = http.Client(); - - /// We'll use a logging handler wrapper to show the requests and responses. - final httpHandler = LoggingHttpHandler(DartHttp(httpClient), - onRequest: (r) => print('${r.method} ${r.uri}'), - onResponse: (r) => print('${r.statusCode}')); - - /// The JSON:API client - final client = JsonApiClient(httpHandler, routing); - - /// Create the first resource. - await client - .createResource('writers', '1', attributes: {'name': 'Martin Fowler'}); - - /// Create the second resource. - await client.createResource('books', '2', attributes: { - 'title': 'Refactoring' - }, many: { - 'authors': ['writers', '1'] - }); - - /// Fetch the book, including its authors. - final response = - await client.fetchResource('books', '2', include: ['authors']); - - /// Extract the primary resource. - final book = response.resource; - - /// Extract the included resource. - final author = response.included.first; - - print('Book: $book'); - print('Author: $author'); - - /// Do not forget to always close the HTTP client. - httpClient.close(); -} diff --git a/example/server.dart b/example/server.dart deleted file mode 100644 index 5f4df56f..00000000 --- a/example/server.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; - -/// This example shows how to run a simple JSON:API server using the built-in -/// HTTP server (dart:io). -/// Run it: `dart example/server.dart` -void main() async { - /// Listening on this port - final port = 8080; - - /// Listening on the localhost - final address = 'localhost'; - - /// Resource repository supports two kind of entities: writers and books - final repo = InMemoryRepository({'writers': {}, 'books': {}}); - - /// Controller provides JSON:API interface to the repository - final controller = RepositoryController(repo); - - /// The JSON:API server routes requests to the controller - final jsonApiServer = JsonApiServer(controller); - - /// We will be logging the requests and responses to the console - final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, - onRequest: (r) => print('${r.method} ${r.uri}\n${r.headers}'), - onResponse: (r) => print('${r.statusCode}\n${r.headers}')); - - /// The handler for the built-in HTTP server - final serverHandler = DartServer(loggingJsonApiServer); - - /// Start the server - final server = await HttpServer.bind(address, port); - print('Listening on ${Uri(host: address, port: port, scheme: 'http')}'); - - /// Each HTTP request will be processed by the handler - await server.forEach(serverHandler); -} diff --git a/lib/client.dart b/lib/client.dart deleted file mode 100644 index a0e0ff39..00000000 --- a/lib/client.dart +++ /dev/null @@ -1,10 +0,0 @@ -library client; - -export 'package:json_api/src/client/content_type.dart'; -export 'package:json_api/src/client/dart_http.dart'; -export 'package:json_api/src/client/document.dart'; -export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/request.dart'; -export 'package:json_api/src/client/response.dart'; -export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/document.dart b/lib/document.dart deleted file mode 100644 index 1c1d4e6e..00000000 --- a/lib/document.dart +++ /dev/null @@ -1,14 +0,0 @@ -library document; - -export 'package:json_api/src/document/api.dart'; -export 'package:json_api/src/document/document.dart'; -export 'package:json_api/src/document/document_exception.dart'; -export 'package:json_api/src/document/error_object.dart'; -export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/primary_data.dart'; -export 'package:json_api/src/document/relationship_object.dart'; -export 'package:json_api/src/document/resource.dart'; -export 'package:json_api/src/document/resource_collection_data.dart'; -export 'package:json_api/src/document/resource_data.dart'; -export 'package:json_api/src/document/resource_object.dart'; diff --git a/lib/http.dart b/lib/http.dart deleted file mode 100644 index 0b312c0b..00000000 --- a/lib/http.dart +++ /dev/null @@ -1,8 +0,0 @@ -/// This is a thin HTTP layer abstraction used by the client and the server. -library http; - -export 'package:json_api/src/http/http_handler.dart'; -export 'package:json_api/src/http/http_request.dart'; -export 'package:json_api/src/http/http_response.dart'; -export 'package:json_api/src/http/logging_http_handler.dart'; -export 'package:json_api/src/http/transforming_http_handler.dart'; diff --git a/lib/json_api.dart b/lib/json_api.dart new file mode 100644 index 00000000..f6ec69c0 --- /dev/null +++ b/lib/json_api.dart @@ -0,0 +1,10 @@ +library json_api; + +export 'package:json_api/src/content_type.dart'; +export 'package:json_api/src/dart_http.dart'; +export 'package:json_api/src/document.dart'; +export 'package:json_api/src/json_api_client.dart'; +export 'package:json_api/src/json_api_client.dart'; +export 'package:json_api/src/request.dart'; +export 'package:json_api/src/response.dart'; +export 'package:json_api/src/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart deleted file mode 100644 index 5b2f4c8a..00000000 --- a/lib/query.dart +++ /dev/null @@ -1,7 +0,0 @@ -library query; - -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_parameters.dart'; -export 'package:json_api/src/query/sort.dart'; diff --git a/lib/routing.dart b/lib/routing.dart deleted file mode 100644 index 2857daec..00000000 --- a/lib/routing.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'package:json_api/src/routing/composite_routing.dart'; -export 'package:json_api/src/routing/contract.dart'; -export 'package:json_api/src/routing/standard.dart'; -export 'package:json_api/src/routing/target.dart'; diff --git a/lib/server.dart b/lib/server.dart deleted file mode 100644 index bf1a5d96..00000000 --- a/lib/server.dart +++ /dev/null @@ -1,8 +0,0 @@ -library server; - -export 'package:json_api/src/server/dart_server.dart'; -export 'package:json_api/src/server/in_memory_repository.dart'; -export 'package:json_api/src/server/json_api_server.dart'; -export 'package:json_api/src/server/pagination.dart'; -export 'package:json_api/src/server/repository.dart'; -export 'package:json_api/src/server/repository_controller.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart deleted file mode 100644 index eeb43434..00000000 --- a/lib/src/client/json_api_client.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/document.dart'; -import 'package:json_api/src/maybe.dart'; - -/// The JSON:API client -class JsonApiClient { - JsonApiClient(this._http, this._uri); - - final HttpHandler _http; - final UriFactory _uri; - - /// Fetches a primary resource collection by [type]. - Future fetchCollection(String type, - {Map headers, - Iterable include, - Map> fields, - Iterable sort, - Map page, - Map parameters}) async { - final request = JsonApiRequest.get(); - Maybe(headers).ifPresent(request.headers); - Maybe(include).ifPresent(request.include); - Maybe(fields).ifPresent(request.fields); - Maybe(sort).ifPresent(request.sort); - Maybe(page).ifPresent(request.page); - Maybe(parameters).ifPresent(request.parameters); - return FetchCollectionResponse(await call(request, _uri.collection(type))); - } - - /// Fetches a related resource collection by [type], [id], [relationship]. - Future fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, - Iterable include, - Map> fields, - Iterable sort, - Map page, - Map parameters}) async { - final request = JsonApiRequest.get(); - Maybe(headers).ifPresent(request.headers); - Maybe(include).ifPresent(request.include); - Maybe(fields).ifPresent(request.fields); - Maybe(sort).ifPresent(request.sort); - Maybe(page).ifPresent(request.page); - Maybe(parameters).ifPresent(request.parameters); - return FetchCollectionResponse( - await call(request, _uri.related(type, id, relationship))); - } - - /// Fetches a primary resource by [type] and [id]. - Future fetchResource(String type, String id, - {Map headers, - Iterable include, - Map> fields, - Map parameters}) async { - final request = JsonApiRequest.get(); - Maybe(headers).ifPresent(request.headers); - Maybe(include).ifPresent(request.include); - Maybe(fields).ifPresent(request.fields); - Maybe(parameters).ifPresent(request.parameters); - return FetchPrimaryResourceResponse( - await call(request, _uri.resource(type, id))); - } - - /// Fetches a related resource by [type], [id], [relationship]. - Future fetchRelatedResource( - String type, String id, String relationship, - {Map headers, - Iterable include, - Map> fields, - Map parameters}) async { - final request = JsonApiRequest.get(); - Maybe(headers).ifPresent(request.headers); - Maybe(include).ifPresent(request.include); - Maybe(fields).ifPresent(request.fields); - Maybe(parameters).ifPresent(request.parameters); - return FetchRelatedResourceResponse( - await call(request, _uri.related(type, id, relationship))); - } - - /// Fetches a relationship by [type], [id], [relationship]. - Future> - fetchRelationship( - String type, String id, String relationship, - {Map headers}) async { - final request = JsonApiRequest.get(); - Maybe(headers).ifPresent(request.headers); - return FetchRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Creates a new [_resource] on the server. - /// The server is expected to assign the resource id. - Future createNewResource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async { - final request = JsonApiRequest.post(ResourceDocument(Resource(type, - attributes: attributes, relationships: _relationships(one, many)))); - Maybe(headers).ifPresent(request.headers); - return CreateResourceResponse(await call(request, _uri.collection(type))); - } - - /// Creates a new [_resource] on the server. - /// The server is expected to accept the provided resource id. - Future createResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async { - final request = - JsonApiRequest.post(_resource(type, id, attributes, one, many)); - Maybe(headers).ifPresent(request.headers); - return ResourceResponse(await call(request, _uri.collection(type))); - } - - /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, - {Map headers}) async { - final request = JsonApiRequest.delete(); - Maybe(headers).ifPresent(request.headers); - return DeleteResourceResponse(await call(request, _uri.resource(type, id))); - } - - /// Updates the resource by [type] and [id]. - Future updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async { - final request = - JsonApiRequest.patch(_resource(type, id, attributes, one, many)); - Maybe(headers).ifPresent(request.headers); - return ResourceResponse(await call(request, _uri.resource(type, id))); - } - - /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceOne( - String type, String id, String relationship, Identifier identifier, - {Map headers}) async { - final request = JsonApiRequest.patch(One(identifier)); - Maybe(headers).ifPresent(request.headers); - return UpdateRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteOne( - String type, String id, String relationship, - {Map headers}) async { - final request = JsonApiRequest.patch(One.empty()); - Maybe(headers).ifPresent(request.headers); - return UpdateRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) async { - final request = JsonApiRequest.delete(Many(identifiers)); - Maybe(headers).ifPresent(request.headers); - return UpdateRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) async { - final request = JsonApiRequest.patch(Many(identifiers)); - Maybe(headers).ifPresent(request.headers); - return UpdateRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) async { - final request = JsonApiRequest.post(Many(identifiers)); - Maybe(headers).ifPresent(request.headers); - return UpdateRelationshipResponse( - await call(request, _uri.relationship(type, id, relationship))); - } - - /// Sends the [request] to [uri]. - /// If the response is successful, returns the [HttpResponse]. - /// Otherwise, throws a [RequestFailure]. - Future call(JsonApiRequest request, Uri uri) async { - final response = await _http.call(request.toHttp(uri)); - if (StatusCode(response.statusCode).isFailed) { - throw RequestFailure.fromHttp(response); - } - return response; - } - - ResourceDocument _resource( - String type, - String id, - Map attributes, - Map one, - Map> many) => - ResourceDocument(ResourceWithIdentity(type, id, - attributes: attributes, relationships: _relationships(one, many))); - - Map _relationships( - Map one, Map> many) => - { - ...one.map((key, value) => MapEntry( - key, - Maybe(value) - .filter((_) => _.isNotEmpty) - .map((_) => One(Identifier.fromKey(_))) - .orGet(() => One.empty()))), - ...many.map( - (key, value) => MapEntry(key, Many(value.map(Identifier.fromKey)))) - }; -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart deleted file mode 100644 index faee2a3c..00000000 --- a/lib/src/client/request.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/client/content_type.dart'; -import 'package:json_api/src/http/method.dart'; -import 'package:json_api/src/maybe.dart'; - -/// A JSON:API HTTP request builder -class JsonApiRequest { - JsonApiRequest(this._method, {Object document}) - : _body = Maybe(document).map(jsonEncode).or(''); - - JsonApiRequest.get() : this(Method.GET); - - JsonApiRequest.post([Object document]) - : this(Method.POST, document: document); - - JsonApiRequest.patch([Object document]) - : this(Method.PATCH, document: document); - - JsonApiRequest.delete([Object document]) - : this(Method.DELETE, document: document); - - final String _method; - final String _body; - final _headers = {}; - QueryParameters _parameters = QueryParameters.empty(); - - /// Adds headers to the request. - void headers(Map headers) { - _headers.addAll(headers); - } - - /// Requests inclusion of related resources. - /// - /// See https://jsonapi.org/format/#fetching-includes - void include(Iterable items) { - _parameters &= Include(items); - } - - /// Requests collection sorting. - /// - /// See https://jsonapi.org/format/#fetching-sorting - void sort(Iterable sort) { - _parameters &= Sort(sort.map(SortField.parse)); - } - - /// Requests a specific page. - /// - /// See https://jsonapi.org/format/#fetching-pagination - void page(Map page){ - _parameters &= Page(page); - } - - /// Requests sparse fieldsets. - /// - /// See https://jsonapi.org/format/#fetching-sparse-fieldsets - void fields(Map> fields) { - _parameters &= Fields(fields); - } - - /// Sets arbitrary query parameters. - void parameters(Map parameters) { - _parameters &= QueryParameters(parameters); - } - - /// Converts to an HTTP request - HttpRequest toHttp(Uri uri) => - HttpRequest(_method, _parameters.addToUri(uri), body: _body, headers: { - ..._headers, - 'Accept': ContentType.jsonApi, - if (_body.isNotEmpty) 'Content-Type': ContentType.jsonApi - }); -} diff --git a/lib/src/client/content_type.dart b/lib/src/content_type.dart similarity index 100% rename from lib/src/client/content_type.dart rename to lib/src/content_type.dart diff --git a/lib/src/client/dart_http.dart b/lib/src/dart_http.dart similarity index 93% rename from lib/src/client/dart_http.dart rename to lib/src/dart_http.dart index 5cfc12b0..92d7aeef 100644 --- a/lib/src/client/dart_http.dart +++ b/lib/src/dart_http.dart @@ -1,5 +1,5 @@ import 'package:http/http.dart'; -import 'package:json_api/http.dart'; +import 'package:json_api_common/http.dart'; /// A handler using the Dart's built-in http client class DartHttp implements HttpHandler { diff --git a/lib/src/client/document.dart b/lib/src/document.dart similarity index 99% rename from lib/src/client/document.dart rename to lib/src/document.dart index b49e2086..5d71a2eb 100644 --- a/lib/src/client/document.dart +++ b/lib/src/document.dart @@ -1,6 +1,6 @@ import 'dart:collection'; -import 'package:json_api/src/maybe.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; class ResourceDocument { ResourceDocument(this._resource); @@ -296,11 +296,11 @@ class ResourceWithIdentity extends Resource with Identity { final Map links; Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) - .filter((_) => _ is Many) + .cast() .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); One one(String key, {One Function() orElse}) => Maybe(relationships[key]) - .filter((_) => _ is One) + .cast() .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); @override diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart deleted file mode 100644 index 2ccf50fd..00000000 --- a/lib/src/document/api.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/meta.dart'; - -/// Details: https://jsonapi.org/format/#document-jsonapi-object -class Api with Meta { - Api({String version, Map meta}) : version = version ?? v1 { - this.meta.addAll(meta ?? {}); - } - - static const v1 = '1.0'; - - /// The JSON:API version. May be null. - final String version; - - bool get isNotEmpty => version.isNotEmpty || meta.isNotEmpty; - - static Api fromJson(Object json) { - if (json is Map) { - return Api(version: json['version'], meta: json['meta']); - } - throw DocumentException("The 'jsonapi' member must be a JSON object"); - } - - Map toJson() => { - if (version != v1) 'version': version, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart deleted file mode 100644 index e3dca051..00000000 --- a/lib/src/document/document.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/api.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/error_object.dart'; -import 'package:json_api/src/document/meta.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/nullable.dart'; - -class Document with Meta { - /// Create a document with primary data - Document(this.data, - {Map meta, Api api, Iterable included}) - : errors = const [], - included = List.unmodifiable(included ?? []), - api = api ?? Api(), - isError = false, - isCompound = included != null, - isMeta = false { - ArgumentError.checkNotNull(data); - this.meta.addAll(meta ?? {}); - } - - /// Create a document with errors (no primary data) - Document.error(Iterable errors, - {Map meta, Api api}) - : data = null, - included = const [], - errors = List.unmodifiable(errors ?? const []), - api = api ?? Api(), - isCompound = false, - isError = true, - isMeta = false { - this.meta.addAll(meta ?? {}); - } - - /// Create an empty document (no primary data and no errors) - Document.empty(Map meta, {Api api}) - : data = null, - included = const [], - errors = const [], - api = api ?? Api(), - isError = false, - isCompound = false, - isMeta = true { - ArgumentError.checkNotNull(meta); - this.meta.addAll(meta); - } - - /// The Primary Data. May be null. - final D data; - - /// Included objects in a compound document. - final List included; - - /// True for non-error documents with included resources. - final bool isCompound; - - /// List of errors. May be empty. - final List errors; - - /// The `jsonapi` object. - final Api api; - - /// True for error documents. - final bool isError; - - /// True for non-error meta-only documents. - final bool isMeta; - - /// Reconstructs a document with the specified primary data - static Document fromJson( - Object json, D Function(Object _) decode) { - if (json is Map) { - final api = nullable(Api.fromJson)(json['jsonapi']); - final meta = json['meta']; - if (json.containsKey('errors')) { - final errors = json['errors']; - if (errors is List) { - return Document.error(errors.map(ErrorObject.fromJson), - meta: meta, api: api); - } - } else if (json.containsKey('data')) { - final included = json['included']; - return Document(decode(json), - meta: meta, - api: api, - included: included is List - ? included.map(ResourceObject.fromJson) - : null); - } else if (json['meta'] != null) { - return Document.empty(meta, api: api); - } - throw DocumentException('Unrecognized JSON:API document structure'); - } - throw DocumentException('A JSON:API document must be a JSON object'); - } - - static const contentType = 'application/vnd.api+json'; - - Map toJson() => { - if (data != null) ...data.toJson() else if (isError) 'errors': errors, - if (isMeta || meta.isNotEmpty) 'meta': meta, - if (api.isNotEmpty) 'jsonapi': api, - if (isCompound) 'included': included, - }; -} diff --git a/lib/src/document/document_exception.dart b/lib/src/document/document_exception.dart deleted file mode 100644 index 65e02d6c..00000000 --- a/lib/src/document/document_exception.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Indicates a violation of JSON:API Document structure or data constraints. -class DocumentException implements Exception { - DocumentException(this.message); - - /// Human-readable text explaining the issue. - final String message; -} diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart deleted file mode 100644 index 4ff58a7e..00000000 --- a/lib/src/document/error_object.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/nullable.dart'; - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - ErrorSource source, - Map links, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - source = source ?? ErrorSource(), - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: nullable(ErrorSource.fromJson)(json['source']), - meta: json['meta'], - links: nullable(Link.mapFromJson)(json['links'])); - } - throw DocumentException('A JSON:API error must be a JSON object'); - } - - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// The `source` object. - final ErrorSource source; - - final Map meta; - final Map links; - - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } -} - -/// An object containing references to the source of the error, optionally including any of the following members: -/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, -/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. -/// - parameter: a string indicating which URI query parameter caused the error. -class ErrorSource { - ErrorSource({String pointer, String parameter}) - : pointer = pointer ?? '', - parameter = parameter ?? ''; - - static ErrorSource fromJson(Object json) { - if (json is Map) { - return ErrorSource( - pointer: json['pointer'], parameter: json['parameter']); - } - throw DocumentException('Can not parse ErrorSource'); - } - - final String pointer; - - final String parameter; - - bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; - - Map toJson() => { - if (pointer.isNotEmpty) 'pointer': pointer, - if (parameter.isNotEmpty) 'parameter': parameter - }; -} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart deleted file mode 100644 index b2fbb960..00000000 --- a/lib/src/document/identifier.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identity.dart'; -import 'package:json_api/src/document/meta.dart'; - -/// Resource identifier -/// -/// Together with [Resource] forms the core of the Document model. -/// Identifiers are passed between the server and the client in the form -/// of [IdentifierObject]s. -class Identifier with Meta, Identity { - /// Neither [type] nor [id] can be null or empty. - Identifier(this.type, this.id, {Map meta}) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - this.meta.addAll(meta ?? {}); - } - - static Identifier fromJson(Object json) { - if (json is Map) { - return Identifier(json['type'], json['id'], meta: json['meta']); - } - throw DocumentException('A JSON:API identifier must be a JSON object'); - } - - /// Resource type - @override - final String type; - - /// Resource id - @override - final String id; - - Map toJson() => { - 'type': type, - 'id': id, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart deleted file mode 100644 index 3f57c93b..00000000 --- a/lib/src/document/identity.dart +++ /dev/null @@ -1,7 +0,0 @@ -mixin Identity { - String get type; - - String get id; - - String get key => '$type:$id'; -} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart deleted file mode 100644 index 7dec855c..00000000 --- a/lib/src/document/link.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/meta.dart'; - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link with Meta { - Link(this.uri, {Map meta}) { - ArgumentError.checkNotNull(uri, 'uri'); - this.meta.addAll(meta ?? {}); - } - - final Uri uri; - - /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - return Link(Uri.parse(json['href']), meta: json['meta']); - } - throw DocumentException( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); - } - throw DocumentException('A JSON:API links object must be a JSON object'); - } - - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; - - @override - String toString() => uri.toString(); -} diff --git a/lib/src/document/links.dart b/lib/src/document/links.dart deleted file mode 100644 index edf9708e..00000000 --- a/lib/src/document/links.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:json_api/src/document/link.dart'; - -mixin Links { - /// The `links` object. - /// May be empty. - /// https://jsonapi.org/format/#document-links - final links = {}; -} diff --git a/lib/src/document/meta.dart b/lib/src/document/meta.dart deleted file mode 100644 index c052f8d9..00000000 --- a/lib/src/document/meta.dart +++ /dev/null @@ -1,4 +0,0 @@ -mixin Meta { - /// Meta data. May be empty. - final meta = {}; -} diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart deleted file mode 100644 index 1e3cea78..00000000 --- a/lib/src/document/primary_data.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/links.dart'; - -/// The top-level Primary Data. This is the essentials of the JSON:API Document. -/// -/// [PrimaryData] may be considered a Document itself with two limitations: -/// - 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 with Links { - PrimaryData({Map links}) { - this.links.addAll(links ?? {}); - } - - Map toJson() => { - if (links.isNotEmpty) 'links': links, - }; -} - -typedef PrimaryDataDecoder = D Function(Object json); diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart deleted file mode 100644 index a1d9e76e..00000000 --- a/lib/src/document/relationship.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:json_api/document.dart'; - -class ToOne extends RelationshipObject { - ToOne(Identifier identifier, {Map links}) - : super(links: links) { - set(identifier); - } - - ToOne.empty({Map links}) : super(links: links); - - final _values = {}; - - bool get isEmpty => _values.isEmpty; - - T mapIfExists(T Function(Identifier _) map, T Function() orElse) => - _values.isEmpty ? orElse() : map(_values.first); - - List toList() => mapIfExists((_) => [_], () => []); - - void set(Identifier identifier) { - ArgumentError.checkNotNull(identifier, 'identifier'); - _values - ..clear() - ..add(identifier); - } - - void clear() { - _values.clear(); - } - - @override - Map toJson() => {...super.toJson(), 'data': _values.first}; - - static ToOne fromNullable(Identifier identifier) => - identifier == null ? ToOne.empty() : ToOne(identifier); -} - -class ToMany extends RelationshipObject { - ToMany(Iterable identifiers, {Map links}) - : super(links: links) { - set(identifiers); - } - - final _map = {}; - - int get length => _map.length; - - List toList() => [..._map.values]; - - void set(Iterable identifiers) { - _map..clear(); - identifiers.forEach((i) => _map[i.key] = i); - } - - void remove(Iterable identifiers) { - identifiers.forEach((i) => _map.remove(i.key)); - } - - void addAll(Iterable identifiers) { - identifiers.forEach((i) => _map[i.key] = i); - } - - @override - Map toJson() => {...super.toJson(), 'data': _map.values}; -} diff --git a/lib/src/document/relationship_object.dart b/lib/src/document/relationship_object.dart deleted file mode 100644 index be35859d..00000000 --- a/lib/src/document/relationship_object.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/meta.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_object.dart'; -import 'package:json_api/src/nullable.dart'; - -/// The Relationship represents the references between the resources. -/// -/// A Relationship can be a JSON:API Document itself when -/// requested separately as described here https://jsonapi.org/format/#fetching-relationships. -/// -/// It can also be a part of [ResourceObject].relationships map. -/// -/// More on this: https://jsonapi.org/format/#document-resource-object-relationships -class RelationshipObject extends PrimaryData with Meta { - RelationshipObject({Map links}) : super(links: links); - - /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static RelationshipObject fromJson(Object json) { - if (json is Map) { - if (json.containsKey('data')) { - final data = json['data']; - if (data == null || data is Map) { - return ToOneObject.fromJson(json); - } - if (data is List) { - return ToManyObject.fromJson(json); - } - } - return RelationshipObject( - links: nullable(Link.mapFromJson)(json['links'])); - } - throw DocumentException( - 'A JSON:API relationship object must be a JSON object'); - } - - /// Parses the `relationships` member of a Resource Object - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map( - (k, v) => MapEntry(k.toString(), RelationshipObject.fromJson(v))); - } - throw DocumentException("The 'relationships' member must be a JSON object"); - } - - @override - Map toJson() => - {...super.toJson(), if (meta.isNotEmpty) 'meta': meta}; -} - -/// Relationship to-one -class ToOneObject extends RelationshipObject { - ToOneObject(this.linkage, {Map links}) : super(links: links); - - ToOneObject.empty({Link self, Map links}) - : linkage = null, - super(links: links); - - static ToOneObject fromIdentifier(Identifier identifier) => - ToOneObject(identifier); - - static ToOneObject fromJson(Object json) { - if (json is Map && json.containsKey('data')) { - return ToOneObject(nullable(Identifier.fromJson)(json['data']), - links: nullable(Link.mapFromJson)(json['links'])); - } - throw DocumentException( - "A to-one relationship must be a JSON object and contain the 'data' member"); - } - - /// Resource Linkage - /// - /// Can be null for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final Identifier linkage; - - ToOne unwrap() => ToOne.fromNullable(linkage); - - @override - Map toJson() => { - ...super.toJson(), - 'data': linkage, - }; -} - -/// Relationship to-many -class ToManyObject extends RelationshipObject { - ToManyObject(Iterable linkage, {Map links}) - : linkage = List.unmodifiable(linkage), - super(links: links); - - static ToManyObject fromIdentifiers(Iterable identifiers) => - ToManyObject(identifiers); - - static ToManyObject fromJson(Object json) { - if (json is Map && json.containsKey('data')) { - final data = json['data']; - if (data is List) { - return ToManyObject( - data.map(Identifier.fromJson), - links: nullable(Link.mapFromJson)(json['links']), - ); - } - } - throw DocumentException( - "A to-many relationship must be a JSON object and contain the 'data' member"); - } - - /// Resource Linkage - /// - /// Can be empty for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final List linkage; - - @override - Map toJson() => { - ...super.toJson(), - 'data': linkage, - }; -} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart deleted file mode 100644 index 254d6d9e..00000000 --- a/lib/src/document/resource.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/identity.dart'; -import 'package:json_api/src/document/links.dart'; -import 'package:json_api/src/document/meta.dart'; -import 'package:json_api/src/document/relationship.dart'; - -/// Resource -/// -/// Together with [Identifier] forms the core of the Document model. -/// Resources are passed between the server and the client in the form -/// of [ResourceObject]s. -class Resource with Meta, Links, Identity { - /// Creates an instance of [Resource]. - /// The [type] can not be null. - /// The [id] may be null for the resources to be created on the server. - Resource(this.type, this.id, - {Map attributes, - Map meta, - Map links, - Map toOne, - Map> toMany}) { - ArgumentError.notNull(type); - this.attributes.addAll(attributes ?? {}); - this.meta.addAll(meta ?? {}); - this.links.addAll(links ?? {}); - toOne?.forEach((k, v) => this._toOne[k] = ToOne.fromNullable(v)); - toMany?.forEach((k, v) => this._toMany[k] = ToMany(v)); - } - - /// Resource type - @override - final String type; - - /// Resource id - /// - /// May be null for resources to be created on the server - @override - final String id; - - /// The map of attributes - final attributes = {}; - - /// The map of to-one relationships - final _toOne = {}; - - /// The map of to-many relationships - final _toMany = {}; - - /// All related resource identifiers. - List get related => _toOne.values - .map((_) => _.toList()) - .followedBy(_toMany.values.map((_) => _.toList())) - .expand((_) => _) - .toList(); - - List relatedByKey(String key) { - if (hasOne(key)) { - return _toOne[key].toList(); - } - if (hasMany(key)) { - return _toMany[key].toList(); - } - return []; - } - - /// True for resources without attributes and relationships - bool get isEmpty => attributes.isEmpty && _toOne.isEmpty && _toMany.isEmpty; - - bool hasOne(String key) => _toOne.containsKey(key); - - ToOne one(String key) => - _toOne[key] ?? (throw StateError('No such relationship')); - - ToMany many(String key) => - _toMany[key] ?? (throw StateError('No such relationship')); - - bool hasMany(String key) => _toMany.containsKey(key); - - void addAll(Resource other) { - attributes.addAll(other.attributes); - _toOne.addAll(other._toOne); - _toMany.addAll(other._toMany); - } - - Resource withId(String newId) { - // TODO: move to NewResource() - if (id != null) throw StateError('Should not change id'); - return Resource(type, newId, attributes: attributes) - .._toOne.addAll(_toOne) - .._toMany.addAll(_toMany); - } - - Map get relationships => { - ..._toOne.map((k, v) => MapEntry( - k, v.mapIfExists((_) => ToOneObject(_), () => ToOneObject(null)))), - ..._toMany.map((k, v) => MapEntry(k, ToManyObject(v.toList()))) - }; - - @override - String toString() => 'Resource($key)'; - - Map toJson() => { - 'type': type, - 'id': id, - if (meta.isNotEmpty) 'meta': meta, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (links.isNotEmpty) 'links': links, - }; -} - -/// Resource to be created on the server. Does not have the id yet -class NewResource extends Resource { - NewResource(String type, {Map attributes}) - : super(type, null, attributes: attributes); -} diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart deleted file mode 100644 index b0f47bdf..00000000 --- a/lib/src/document/resource_collection_data.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_object.dart'; - -/// Represents a resource collection or a collection of related resources of a to-many relationship -class ResourceCollectionData extends PrimaryData { - ResourceCollectionData(Iterable collection, - {Map links}) - : collection = List.unmodifiable(collection ?? const []), - super(links: links); - - static ResourceCollectionData fromJson(Object json) { - if (json is Map) { - final data = json['data']; - if (data is List) { - return ResourceCollectionData(data.map(ResourceObject.fromJson), - links: Link.mapFromJson(json['links'] ?? {})); - } - } - throw DocumentException( - "A JSON:API resource collection document must be a JSON object with a JSON array in the 'data' member"); - } - - final List collection; - - /// Returns a list of resources contained in the collection - List unwrap() => collection.map((_) => _.unwrap()).toList(); - - /// Returns a map of resources indexed by ids - Map unwrapToMap() => - Map.fromIterable(unwrap(), key: (r) => r.id); - - @override - Map toJson() => { - ...super.toJson(), - ...{'data': collection}, - }; -} diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart deleted file mode 100644 index 495efdf8..00000000 --- a/lib/src/document/resource_data.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_object.dart'; -import 'package:json_api/src/nullable.dart'; - -/// Represents a single resource or a single related resource of a to-one relationship -class ResourceData extends PrimaryData { - ResourceData(this.resourceObject, {Map links}) - : super(links: links); - - static ResourceData fromResource(Resource resource) => - ResourceData(ResourceObject.fromResource(resource)); - - static ResourceData fromJson(Object json) { - if (json is Map) { - return ResourceData(nullable(ResourceObject.fromJson)(json['data']), - links: nullable(Link.mapFromJson)(json['links'])); - } - throw DocumentException( - "A JSON:API resource document must be a JSON object and contain the 'data' member"); - } - - final ResourceObject resourceObject; - - @override - Map toJson() => { - ...super.toJson(), - 'data': resourceObject?.toJson(), - }; - - Resource unwrap() => resourceObject?.unwrap(); -} diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart deleted file mode 100644 index 76d2ac5a..00000000 --- a/lib/src/document/resource_object.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/links.dart'; -import 'package:json_api/src/document/meta.dart'; -import 'package:json_api/src/document/relationship_object.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/nullable.dart'; - -/// [ResourceObject] is a JSON representation of a [Resource]. -/// -/// 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. -/// -/// More on this: https://jsonapi.org/format/#document-resource-objects -class ResourceObject with Meta, Links { - ResourceObject(this.type, this.id, - {Map attributes, - Map relationships, - Map meta, - Map links}) - : attributes = Map.unmodifiable(attributes ?? const {}), - relationships = Map.unmodifiable(relationships ?? const {}) { - this.meta.addAll(meta ?? {}); - this.links.addAll(links ?? {}); - } - - static ResourceObject fromResource(Resource resource) => - ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: resource.relationships); - - /// Reconstructs the `data` member of a JSON:API Document. - static ResourceObject fromJson(Object json) { - if (json is Map) { - final relationships = json['relationships']; - final attributes = json['attributes']; - final type = json['type']; - if ((relationships == null || relationships is Map) && - (attributes == null || attributes is Map) && - type is String && - type.isNotEmpty) { - return ResourceObject(json['type'], json['id'], - attributes: attributes, - relationships: - nullable(RelationshipObject.mapFromJson)(relationships), - links: Link.mapFromJson(json['links'] ?? {}), - meta: json['meta']); - } - throw DocumentException('Invalid JSON:API resource object'); - } - throw DocumentException('A JSON:API resource must be a JSON object'); - } - - final String type; - final String id; - final Map attributes; - final Map relationships; - - Link get self => links['self']; - - /// Returns the JSON object to be used in the `data` or `included` members - /// of a JSON:API Document - Map toJson() => { - 'type': type, - if (id != null) 'id': id, - if (meta.isNotEmpty) 'meta': meta, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (links.isNotEmpty) 'links': links, - }; - - /// Extracts the [Resource] if possible. The standard allows relationships - /// without `data` member. In this case the original [Resource] can not be - /// recovered and this method will throw a [StateError]. - /// - /// Example of missing `data`: https://discuss.jsonapi.org/t/relationships-data-node/223 - Resource unwrap() { - final toOne = {}; - final toMany = >{}; - final incomplete = {}; - relationships.forEach((name, rel) { - if (rel is ToOneObject) { - toOne[name] = rel.linkage; - } else if (rel is ToManyObject) { - toMany[name] = rel.linkage; - } else { - incomplete[name] = rel; - } - }); - - if (incomplete.isNotEmpty) { - throw StateError('Can not convert to resource' - ' due to incomplete relationship: ${incomplete.keys}'); - } - - return Resource(type, id, - attributes: attributes, toOne: toOne, toMany: toMany); - } -} diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart deleted file mode 100644 index 7693fd07..00000000 --- a/lib/src/http/http_handler.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -/// A callable class which converts requests to responses -abstract class HttpHandler { - /// Sends the request over the network and returns the received response - Future call(HttpRequest request); - - /// Creates an instance of [HttpHandler] from a function - static HttpHandler fromFunction(HttpHandlerFunc f) => _HandlerFromFunction(f); -} - -/// This typedef is compatible with [HttpHandler] -typedef HttpHandlerFunc = Future Function(HttpRequest request); - -class _HandlerFromFunction implements HttpHandler { - const _HandlerFromFunction(this._f); - - @override - Future call(HttpRequest request) => _f(request); - - final HttpHandlerFunc _f; -} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart deleted file mode 100644 index 42cd2cd5..00000000 --- a/lib/src/http/http_request.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:json_api/src/http/method.dart'; - -/// The request which is sent by the client and received by the server -class HttpRequest { - HttpRequest(String method, Uri uri, - {String body, Map headers}) - : this._(method.toUpperCase(), uri, _normalize(headers), body ?? ''); - - HttpRequest._(this.method, this.uri, this.headers, this.body); - - /// Requested URI - final Uri uri; - - /// Request method, uppercase - final String method; - - /// Request body - final String body; - - /// Request headers. Unmodifiable. Lowercase keys - final Map headers; - - static Map _normalize(Map headers) => - Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))); - - HttpRequest withHeaders(Map headers) => - HttpRequest._(method, uri, _normalize(headers), body); - - HttpRequest withUri(Uri uri) => HttpRequest._(method, uri, headers, body); - - bool get isGet => method == Method.GET; - - bool get isPost => method == Method.POST; - - bool get isDelete => method == Method.DELETE; - - bool get isPatch => method == Method.PATCH; - - bool get isOptions => method == Method.OPTIONS; -} diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart deleted file mode 100644 index dbe90e7e..00000000 --- a/lib/src/http/http_response.dart +++ /dev/null @@ -1,16 +0,0 @@ -/// The response sent by the server and received by the client -class HttpResponse { - HttpResponse(this.statusCode, {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - body = body ?? ''; - - /// Response status code - final int statusCode; - - /// Response body - final String body; - - /// Response headers. Unmodifiable. Lowercase keys - final Map headers; -} diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart deleted file mode 100644 index ddc092d6..00000000 --- a/lib/src/http/logging_http_handler.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/src/http/http_handler.dart'; -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -/// A wrapper over [HttpHandler] which allows logging -class LoggingHttpHandler implements HttpHandler { - LoggingHttpHandler(this.wrapped, {this.onRequest, this.onResponse}); - - /// The wrapped handler - final HttpHandler wrapped; - - /// This function will be called before the request is sent - final void Function(HttpRequest) onRequest; - - /// This function will be called after the response is received - final void Function(HttpResponse) onResponse; - - @override - Future call(HttpRequest request) async { - onRequest?.call(request); - final response = await wrapped(request); - onResponse?.call(response); - return response; - } -} diff --git a/lib/src/http/method.dart b/lib/src/http/method.dart deleted file mode 100644 index 770d404d..00000000 --- a/lib/src/http/method.dart +++ /dev/null @@ -1,7 +0,0 @@ -class Method { - static final DELETE = 'DELETE'; - static final GET = 'GET'; - static final OPTIONS = 'OPTIONS'; - static final PATCH = 'PATCH'; - static final POST = 'POST'; -} diff --git a/lib/src/http/transforming_http_handler.dart b/lib/src/http/transforming_http_handler.dart deleted file mode 100644 index dea514c3..00000000 --- a/lib/src/http/transforming_http_handler.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/http.dart'; - -class TransformingHttpHandler implements HttpHandler { - TransformingHttpHandler(this._handler, - {HttpRequestTransformer requestTransformer, - HttpResponseTransformer responseTransformer}) - : _requestTransformer = requestTransformer ?? _identity, - _responseTransformer = responseTransformer ?? _identity; - - final HttpHandler _handler; - final HttpRequestTransformer _requestTransformer; - final HttpResponseTransformer _responseTransformer; - - @override - Future call(HttpRequest request) async => - _responseTransformer(await _handler.call(_requestTransformer(request))); -} - -typedef HttpRequestTransformer = HttpRequest Function(HttpRequest _); -typedef HttpResponseTransformer = HttpResponse Function(HttpResponse _); - -T _identity(T _) => _; diff --git a/lib/src/json_api_client.dart b/lib/src/json_api_client.dart new file mode 100644 index 00000000..4cc99047 --- /dev/null +++ b/lib/src/json_api_client.dart @@ -0,0 +1,209 @@ +import 'package:json_api/json_api.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api_common/http.dart'; +import 'package:json_api_common/url_design.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +/// The JSON:API client +class JsonApiClient { + JsonApiClient(this._http, this._url); + + final HttpHandler _http; + final UrlDesign _url; + + /// Fetches a primary resource collection by [type]. + Future fetchCollection(String type, + {Map headers, + Iterable include, + Map> fields, + Iterable sort, + Map page, + Map query}) async => + FetchCollection(await call( + JsonApiRequest('GET', + headers: headers, + include: include, + fields: fields, + sort: sort, + page: page, + query: query), + _url.collection(type))); + + /// Fetches a related resource collection by [type], [id], [relationship]. + Future fetchRelatedCollection( + String type, String id, String relationship, + {Map headers, + Iterable include, + Map> fields, + Iterable sort, + Map page, + Map query}) async => + FetchCollection(await call( + JsonApiRequest('GET', + headers: headers, + include: include, + fields: fields, + sort: sort, + page: page, + query: query), + _url.related(type, id, relationship))); + + /// Fetches a primary resource by [type] and [id]. + Future fetchResource(String type, String id, + {Map headers, + Iterable include, + Map> fields, + Map query}) async => + FetchPrimaryResource(await call( + JsonApiRequest('GET', + headers: headers, include: include, fields: fields, query: query), + _url.resource(type, id))); + + /// Fetches a related resource by [type], [id], [relationship]. + Future fetchRelatedResource( + String type, String id, String relationship, + {Map headers, + Iterable include, + Map> fields, + Map query}) async => + FetchRelatedResource(await call( + JsonApiRequest('GET', + headers: headers, include: include, fields: fields, query: query), + _url.related(type, id, relationship))); + + /// Fetches a relationship by [type], [id], [relationship]. + Future> fetchRelationship( + String type, String id, String relationship, + {Map headers, Map query}) async => + FetchRelationship(await call( + JsonApiRequest('GET', headers: headers, query: query), + _url.relationship(type, id, relationship))); + + /// Creates a new resource on the server. + /// The server is expected to assign the resource id. + Future createNewResource(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async => + CreateResource(await call( + JsonApiRequest('POST', + headers: headers, + document: ResourceDocument(Resource(type, + attributes: attributes, + relationships: _relationships(one, many)))), + _url.collection(type))); + + /// Creates a resource on the server. + /// The server is expected to accept the provided resource id. + Future createResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async => + UpdateResource(await call( + JsonApiRequest('POST', + headers: headers, + document: _resource(type, id, attributes, one, many)), + _url.collection(type))); + + /// Deletes the resource by [type] and [id]. + Future deleteResource(String type, String id, + {Map headers}) async => + DeleteResource(await call( + JsonApiRequest('DELETE', headers: headers), _url.resource(type, id))); + + /// Updates the resource by [type] and [id]. + Future updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map headers}) async => + UpdateResource(await call( + JsonApiRequest('PATCH', + headers: headers, + document: _resource(type, id, attributes, one, many)), + _url.resource(type, id))); + + /// Replaces the to-one [relationship] of [type] : [id]. + Future> replaceOne( + String type, String id, String relationship, Identifier identifier, + {Map headers}) async => + UpdateRelationship(await call( + JsonApiRequest('PATCH', headers: headers, document: One(identifier)), + _url.relationship(type, id, relationship))); + + /// Deletes the to-one [relationship] of [type] : [id]. + Future> deleteOne( + String type, String id, String relationship, + {Map headers}) async => + UpdateRelationship(await call( + JsonApiRequest('PATCH', headers: headers, document: One.empty()), + _url.relationship(type, id, relationship))); + + /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. + Future> deleteMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) async => + UpdateRelationship(await call( + JsonApiRequest('DELETE', + headers: headers, document: Many(identifiers)), + _url.relationship(type, id, relationship))); + + /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. + Future> replaceMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers}) async => + UpdateRelationship(await call( + JsonApiRequest('PATCH', + headers: headers, document: Many(identifiers)), + _url.relationship(type, id, relationship))); + + /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. + Future> addMany(String type, String id, + String relationship, Iterable identifiers, + {Map headers = const {}}) async => + UpdateRelationship(await call( + JsonApiRequest('POST', headers: headers, document: Many(identifiers)), + _url.relationship(type, id, relationship))); + + /// Sends the [request] to [uri]. + /// If the response is successful, returns the [HttpResponse]. + /// Otherwise, throws a [RequestFailure]. + Future call(JsonApiRequest request, Uri uri) async { + final response = await _http.call(HttpRequest( + request.method, + request.query.isEmpty + ? uri + : uri.replace(queryParameters: request.query), + body: request.body, + headers: request.headers)); + if (StatusCode(response.statusCode).isFailed) { + throw RequestFailure.fromHttp(response); + } + return response; + } + + ResourceDocument _resource( + String type, + String id, + Map attributes, + Map one, + Map> many) => + ResourceDocument(ResourceWithIdentity(type, id, + attributes: attributes, relationships: _relationships(one, many))); + + Map _relationships( + Map one, Map> many) => + { + ...one.map((key, value) => MapEntry( + key, + Maybe(value) + .filter((_) => _.isNotEmpty) + .map(Identifier.fromKey) + .map((_) => One(_)) + .orGet(() => One.empty()))), + ...many.map( + (key, value) => MapEntry(key, Many(value.map(Identifier.fromKey)))) + }; +} diff --git a/lib/src/maybe.dart b/lib/src/maybe.dart deleted file mode 100644 index 8d98325e..00000000 --- a/lib/src/maybe.dart +++ /dev/null @@ -1,65 +0,0 @@ -/// A variation of the Maybe monad with eager execution. -abstract class Maybe { - factory Maybe(T value) => value == null ? Nothing() : Just(value); - - /// Maps the value - Maybe

map

(P Function(T _) f); - - Maybe filter(bool Function(T _) f); - - T or(T _); - - T orGet(T Function() f); - - T orThrow(Object Function() f); - - void ifPresent(void Function(T _) f); -} - -class Just implements Maybe { - Just(this.value) { - ArgumentError.checkNotNull(value); - } - - final T value; - - @override - Maybe

map

(P Function(T _) f) => Maybe(f(value)); - - @override - T or(T _) => value; - - @override - T orGet(T Function() f) => value; - - @override - T orThrow(Object Function() f) => value; - - @override - void ifPresent(void Function(T _) f) => f(value); - - @override - Maybe filter(bool Function(T _) f) => f(value) ? this : Nothing(); -} - -class Nothing implements Maybe { - Nothing(); - - @override - Maybe

map

(P Function(T _) map) => Nothing

(); - - @override - T or(T _) => _; - - @override - T orGet(T Function() f) => f(); - - @override - T orThrow(Object Function() f) => throw f(); - - @override - void ifPresent(void Function(T _) f) {} - - @override - Maybe filter(bool Function(T _) f) => this; -} diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart deleted file mode 100644 index 88675a9c..00000000 --- a/lib/src/nullable.dart +++ /dev/null @@ -1,3 +0,0 @@ -_Fun nullable(U Function(V v) f) => (v) => v == null ? null : f(v); - -typedef _Fun = U Function(V v); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart deleted file mode 100644 index ef63c7bf..00000000 --- a/lib/src/query/fields.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:json_api/src/query/query_parameters.dart'; - -/// Query parameters defining Sparse Fieldsets -/// @see https://jsonapi.org/format/#fetching-sparse-fieldsets -class Fields extends QueryParameters { - /// The [fields] argument maps the resource type to a list of fields. - /// - /// Example: - /// ```dart - /// Fields({'articles': ['title', 'body'], 'people': ['name']}).addTo(url); - /// ``` - /// encodes to - /// ``` - /// ?fields[articles]=title,body&fields[people]=name - /// ``` - Fields(Map> fields) - : _fields = {...fields}, - super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); - - /// Extracts the requested fields from the [uri]. - static Fields fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); - - /// Extracts the requested fields from [queryParameters]. - static Fields fromQueryParameters( - Map> queryParameters) => - Fields(queryParameters.map((k, v) => MapEntry( - _regex.firstMatch(k)?.group(1), - v.expand((_) => _.split(',')).toList())) - ..removeWhere((k, v) => k == null)); - - List operator [](String key) => _fields[key]; - - bool get isEmpty => _fields.isEmpty; - - bool get isNotEmpty => _fields.isNotEmpty; - - static final _regex = RegExp(r'^fields\[(.+)\]$'); - - final Map> _fields; -} diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart deleted file mode 100644 index a82273a3..00000000 --- a/lib/src/query/include.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/src/query/query_parameters.dart'; - -/// Query parameter defining inclusion of related resources. -/// @see https://jsonapi.org/format/#fetching-includes -class Include extends QueryParameters with IterableMixin { - /// Example: - /// ```dart - /// Include(['comments', 'comments.author']).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?include=comments,comments.author - /// ``` - Include(Iterable resources) - : _resources = [...resources], - super({'include': resources.join(',')}); - - static Include fromUri(Uri uri) => - fromQueryParameters(uri.queryParametersAll); - - static Include fromQueryParameters(Map> parameters) => - Include((parameters['include']?.expand((_) => _.split(',')) ?? [])); - - @override - Iterator get iterator => _resources.iterator; - - final List _resources; -} diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart deleted file mode 100644 index dceac7f4..00000000 --- a/lib/src/query/page.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_api/src/query/query_parameters.dart'; - -/// Query parameters defining the pagination data. -/// @see https://jsonapi.org/format/#fetching-pagination -class Page extends QueryParameters { - /// Example: - /// ```dart - /// Page({'limit': '10', 'offset': '20'}).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?page[limit]=10&page[offset]=20 - /// ``` - /// - Page(Map parameters) - : _parameters = {...parameters}, - super(parameters.map((k, v) => MapEntry('page[${k}]', v))); - - static Page fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); - - static Page fromQueryParameters(Map> queryParameters) => - Page(queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.last)) - ..removeWhere((k, v) => k == null)); - - String operator [](String key) => _parameters[key]; - - static final _regex = RegExp(r'^page\[(.+)\]$'); - - bool get isEmpty => _parameters.isEmpty; - - bool get isNotEmpty => _parameters.isNotEmpty; - final Map _parameters; -} diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart deleted file mode 100644 index 106971cd..00000000 --- a/lib/src/query/query_parameters.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// This class and its descendants describe the query parameters recognized -/// by JSON:API. -class QueryParameters { - QueryParameters(Map parameters) - : _parameters = {...parameters}; - - QueryParameters.empty() : this(const {}); - - final Map _parameters; - - /// Adds (or replaces) this parameters to the [uri]. - Uri addToUri(Uri uri) => _parameters.isEmpty - ? uri - : uri.replace(queryParameters: {...uri.queryParameters, ..._parameters}); - - /// Merges this parameters with [other] parameters. Returns a new instance. - QueryParameters merge(QueryParameters other) => - QueryParameters({..._parameters, ...other._parameters}); - - /// A shortcut for [merge] - QueryParameters operator &(QueryParameters moreParameters) => - merge(moreParameters); -} diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart deleted file mode 100644 index 0636b1d1..00000000 --- a/lib/src/query/sort.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/src/query/query_parameters.dart'; - -/// Query parameters defining the sorting. -/// @see https://jsonapi.org/format/#fetching-sorting -class Sort extends QueryParameters with IterableMixin { - /// The [fields] arguments is the list of sorting criteria. - /// Use [Asc] and [Desc] to define sort direction. - /// - /// Example: - /// ```dart - /// Sort([Asc('created'), Desc('title')]).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?sort=-created,title - /// ``` - Sort(Iterable fields) - : _fields = [...fields], - super({'sort': fields.join(',')}); - final List _fields; - - static Sort fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); - - static Sort fromQueryParameters(Map> queryParameters) => - Sort((queryParameters['sort']?.expand((_) => _.split(',')) ?? []) - .map(SortField.parse)); - - @override - Iterator get iterator => _fields.iterator; -} - -class SortField { - SortField.Asc(this.name) - : isAsc = true, - isDesc = false; - - SortField.Desc(this.name) - : isAsc = false, - isDesc = true; - - static SortField parse(String queryParam) => queryParam.startsWith('-') - ? Desc(queryParam.substring(1)) - : Asc(queryParam); - final bool isAsc; - - final bool isDesc; - - final String name; - - /// Returns 1 for Ascending fields, -1 for Descending - int get comparisonFactor => isAsc ? 1 : -1; - - @override - String toString() => isAsc ? name : '-$name'; -} - -class Asc extends SortField { - Asc(String name) : super.Asc(name); -} - -class Desc extends SortField { - Desc(String name) : super.Desc(name); -} diff --git a/lib/src/request.dart b/lib/src/request.dart new file mode 100644 index 00000000..8f0d5125 --- /dev/null +++ b/lib/src/request.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:json_api/src/content_type.dart'; +import 'package:json_api_common/query.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +/// A JSON:API HTTP request. +class JsonApiRequest { + /// Created an instance of JSON:API HTTP request. + /// + /// - [method] the HTTP method + /// - [document] if passed, will be JSON-encoded and sent in the HTTP body + /// - [headers] any arbitrary HTTP headers + /// - [include] related resources to include (for GET requests) + /// - [fields] sparse fieldsets (for GET requests) + /// - [sort] sorting options (for GET collection requests) + /// - [page] pagination options (for GET collection requests) + /// - [query] any arbitrary query parameters (for GET requests) + JsonApiRequest(String method, + {Object document, + Map headers, + Iterable include, + Map> fields, + Iterable sort, + Map page, + Map query}) + : method = method.toLowerCase(), + body = Maybe(document).map(jsonEncode).or(''), + query = Map.unmodifiable({ + if (include != null) ...Include(include).asQueryParameters, + if (fields != null) ...Fields(fields).asQueryParameters, + if (sort != null) ...Sort(sort).asQueryParameters, + if (page != null) ...Page(page).asQueryParameters, + ...?query, + }), + headers = Map.unmodifiable({ + 'Accept': ContentType.jsonApi, + if (document != null) 'Content-Type': ContentType.jsonApi, + ...?headers, + }); + + /// HTTP method, lowercase. + final String method; + + /// HTTP body. + final String body; + + /// HTTP headers. + final Map headers; + + /// Map of query parameters. + final Map query; +} diff --git a/lib/src/client/response.dart b/lib/src/response.dart similarity index 74% rename from lib/src/client/response.dart rename to lib/src/response.dart index 2df797db..42f715bc 100644 --- a/lib/src/client/response.dart +++ b/lib/src/response.dart @@ -1,21 +1,21 @@ import 'dart:collection'; import 'dart:convert'; -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/document.dart'; -import 'package:json_api/src/maybe.dart'; +import 'package:json_api/json_api.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api_common/http.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; -class FetchCollectionResponse with IterableMixin { - factory FetchCollectionResponse(HttpResponse http) { +class FetchCollection with IterableMixin { + factory FetchCollection(HttpResponse http) { final document = DataDocument.fromJson(jsonDecode(http.body)); - return FetchCollectionResponse._(http, + return FetchCollection._(http, resources: ResourceCollection.fromJson(document.data), included: ResourceCollection(document.included(orElse: () => [])), links: document.links); } - FetchCollectionResponse._(this.http, + FetchCollection._(this.http, {ResourceCollection resources, ResourceCollection included, Map links = const {}}) @@ -32,16 +32,16 @@ class FetchCollectionResponse with IterableMixin { Iterator get iterator => resources.iterator; } -class FetchPrimaryResourceResponse { - factory FetchPrimaryResourceResponse(HttpResponse http) { +class FetchPrimaryResource { + factory FetchPrimaryResource(HttpResponse http) { final document = DataDocument.fromJson(jsonDecode(http.body)); - return FetchPrimaryResourceResponse._( + return FetchPrimaryResource._( http, ResourceWithIdentity.fromJson(document.data), included: ResourceCollection(document.included(orElse: () => [])), links: document.links); } - FetchPrimaryResourceResponse._(this.http, this.resource, + FetchPrimaryResource._(this.http, this.resource, {ResourceCollection included, Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); @@ -52,15 +52,14 @@ class FetchPrimaryResourceResponse { final Map links; } -class CreateResourceResponse { - factory CreateResourceResponse(HttpResponse http) { +class CreateResource { + factory CreateResource(HttpResponse http) { final document = DataDocument.fromJson(jsonDecode(http.body)); - return CreateResourceResponse._( - http, ResourceWithIdentity.fromJson(document.data), + return CreateResource._(http, ResourceWithIdentity.fromJson(document.data), links: document.links); } - CreateResourceResponse._(this.http, this.resource, + CreateResource._(this.http, this.resource, {Map links = const {}}) : links = Map.unmodifiable(links ?? const {}); @@ -69,23 +68,22 @@ class CreateResourceResponse { final ResourceWithIdentity resource; } -class ResourceResponse { - factory ResourceResponse(HttpResponse http) { +class UpdateResource { + factory UpdateResource(HttpResponse http) { if (http.body.isEmpty) { - return ResourceResponse._empty(http); + return UpdateResource._empty(http); } final document = DataDocument.fromJson(jsonDecode(http.body)); - return ResourceResponse._( - http, ResourceWithIdentity.fromJson(document.data), + return UpdateResource._(http, ResourceWithIdentity.fromJson(document.data), links: document.links); } - ResourceResponse._(this.http, ResourceWithIdentity resource, + UpdateResource._(this.http, ResourceWithIdentity resource, {Map links = const {}}) : _resource = Just(resource), links = Map.unmodifiable(links ?? const {}); - ResourceResponse._empty(this.http) + UpdateResource._empty(this.http) : _resource = Nothing(), links = const {}; @@ -98,8 +96,8 @@ class ResourceResponse { Maybe(orElse).orThrow(() => StateError('No content returned'))()); } -class DeleteResourceResponse { - DeleteResourceResponse(this.http) +class DeleteResource { + DeleteResource(this.http) : meta = http.body.isEmpty ? const {} : Document.fromJson(jsonDecode(http.body)).meta; @@ -108,16 +106,16 @@ class DeleteResourceResponse { final Map meta; } -class FetchRelationshipResponse { - FetchRelationshipResponse(this.http) +class FetchRelationship { + FetchRelationship(this.http) : relationship = Relationship.fromJson(jsonDecode(http.body)).as(); final HttpResponse http; final R relationship; } -class UpdateRelationshipResponse { - UpdateRelationshipResponse(this.http) +class UpdateRelationship { + UpdateRelationship(this.http) : _relationship = Maybe(http.body) .filter((_) => _.isNotEmpty) .map(jsonDecode) @@ -131,16 +129,16 @@ class UpdateRelationshipResponse { () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); } -class FetchRelatedResourceResponse { - factory FetchRelatedResourceResponse(HttpResponse http) { +class FetchRelatedResource { + factory FetchRelatedResource(HttpResponse http) { final document = DataDocument.fromJson(jsonDecode(http.body)); - return FetchRelatedResourceResponse._( + return FetchRelatedResource._( http, Maybe(document.data).map(ResourceWithIdentity.fromJson), included: ResourceCollection(document.included(orElse: () => [])), links: document.links); } - FetchRelatedResourceResponse._(this.http, this._resource, + FetchRelatedResource._(this.http, this._resource, {ResourceCollection included, Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), included = included ?? ResourceCollection(const []); diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart deleted file mode 100644 index 5a5b3cb4..00000000 --- a/lib/src/routing/composite_routing.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:json_api/src/routing/contract.dart'; - -/// URI design composed of independent URI patterns. -class CompositeRouting implements Routing { - CompositeRouting( - this._collection, this._resource, this._related, this._relationship); - - final CollectionUriPattern _collection; - final ResourceUriPattern _resource; - final RelatedUriPattern _related; - final RelationshipUriPattern _relationship; - - @override - Uri collection(String type) => _collection.uri(type); - - @override - Uri related(String type, String id, String relationship) => - _related.uri(type, id, relationship); - - @override - Uri relationship(String type, String id, String relationship) => - _relationship.uri(type, id, relationship); - - @override - Uri resource(String type, String id) => _resource.uri(type, id); - - @override - bool match(Uri uri, UriMatchHandler handler) => - _collection.match(uri, handler.collection) || - _resource.match(uri, handler.resource) || - _related.match(uri, handler.related) || - _relationship.match(uri, handler.relationship); -} diff --git a/lib/src/routing/contract.dart b/lib/src/routing/contract.dart deleted file mode 100644 index f4aedd9c..00000000 --- a/lib/src/routing/contract.dart +++ /dev/null @@ -1,77 +0,0 @@ -/// Makes URIs for specific targets -abstract class UriFactory { - /// 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); -} - -abstract class CollectionUriPattern { - /// Returns the URI for a collection of type [type]. - Uri uri(String type); - - /// Matches the [uri] with a collection URI pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type) onMatch); -} - -abstract class RelationshipUriPattern { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a relationship URI pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id, String rel) onMatch); -} - -abstract class RelatedUriPattern { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a related URI pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id, String rel) onMatch); -} - -abstract class ResourceUriPattern { - Uri uri(String type, String id); - - /// Matches the [uri] with a resource URI pattern. - /// If the match is successful, calls [onMatch]. - /// Returns true if the match was successful. - bool match(Uri uri, Function(String type, String id) onMatch); -} - -/// Matches the URI with URI Design patterns. -/// -/// See https://jsonapi.org/recommendations/#urls -abstract class UriPatternMatcher { - /// Matches the [uri] with route patterns. - /// If there is a match, calls the corresponding method of the [handler]. - /// Returns true if match was found. - bool match(Uri uri, UriMatchHandler handler); -} - -abstract class UriMatchHandler { - void collection(String type); - - void resource(String type, String id); - - void related(String type, String id, String relationship); - - void relationship(String type, String id, String relationship); -} - -abstract class Routing implements UriFactory, UriPatternMatcher {} diff --git a/lib/src/routing/standard.dart b/lib/src/routing/standard.dart deleted file mode 100644 index e7a6d8fd..00000000 --- a/lib/src/routing/standard.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:json_api/src/routing/composite_routing.dart'; -import 'package:json_api/src/routing/contract.dart'; - -/// The standard (recommended) URI design -class StandardRouting extends CompositeRouting { - StandardRouting([Uri base]) - : super(StandardCollection(base), StandardResource(base), - StandardRelated(base), StandardRelationship(base)); -} - -/// The recommended URI design for a primary resource collections. -/// Example: `/photos` -/// -/// See: https://jsonapi.org/recommendations/#urls-resource-collections -class StandardCollection extends _BaseRoute implements CollectionUriPattern { - StandardCollection([Uri base]) : super(base); - - @override - bool match(Uri uri, Function(String type) onMatch) { - final seg = _segments(uri); - if (seg.length == 1) { - onMatch(seg.first); - return true; - } - return false; - } - - @override - Uri uri(String type) => _resolve([type]); -} - -/// The recommended URI design for a primary resource. -/// Example: `/photos/1` -/// -/// See: https://jsonapi.org/recommendations/#urls-individual-resources -class StandardResource extends _BaseRoute implements ResourceUriPattern { - StandardResource([Uri base]) : super(base); - - @override - bool match(Uri uri, Function(String type, String id) onMatch) { - final seg = _segments(uri); - if (seg.length == 2) { - onMatch(seg.first, seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id) => _resolve([type, id]); -} - -/// The recommended URI design for a related resource or collections. -/// Example: `/photos/1/comments` -/// -/// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelated extends _BaseRoute implements RelatedUriPattern { - StandardRelated([Uri base]) : super(base); - - @override - bool match(Uri uri, Function(String type, String id, String rel) onMatch) { - final seg = _segments(uri); - if (seg.length == 3) { - onMatch(seg.first, seg[1], seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id, String relationship) => - _resolve([type, id, relationship]); -} - -/// The recommended URI design for a relationship. -/// Example: `/photos/1/relationships/comments` -/// -/// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelationship extends _BaseRoute - implements RelationshipUriPattern { - StandardRelationship([Uri base]) : super(base); - - @override - bool match(Uri uri, Function(String type, String id, String rel) onMatch) { - final seg = _segments(uri); - if (seg.length == 4 && seg[2] == _rel) { - onMatch(seg.first, seg[1], seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id, String relationship) => - _resolve([type, id, _rel, relationship]); - - static const _rel = 'relationships'; -} - -class _BaseRoute { - _BaseRoute([Uri base]) : _base = base ?? Uri(path: '/'); - - final Uri _base; - - Uri _resolve(List pathSegments) => - _base.resolveUri(Uri(pathSegments: pathSegments)); - - List _segments(Uri uri) => - uri.pathSegments.skip(_base.pathSegments.length).toList(); -} diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart deleted file mode 100644 index 9e9b4986..00000000 --- a/lib/src/routing/target.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:json_api/routing.dart'; - -class CollectionTarget { - CollectionTarget(this.type); - - final String type; - - Uri link(UriFactory factory) => factory.collection(type); -} - -class ResourceTarget implements CollectionTarget { - ResourceTarget(this.type, this.id); - - @override - final String type; - - final String id; - - @override - Uri link(UriFactory factory) => factory.resource(type, id); -} - -class RelationshipTarget implements ResourceTarget { - RelationshipTarget(this.type, this.id, this.relationship); - - @override - final String type; - - @override - final String id; - - final String relationship; - - @override - Uri link(UriFactory factory) => factory.relationship(type, id, relationship); -} - -class RelatedTarget implements ResourceTarget { - RelatedTarget(this.type, this.id, this.relationship); - - @override - final String type; - - @override - final String id; - - final String relationship; - - @override - Uri link(UriFactory factory) => factory.related(type, id, relationship); -} diff --git a/lib/src/server/collection.dart b/lib/src/server/collection.dart deleted file mode 100644 index c5382fed..00000000 --- a/lib/src/server/collection.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// A collection of elements (e.g. resources) returned by the server. -class Collection { - Collection(Iterable elements, [this.total]) - : elements = List.unmodifiable(elements); - - final List elements; - - /// Total count of the elements on the server. May be null. - final int total; -} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart deleted file mode 100644 index 5ab5238b..00000000 --- a/lib/src/server/controller.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/response.dart'; - -/// This is a controller consolidating all possible requests a JSON:API server -/// may handle. -abstract class Controller { - /// Finds an returns a primary resource collection. - /// See https://jsonapi.org/format/#fetching-resources - Future fetchCollection(Request request); - - /// Finds an returns a primary resource. - /// See https://jsonapi.org/format/#fetching-resources - Future fetchResource(Request request); - - /// Finds an returns a related resource or a collection of related resources. - /// See https://jsonapi.org/format/#fetching-resources - Future fetchRelated(Request request); - - /// Finds an returns a relationship of a primary resource. - /// See https://jsonapi.org/format/#fetching-relationships - Future fetchRelationship(Request request); - - /// Deletes the resource. - /// See https://jsonapi.org/format/#crud-deleting - Future deleteResource(Request request); - - /// Creates a new resource in the collection. - /// See https://jsonapi.org/format/#crud-creating - Future createResource( - Request request, Resource resource); - - /// Updates the resource. - /// See https://jsonapi.org/format/#crud-updating - Future updateResource( - Request request, Resource resource); - - /// Replaces the to-one relationship. - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - Future replaceToOne( - Request request, Identifier identifier); - - /// Replaces the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future replaceToMany( - Request request, List identifiers); - - /// Removes the given identifiers from the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future deleteFromRelationship( - Request request, List identifiers); - - /// Adds the given identifiers to the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - Future addToRelationship( - Request request, List identifiers); -} diff --git a/lib/src/server/dart_server.dart b/lib/src/server/dart_server.dart deleted file mode 100644 index 04850ba1..00000000 --- a/lib/src/server/dart_server.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; -import 'dart:io' as dart; - -import 'package:json_api/http.dart'; - -class DartServer { - DartServer(this._handler); - - final HttpHandler _handler; - - Future call(dart.HttpRequest request) async { - final response = await _handler(await _convertRequest(request)); - response.headers.forEach(request.response.headers.add); - request.response.statusCode = response.statusCode; - request.response.write(response.body); - await request.response.close(); - } - - Future _convertRequest(dart.HttpRequest r) async { - final body = await r.cast>().transform(utf8.decoder).join(); - final headers = {}; - r.headers.forEach((k, v) => headers[k] = v.join(', ')); - return HttpRequest(r.method, r.requestedUri, body: body, headers: headers); - } -} diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart deleted file mode 100644 index 7fcfe4b3..00000000 --- a/lib/src/server/in_memory_repository.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/repository.dart'; - -typedef IdGenerator = String Function(); -typedef TypeAttributionCriteria = bool Function(String collection, String type); - -/// An in-memory implementation of [Repository] -class InMemoryRepository implements Repository { - InMemoryRepository(this._collections, {IdGenerator nextId}) - : _nextId = nextId; - final Map> _collections; - final IdGenerator _nextId; - - @override - Future create(String collection, Resource resource) async { - if (!_collections.containsKey(collection)) { - throw CollectionNotFound("Collection '$collection' does not exist"); - } - if (collection != resource.type) { - throw _invalidType(resource, collection); - } - for (final relationship in resource.related) { - // Make sure the relationships exist - await get(relationship.type, relationship.id); - } - if (resource.id == null) { - if (_nextId == null) { - throw UnsupportedOperation('Id generation is not supported'); - } - final id = _nextId(); - final created = resource.withId(id); - _collections[collection][created.id] = created; - return created; - } - if (_collections[collection].containsKey(resource.id)) { - throw ResourceExists('Resource with this type and id already exists'); - } - _collections[collection][resource.id] = resource; - return null; - } - - @override - Future get(String type, String id) async { - if (_collections.containsKey(type)) { - final resource = _collections[type][id]; - if (resource == null) { - throw ResourceNotFound("Resource '${id}' does not exist in '${type}'"); - } - return resource; - } - throw CollectionNotFound("Collection '${type}' does not exist"); - } - - @override - Future update(String type, String id, Resource resource) async { - if (type != resource.type) { - throw _invalidType(resource, type); - } - final original = await get(type, id); - if (resource.isEmpty && resource.id == id) { - return null; - } - original.addAll(resource); - return original; - } - - @override - Future delete(String type, String id) async { - await get(type, id); - _collections[type].remove(id); - return null; - } - - @override - Future> getCollection(String type, - {int limit, int offset, List sort}) async { - if (_collections.containsKey(type)) { - return Collection(_collections[type].values, _collections[type].length); - } - throw CollectionNotFound("Collection '$type' does not exist"); - } - - InvalidType _invalidType(Resource resource, String collection) { - return InvalidType( - "Type '${resource.type}' does not belong in '$collection'"); - } -} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart deleted file mode 100644 index 5628d8dd..00000000 --- a/lib/src/server/json_api_server.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/response_factory.dart'; -import 'package:json_api/src/server/route.dart'; -import 'package:json_api/src/server/route_matcher.dart'; - -/// A simple implementation of JSON:API server -class JsonApiServer implements HttpHandler { - JsonApiServer(this._controller, - {Routing routing, ResponseFactory responseFactory}) - : _routing = routing ?? StandardRouting(), - _rf = responseFactory ?? ResponseFactory(routing ?? StandardRouting()); - - final Routing _routing; - final ResponseFactory _rf; - final Controller _controller; - - @override - Future call(HttpRequest httpRequest) async { - final matcher = RouteMatcher(); - _routing.match(httpRequest.uri, matcher); - return (await ErrorHandling(CorsEnabled(matcher.getMatchedRouteOrElse( - () => UnmatchedRoute(allowedMethods: [httpRequest.method])))) - .dispatch(httpRequest, _controller)) - .convert(_rf); - } -} diff --git a/lib/src/server/pagination.dart b/lib/src/server/pagination.dart deleted file mode 100644 index 9877a16d..00000000 --- a/lib/src/server/pagination.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:json_api/src/query/page.dart'; - -/// Pagination strategy determines how pagination information is encoded in the -/// URL query parameters -abstract class Pagination { - /// Number of elements per page. Null for unlimited. - int limit(Page page); - - /// The page offset. - int offset(Page page); - - /// Link to the first page. Null if not supported. - Page first(); - - /// Reference to the last page. Null if not supported. - Page last(int total); - - /// Reference to the next page. Null if not supported or if current page is the last. - Page next(Page page, [int total]); - - /// Reference to the first page. Null if not supported or if current page is the first. - Page prev(Page page); -} - -/// No pagination. The server will not be able to produce pagination links. -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; -} - -/// Pages of fixed [size]. -class FixedSizePage implements Pagination { - FixedSizePage(this.size) { - if (size < 1) throw ArgumentError(); - } - - final int size; - - @override - Page first() => _page(1); - - @override - Page last(int total) => _page((total - 1) ~/ size + 1); - - @override - Page next(Page page, [int total]) { - final number = _number(page); - if (total == null || number * size < total) { - return _page(number + 1); - } - return null; - } - - @override - Page prev(Page page) { - final number = _number(page); - if (number > 1) return _page(number - 1); - return null; - } - - @override - int limit(Page page) => size; - - @override - int offset(Page page) => size * (_number(page) - 1); - - int _number(Page page) => int.parse(page['number'] ?? '1'); - - Page _page(int number) => Page({'number': number.toString()}); -} diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart deleted file mode 100644 index 41673861..00000000 --- a/lib/src/server/repository.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/server/collection.dart'; - -/// The Repository translates CRUD operations on resources to actual data -/// manipulation. -abstract class Repository { - /// Creates the [resource] in the [collection]. - /// If the resource was modified during creation, - /// this method must return the modified resource (e.g. with the generated id). - /// Otherwise must return null. - /// - /// Throws [CollectionNotFound] if there is no such [collection]. - - /// Throws [ResourceNotFound] if one or more related resources are not found. - /// - /// Throws [UnsupportedOperation] if the operation - /// is not supported (e.g. the client sent a resource without the id, but - /// the id generation is not supported by this repository). This exception - /// will be converted to HTTP 403 error. - /// - /// Throws [InvalidType] if the [resource] - /// does not belong to the collection. - Future create(String collection, Resource resource); - - /// Returns the resource by [type] and [id]. - Future get(String type, String id); - - /// Updates the resource identified by [type] and [id]. - /// If the resource was modified during update, returns the modified resource. - /// Otherwise returns null. - Future update(String type, String id, Resource resource); - - /// Deletes the resource identified by [type] and [id] - Future delete(String type, String id); - - /// Returns a collection of resources - Future> getCollection(String collection, - {int limit, int offset, List sort}); -} - -/// Thrown when the requested collection does not exist -/// This exception should result in HTTP 404. -class CollectionNotFound implements Exception { - CollectionNotFound(this.message); - - final String message; -} - -/// Thrown when the requested resource does not exist. -/// This exception should result in HTTP 404. -class ResourceNotFound implements Exception { - ResourceNotFound(this.message); - - final String message; -} - -/// Thrown if the operation -/// is not supported (e.g. the client sent a resource without the id, but -/// the id generation is not supported by this repository). -/// This exception should result in HTTP 403. -class UnsupportedOperation implements Exception { - UnsupportedOperation(this.message); - - final String message; -} - -/// Thrown if the resource type does not belong to the collection. -/// This exception should result in HTTP 409. -class InvalidType implements Exception { - InvalidType(this.message); - - final String message; -} - -/// Thrown if the client asks to create a resource which already exists. -/// This exception should result in HTTP 409. -class ResourceExists implements Exception { - ResourceExists(this.message); - - final String message; -} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart deleted file mode 100644 index e33f53ae..00000000 --- a/lib/src/server/repository_controller.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/response.dart'; - -/// An opinionated implementation of [Controller]. Translates JSON:API -/// requests to [Repository] methods calls. -class RepositoryController implements Controller { - RepositoryController(this._repo, {Pagination pagination}) - : _pagination = pagination ?? NoPagination(); - - final Repository _repo; - final Pagination _pagination; - - @override - Future addToRelationship( - Request request, List identifiers) => - _do(() async { - final resource = - await _repo.get(request.target.type, request.target.id); - if (!resource.hasMany(request.target.relationship)) { - return ErrorResponse(404, [ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "There is no to-many relationship '${request.target.relationship}' in this resource") - ]); - } - resource.many(request.target.relationship).addAll(identifiers); - return ToManyResponse( - request, resource.many(request.target.relationship).toList()); - }); - - @override - Future createResource( - Request request, Resource resource) => - _do(() async { - final modified = await _repo.create(request.target.type, resource); - if (modified == null) { - return NoContentResponse(); - } - return CreatedResourceResponse(request, modified); - }); - - @override - Future deleteFromRelationship( - Request request, List identifiers) => - _do(() async { - final resource = - await _repo.get(request.target.type, request.target.id); - if (resource.hasMany(request.target.relationship)) { - resource.many(request.target.relationship).remove(identifiers); - return ToManyResponse( - request, resource.many(request.target.relationship).toList()); - } - return ErrorResponse(404, [ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "There is no to-many relationship '${request.target.relationship}' in this resource") - ]); - }); - - @override - Future deleteResource(Request request) => - _do(() async { - await _repo.delete(request.target.type, request.target.id); - return NoContentResponse(); - }); - - @override - Future fetchCollection(Request request) => - _do(() async { - final limit = _pagination.limit(request.page); - final offset = _pagination.offset(request.page); - - final collection = await _repo.getCollection(request.target.type, - sort: request.sort.toList(), limit: limit, offset: offset); - - final resources = []; - for (final resource in collection.elements) { - for (final path in request.include) { - resources.addAll(await _getRelated(resource, path.split('.'))); - } - } - return PrimaryCollectionResponse(request, collection, - include: request.isCompound ? resources : null); - }); - - @override - Future fetchRelated(Request request) => - _do(() async { - final resource = - await _repo.get(request.target.type, request.target.id); - if (resource.hasOne(request.target.relationship)) { - return RelatedResourceResponse( - request, - await resource.one(request.target.relationship).mapIfExists( - (i) async => _repo.get(i.type, i.id), () async => null)); - } - if (resource.hasMany(request.target.relationship)) { - final related = []; - for (final identifier - in resource.many(request.target.relationship).toList()) { - related.add(await _repo.get(identifier.type, identifier.id)); - } - return RelatedCollectionResponse(request, Collection(related)); - } - return ErrorResponse( - 404, _relationshipNotFound(request.target.relationship)); - }); - - @override - Future fetchRelationship(Request request) => - _do(() async { - final resource = - await _repo.get(request.target.type, request.target.id); - if (resource.hasOne(request.target.relationship)) { - return ToOneResponse( - request, - resource - .one(request.target.relationship) - .mapIfExists((i) => i, () => null)); - } - if (resource.hasMany(request.target.relationship)) { - return ToManyResponse( - request, resource.many(request.target.relationship).toList()); - } - return ErrorResponse( - 404, _relationshipNotFound(request.target.relationship)); - }); - - @override - Future fetchResource(Request request) => - _do(() async { - final resource = - await _repo.get(request.target.type, request.target.id); - final resources = []; - for (final path in request.include) { - resources.addAll(await _getRelated(resource, path.split('.'))); - } - return PrimaryResourceResponse(request, resource, - include: request.isCompound ? resources : null); - }); - - @override - Future replaceToMany( - Request request, List identifiers) => - _do(() async { - await _repo.update( - request.target.type, - request.target.id, - Resource(request.target.type, request.target.id, - toMany: {request.target.relationship: identifiers})); - return NoContentResponse(); - }); - - @override - Future updateResource( - Request request, Resource resource) => - _do(() async { - final modified = await _repo.update( - request.target.type, request.target.id, resource); - if (modified == null) { - return NoContentResponse(); - } - return PrimaryResourceResponse(request, modified, include: null); - }); - - @override - Future replaceToOne( - Request request, Identifier identifier) => - _do(() async { - await _repo.update( - request.target.type, - request.target.id, - Resource(request.target.type, request.target.id, - toOne: {request.target.relationship: identifier})); - return NoContentResponse(); - }); - - Future> _getRelated( - Resource resource, - Iterable path, - ) async { - if (path.isEmpty) return []; - final resources = []; - for (final id in resource.relatedByKey(path.first)) { - final r = await _repo.get(id.type, id.id); - if (path.length > 1) { - resources.addAll(await _getRelated(r, path.skip(1))); - } else { - resources.add(r); - } - } - return _unique(resources); - } - - Iterable _unique(Iterable included) => - Map.fromIterable(included, - key: (_) => '${_.type}:${_.id}').values; - - Future _do(Future Function() action) async { - try { - return await action(); - } on UnsupportedOperation catch (e) { - return ErrorResponse(403, [ - ErrorObject( - status: '403', title: 'Unsupported operation', detail: e.message) - ]); - } on CollectionNotFound catch (e) { - return ErrorResponse(404, [ - ErrorObject( - status: '404', title: 'Collection not found', detail: e.message) - ]); - } on ResourceNotFound catch (e) { - return ErrorResponse(404, [ - ErrorObject( - status: '404', title: 'Resource not found', detail: e.message) - ]); - } on InvalidType catch (e) { - return ErrorResponse(409, [ - ErrorObject( - status: '409', title: 'Invalid resource type', detail: e.message) - ]); - } on ResourceExists catch (e) { - return ErrorResponse(409, [ - ErrorObject(status: '409', title: 'Resource exists', detail: e.message) - ]); - } - } - - List _relationshipNotFound(String relationship) => [ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "Relationship '$relationship' does not exist in this resource") - ]; -} diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart deleted file mode 100644 index 430622eb..00000000 --- a/lib/src/server/request.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/routing/target.dart'; - -class Request { - Request(this.uri, this.target) - : sort = Sort.fromUri(uri), - include = Include.fromUri(uri), - page = Page.fromUri(uri); - - final Uri uri; - final Include include; - final Page page; - final Sort sort; - final T target; - - bool get isCompound => include.isNotEmpty; -} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart deleted file mode 100644 index d41a506b..00000000 --- a/lib/src/server/response.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/routing/target.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/response_factory.dart'; - -abstract class Response { - HttpResponse convert(ResponseFactory f); -} - -class ExtraHeaders implements Response { - ExtraHeaders(this._response, this._headers); - - final Response _response; - final Map _headers; - - @override - HttpResponse convert(ResponseFactory f) { - final r = _response.convert(f); - return HttpResponse(r.statusCode, - body: r.body, headers: {...r.headers, ..._headers}); - } -} - -class ErrorResponse implements Response { - ErrorResponse(this.status, this.errors); - - final int status; - final Iterable errors; - - @override - HttpResponse convert(ResponseFactory f) => f.error(status, errors: errors); -} - -class NoContentResponse implements Response { - NoContentResponse(); - - @override - HttpResponse convert(ResponseFactory f) => f.noContent(); -} - -class PrimaryResourceResponse implements Response { - PrimaryResourceResponse(this.request, this.resource, {this.include}); - - final Request request; - final Resource resource; - final List include; - - @override - HttpResponse convert(ResponseFactory f) => - f.primaryResource(request, resource, include: include); -} - -class RelatedResourceResponse implements Response { - RelatedResourceResponse(this.request, this.resource, {this.include}); - - final Request request; - final Resource resource; - final Iterable include; - - @override - HttpResponse convert(ResponseFactory f) => - f.relatedResource(request, resource, include: include); -} - -class CreatedResourceResponse implements Response { - CreatedResourceResponse(this.request, this.resource); - - final Request request; - final Resource resource; - - @override - HttpResponse convert(ResponseFactory f) => - f.createdResource(request, resource); -} - -class PrimaryCollectionResponse implements Response { - PrimaryCollectionResponse(this.request, this.collection, {this.include}); - - final Request request; - final Collection collection; - final Iterable include; - - @override - HttpResponse convert(ResponseFactory f) => - f.primaryCollection(request, collection, include: include); -} - -class RelatedCollectionResponse implements Response { - RelatedCollectionResponse(this.request, this.collection, {this.include}); - - final Request request; - final Collection collection; - final List include; - - @override - HttpResponse convert(ResponseFactory f) => - f.relatedCollection(request, collection, include: include); -} - -class ToOneResponse implements Response { - ToOneResponse(this.request, this.identifier); - - final Request request; - final Identifier identifier; - - @override - HttpResponse convert(ResponseFactory f) => - f.relationshipToOne(request, identifier); -} - -class ToManyResponse implements Response { - ToManyResponse(this.request, this.identifiers); - - final Request request; - final Iterable identifiers; - - @override - HttpResponse convert(ResponseFactory f) => - f.relationshipToMany(request, identifiers); -} diff --git a/lib/src/server/response_factory.dart b/lib/src/server/response_factory.dart deleted file mode 100644 index 26df6535..00000000 --- a/lib/src/server/response_factory.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/collection.dart'; -import 'package:json_api/src/server/request.dart'; - -class ResponseFactory { - ResponseFactory(this._uri); - - final UriFactory _uri; - - HttpResponse error(int status, - {Iterable errors, Map headers}) => - HttpResponse(status, - body: jsonEncode(Document.error(errors ?? [])), - headers: {...(headers ?? {}), 'Content-Type': Document.contentType}); - - HttpResponse noContent() => HttpResponse(204); - - HttpResponse accepted(Resource resource) => HttpResponse(202, - headers: { - 'Content-Type': Document.contentType, - 'Content-Location': _uri.resource(resource.type, resource.id).toString() - }, - body: jsonEncode(Document(ResourceData(_resource(resource), - links: {'self': Link(_uri.resource(resource.type, resource.id))})))); - - HttpResponse primaryResource( - Request request, Resource resource, - {Iterable include}) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document( - ResourceData(_resource(resource), - links: {'self': Link(_self(request))}), - included: - request.isCompound ? (include ?? []).map(_resource) : null))); - - HttpResponse createdResource( - Request request, Resource resource) => - HttpResponse(201, - headers: { - 'Content-Type': Document.contentType, - 'Location': _uri.resource(resource.type, resource.id).toString() - }, - body: jsonEncode(Document(ResourceData(_resource(resource), links: { - 'self': Link(_uri.resource(resource.type, resource.id)) - })))); - - HttpResponse primaryCollection( - Request request, Collection collection, - {Iterable include}) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document( - ResourceCollectionData( - collection.elements.map(_resource), - links: {'self': Link(_self(request))}, - ), - included: - request.isCompound ? (include ?? []).map(_resource) : null))); - - HttpResponse relatedCollection( - Request request, Collection collection, - {List include}) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document( - ResourceCollectionData(collection.elements.map(_resource), - links: { - 'self': Link(_self(request)), - 'related': Link(_uri.related(request.target.type, - request.target.id, request.target.relationship)) - }), - included: - request.isCompound ? (include ?? []).map(_resource) : null))); - - HttpResponse relatedResource( - Request request, Resource resource, - {Iterable include}) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document( - ResourceData(nullable(_resource)(resource), links: { - 'self': Link(_self(request)), - 'related': Link(_uri.related(request.target.type, - request.target.id, request.target.relationship)) - }), - included: - request.isCompound ? (include ?? []).map(_resource) : null))); - - HttpResponse relationshipToMany(Request request, - Iterable identifiers) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ToManyObject( - identifiers, - links: { - 'self': Link(_self(request)), - 'related': Link(_uri.related(request.target.type, - request.target.id, request.target.relationship)) - }, - )))); - - HttpResponse relationshipToOne( - Request request, Identifier identifier) => - HttpResponse(200, - headers: {'Content-Type': Document.contentType}, - body: jsonEncode(Document(ToOneObject( - identifier, - links: { - 'self': Link(_self(request)), - 'related': Link(_uri.related(request.target.type, - request.target.id, request.target.relationship)) - }, - )))); - - ResourceObject _resource(Resource resource) => - ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: { - ...resource.relationships.map((k, v) => MapEntry( - k, - v - ..links['self'] = - Link(_uri.relationship(resource.type, resource.id, k)) - ..links['related'] = - Link(_uri.related(resource.type, resource.id, k)))) - }, - links: { - 'self': Link(_uri.resource(resource.type, resource.id)) - }); - - Uri _self(Request r) => r.uri.queryParametersAll.isNotEmpty - ? r.target.link(_uri).replace(queryParameters: r.uri.queryParametersAll) - : r.target.link(_uri); -} diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart deleted file mode 100644 index 4cf058fe..00000000 --- a/lib/src/server/route.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/response.dart'; - -abstract class Route { - List get allowedMethods; - - Future dispatch(HttpRequest request, Controller controller); -} - -class UnmatchedRoute implements Route { - UnmatchedRoute({this.allowedMethods = const []}); - - @override - final allowedMethods; - - @override - Future dispatch(HttpRequest request, Controller controller) async => - ErrorResponse(404, [ - ErrorObject( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server', - ) - ]); -} - -class ErrorHandling implements Route { - ErrorHandling(this._route); - - final Route _route; - - @override - List get allowedMethods => _route.allowedMethods; - - @override - Future dispatch(HttpRequest request, Controller controller) async { - if (!_route.allowedMethods.contains(request.method)) { - return ExtraHeaders( - ErrorResponse(405, []), {'Allow': _route.allowedMethods.join(', ')}); - } - try { - return await _route.dispatch(request, controller); - } on FormatException catch (e) { - return ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: 'Invalid JSON. ${e.message}', - ) - ]); - } on DocumentException catch (e) { - return ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: e.message, - ) - ]); - } on IncompleteRelationshipException { - return ErrorResponse(400, [ - ErrorObject( - status: '400', - title: 'Bad Request', - detail: 'Incomplete relationship object', - ) - ]); - } - } -} - -class CorsEnabled implements Route { - CorsEnabled(this._route); - - final Route _route; - - @override - List get allowedMethods => _route.allowedMethods + ['OPTIONS']; - - @override - Future dispatch(HttpRequest request, Controller controller) async { - if (request.isOptions) { - return ExtraHeaders(NoContentResponse(), { - 'Access-Control-Allow-Methods': allowedMethods.join(', '), - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Max-Age': '3600', - }); - } - return ExtraHeaders(await _route.dispatch(request, controller), - {'Access-Control-Allow-Origin': '*'}); - } -} - -class CollectionRoute implements Route { - CollectionRoute(this._target); - - final CollectionTarget _target; - - @override - final allowedMethods = ['GET', 'POST']; - - @override - Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request.uri, _target); - if (request.isGet) { - return controller.fetchCollection(r); - } - if (request.isPost) { - return controller.createResource( - r, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - } - throw ArgumentError(); - } -} - -class ResourceRoute implements Route { - ResourceRoute(this._target); - - final ResourceTarget _target; - - @override - final allowedMethods = ['DELETE', 'GET', 'PATCH']; - - @override - Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request.uri, _target); - if (request.isDelete) { - return controller.deleteResource(r); - } - if (request.isGet) { - return controller.fetchResource(r); - } - if (request.isPatch) { - return controller.updateResource( - r, ResourceData.fromJson(jsonDecode(request.body)).unwrap()); - } - throw ArgumentError(); - } -} - -class RelatedRoute implements Route { - RelatedRoute(this._target); - - final RelatedTarget _target; - - @override - final allowedMethods = ['GET']; - - @override - Future dispatch(HttpRequest request, Controller controller) { - if (request.isGet) { - return controller.fetchRelated(Request(request.uri, _target)); - } - throw ArgumentError(); - } -} - -class RelationshipRoute implements Route { - RelationshipRoute(this._target); - - final RelationshipTarget _target; - - @override - final allowedMethods = ['DELETE', 'GET', 'PATCH', 'POST']; - - @override - Future dispatch(HttpRequest request, Controller controller) { - final r = Request(request.uri, _target); - if (request.isDelete) { - return controller.deleteFromRelationship( - r, ToManyObject.fromJson(jsonDecode(request.body)).linkage); - } - if (request.isGet) { - return controller.fetchRelationship(r); - } - if (request.isPatch) { - final rel = RelationshipObject.fromJson(jsonDecode(request.body)); - if (rel is ToOneObject) { - return controller.replaceToOne(r, rel.linkage); - } - if (rel is ToManyObject) { - return controller.replaceToMany(r, rel.linkage); - } - throw IncompleteRelationshipException(); - } - if (request.isPost) { - return controller.addToRelationship( - r, ToManyObject.fromJson(jsonDecode(request.body)).linkage); - } - throw ArgumentError(); - } -} - -/// Thrown if the relationship object has no data -class IncompleteRelationshipException implements Exception {} diff --git a/lib/src/server/route_matcher.dart b/lib/src/server/route_matcher.dart deleted file mode 100644 index ef991fe9..00000000 --- a/lib/src/server/route_matcher.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/route.dart'; - -class RouteMatcher implements UriMatchHandler { - Route _match; - - @override - void collection(String type) { - _match = CollectionRoute(CollectionTarget(type)); - } - - @override - void related(String type, String id, String relationship) { - _match = RelatedRoute(RelatedTarget(type, id, relationship)); - } - - @override - void relationship(String type, String id, String relationship) { - _match = RelationshipRoute(RelationshipTarget(type, id, relationship)); - } - - @override - void resource(String type, String id) { - _match = ResourceRoute(ResourceTarget(type, id)); - } - - Route getMatchedRouteOrElse(Route Function() orElse) => _match ?? orElse(); -} diff --git a/lib/src/client/status_code.dart b/lib/src/status_code.dart similarity index 100% rename from lib/src/client/status_code.dart rename to lib/src/status_code.dart diff --git a/pubspec.yaml b/pubspec.yaml index 7bce48ec..5640d4f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,15 @@ name: json_api version: 4.0.0 homepage: https://github.com/f3ath/json-api-dart -description: Framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) +description: Framework-agnostic implementations of JSON:API Client. Supports JSON:API v1.0 (http://jsonapi.org) environment: - sdk: '>=2.6.0 <3.0.0' + sdk: '>=2.8.0 <3.0.0' dependencies: - http: ^0.12.0 + json_api_common: ^0.0.2 + maybe_just_nothing: ^0.1.0 dev_dependencies: + json_api_server: + path: ../json-api-server html: ^0.14.0 pedantic: ^1.9.0 test: ^1.9.2 diff --git a/test/client_test.dart b/test/client_test.dart new file mode 100644 index 00000000..293cbbf7 --- /dev/null +++ b/test/client_test.dart @@ -0,0 +1,204 @@ +import 'package:json_api/json_api.dart'; +import 'package:json_api_common/http.dart'; +import 'package:json_api_common/url_design.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; +import 'package:test/test.dart'; + +import 'responses.dart' as mock; + +void main() { + final http = MockHandler(); + final client = JsonApiClient(http, UrlDesign()); + setUp(() { + http.request = null; + http.response = null; + }); + + group('Fetch Collection', () { + test('Sends correct request when given no arguments', () async { + http.response = mock.fetchCollection200; + final response = await client.fetchCollection('articles'); + expect(response.length, 1); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.fetchCollection200; + final response = await client.fetchCollection('articles', headers: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, sort: [ + 'title', + '-date' + ], page: { + 'limit': '10' + }, query: { + 'foo': 'bar' + }); + expect(response.length, 1); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), + '/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchCollection('articles'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Fetch Related Collection', () { + test('Sends correct request when given no arguments', () async { + http.response = mock.fetchCollection200; + final response = + await client.fetchRelatedCollection('people', '1', 'articles'); + expect(response.length, 1); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), '/people/1/articles'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.fetchCollection200; + final response = await client + .fetchRelatedCollection('people', '1', 'articles', headers: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, sort: [ + 'title', + '-date' + ], page: { + 'limit': '10' + }, query: { + 'foo': 'bar' + }); + expect(response.length, 1); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), + '/people/1/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchRelatedCollection('people', '1', 'articles'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Fetch Primary Resource', () { + test('Sends correct request when given no arguments', () async { + http.response = mock.fetchResource200; + final response = await client.fetchResource('articles', '1'); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.fetchResource200; + final response = await client.fetchResource('articles', '1', headers: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, query: { + 'foo': 'bar' + }); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), + '/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchResource('articles', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Fetch Related Resource', () { + test('Sends correct request when given no arguments', () async { + http.response = mock.fetchRelatedResourceNull200; + final response = + await client.fetchRelatedResource('articles', '1', 'author'); + expect(response.resource, isA>()); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.fetchResource200; + final response = await client.fetchResource('articles', '1', headers: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, query: { + 'foo': 'bar' + }); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'GET'); + expect(http.request.uri.toString(), + '/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchResource('articles', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); +} + +class MockHandler implements HttpHandler { + HttpResponse response; + HttpRequest request; + + @override + Future call(HttpRequest request) async { + this.request = request; + return response; + } +} diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart deleted file mode 100644 index ef65ca16..00000000 --- a/test/functional/compound_document_test.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart' as d; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -void main() async { - JsonApiClient client; - JsonApiServer server; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - final wonderland = - d.Resource('countries', '1', attributes: {'name': 'Wonderland'}); - final alice = d.Resource('people', '1', - attributes: {'name': 'Alice'}, - toOne: {'birthplace': d.Identifier(wonderland.type, wonderland.id)}); - final bob = d.Resource('people', '2', - attributes: {'name': 'Bob'}, - toOne: {'birthplace': d.Identifier(wonderland.type, wonderland.id)}); - final comment1 = d.Resource('comments', '1', - attributes: {'text': 'First comment!'}, - toOne: {'author': d.Identifier(bob.type, bob.id)}); - final comment2 = d.Resource('comments', '2', - attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': d.Identifier(alice.type, alice.id)}); - final post = d.Resource('posts', '1', attributes: { - 'title': 'Hello World' - }, toOne: { - 'author': d.Identifier(alice.type, alice.id) - }, toMany: { - 'comments': [ - d.Identifier(comment1.type, comment1.id), - d.Identifier(comment2.type, comment2.id) - ], - 'tags': [] - }); - - setUp(() async { - final repository = InMemoryRepository({ - 'posts': {'1': post}, - 'comments': {'1': comment1, '2': comment2}, - 'people': {'1': alice, '2': bob}, - 'countries': {'1': wonderland}, - 'tags': {} - }); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, routing); - }); - - group('Single Resources', () { - test('included == [] when requested but nothing to include', () async { - final r = await client.fetchResource('posts', '1', include: ['tags']); - expect(r.resource.key, 'posts:1'); - expect(r.included, []); - expect(r.links['self'].toString(), '/posts/1?include=tags'); - }); - - test('can include first-level relatives', () async { - final r = await client.fetchResource('posts', '1', include: ['comments']); - expect(r.resource.key, 'posts:1'); - expect(r.included.length, 2); - expect(r.included.first.key, 'comments:1'); - expect(r.included.last.key, 'comments:2'); - }); - - test('can include second-level relatives', () async { - final r = await client - .fetchResource('posts', '1', include: ['comments.author']); - expect(r.resource.key, 'posts:1'); - expect(r.included.length, 2); - expect(r.included.first.attributes['name'], 'Bob'); - expect(r.included.last.attributes['name'], 'Alice'); - }); - - test('can include third-level relatives', () async { - final r = await client - .fetchResource('posts', '1', include: ['comments.author.birthplace']); - expect(r.resource.key, 'posts:1'); - expect(r.included.length, 1); - expect(r.included.first.attributes['name'], 'Wonderland'); - }); - - test('can include first- and second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - include: ['comments', 'comments.author']); - expect(r.resource.key, 'posts:1'); - expect(r.included.length, 4); - expect(r.included.toList()[0].key, 'comments:1'); - expect(r.included.toList()[1].key, 'comments:2'); - expect(r.included.toList()[2].attributes['name'], 'Bob'); - expect(r.included.toList()[3].attributes['name'], 'Alice'); - }); - }); - - group('Resource Collection', () { - test('not compound by default', () async { - final r = await client.fetchCollection('posts'); - expect(r.first.key, 'posts:1'); - expect(r.included.isEmpty, true); - }); - - test('document is compound when requested but nothing to include', - () async { - final r = await client.fetchCollection('posts', include: ['tags']); - expect(r.first.key, 'posts:1'); - expect(r.included.isEmpty, true); - }); - - test('can include first-level relatives', () async { - final r = await client.fetchCollection('posts', include: ['comments']); - expect(r.first.type, 'posts'); - expect(r.included.length, 2); - expect(r.included.first.key, 'comments:1'); - expect(r.included.last.key, 'comments:2'); - }); - - test('can include second-level relatives', () async { - final r = - await client.fetchCollection('posts', include: ['comments.author']); - expect(r.first.type, 'posts'); - expect(r.included.length, 2); - expect(r.included.first.attributes['name'], 'Bob'); - expect(r.included.last.attributes['name'], 'Alice'); - }); - - test('can include third-level relatives', () async { - final r = await client - .fetchCollection('posts', include: ['comments.author.birthplace']); - expect(r.first.key, 'posts:1'); - expect(r.included.length, 1); - expect(r.included.first.attributes['name'], 'Wonderland'); - }); - - test('can include first- and second-level relatives', () async { - final r = await client - .fetchCollection('posts', include: ['comments', 'comments.author']); - expect(r.first.key, 'posts:1'); - expect(r.included.length, 4); - expect(r.included.toList()[0].key, 'comments:1'); - expect(r.included.toList()[1].key, 'comments:2'); - expect(r.included.toList()[2].attributes['name'], 'Bob'); - expect(r.included.toList()[3].attributes['name'], 'Alice'); - }); - }); -} diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart deleted file mode 100644 index 5e0f1b99..00000000 --- a/test/functional/crud/creating_resources_test.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -void main() async { - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final urls = StandardRouting(base); - - group('Server-generated ID', () { - test('201 Created', () async { - final repository = InMemoryRepository({ - 'people': {}, - }, nextId: Uuid().v4); - final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server, urls); - - final r = await client - .createNewResource('people', attributes: {'name': 'Martin Fowler'}); - expect(r.http.statusCode, 201); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.http.headers['location'], isNotNull); - expect(r.http.headers['location'], r.links['self'].uri.toString()); - final created = r.resource; - expect(created.type, 'people'); - expect(created.id, isNotNull); - expect(created.attributes, equals({'name': 'Martin Fowler'})); - final r1 = await client.fetchResource(created.type, created.id); - expect(r1.resource.type, 'people'); - expect(r1.resource.id, isNotNull); - expect(r1.resource.attributes, equals({'name': 'Martin Fowler'})); - }); - - test('403 when the id can not be generated', () async { - final repository = InMemoryRepository({'people': {}}); - final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server, urls); - - try { - await client.createNewResource('people'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 403); - expect(e.errors.first.status, '403'); - expect(e.errors.first.title, 'Unsupported operation'); - expect(e.errors.first.detail, 'Id generation is not supported'); - } - }); - }); - - group('Client-generated ID', () { - JsonApiClient client; - setUp(() async { - final repository = InMemoryRepository({ - 'books': {}, - 'people': {}, - 'companies': {}, - 'noServerId': {}, - 'fruits': {}, - 'apples': {} - }); - final server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, urls); - }); - - test('204 No Content', () async { - final r = await client.createResource('people', '123', - attributes: {'name': 'Martin Fowler'}); - expect(r.http.statusCode, 204); - expect(r.http.headers['location'], isNull); - final r1 = await client.fetchResource('people', '123'); - expect(r1.http.statusCode, 200); - expect(r1.resource.attributes['name'], 'Martin Fowler'); - }); - - test('404 when the collection does not exist', () async { - try { - await client.createNewResource('unicorns'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 when the related resource does not exist (to-one)', () async { - try { - await client - .createNewResource('books', one: {'publisher': 'companies:123'}); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect(e.errors.first.detail, - "Resource '123' does not exist in 'companies'"); - } - }); - - test('404 when the related resource does not exist (to-many)', () async { - try { - await client.createNewResource('books', many: { - 'authors': ['people:123'] - }); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '123' does not exist in 'people'"); - } - }); -// -// test('409 when the resource type does not match collection', () async { -// final r = await client.send( -// Request.createResource( -// Document(ResourceData.fromResource(Resource('cucumbers', null)))), -// urls.collection('fruits')); -// expect(r.isSuccessful, isFalse); -// expect(r.isFailed, isTrue); -// expect(r.http.statusCode, 409); -// expect(r.http.headers['content-type'], Document.contentType); -// expect(r.decodeDocument().data, isNull); -// final error = r.decodeDocument().errors.first; -// expect(error.status, '409'); -// expect(error.title, 'Invalid resource type'); -// expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); -// }); - - test('409 when the resource with this id already exists', () async { - await client.createResource('apples', '123'); - try { - await client.createResource('apples', '123'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 409); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '409'); - expect(e.errors.first.title, 'Resource exists'); - expect(e.errors.first.detail, - 'Resource with this type and id already exists'); - } - }); - }); -} diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart deleted file mode 100644 index 64933c25..00000000 --- a/test/functional/crud/deleting_resources_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, routing); - - await seedResources(client); - }); - - test('successful', () async { - final r = await client.deleteResource('books', '1'); - expect(r.http.statusCode, 204); - try { - await client.fetchResource('books', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - } - }); - - test('404 on collection', () async { - try { - await client.deleteResource('unicorns', '42'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.deleteResource('books', '42'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect(e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); -} diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart deleted file mode 100644 index eba8d014..00000000 --- a/test/functional/crud/fetching_relationships_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, routing); - - await seedResources(client); - }); - - group('Generic', () { - test('200 OK to-one', () async { - final r = await client.fetchRelationship('books', '1', 'publisher'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - final rel = r.relationship; - expect(rel.isSingular, true); - expect(rel.isPlural, false); - expect(rel.first.type, 'companies'); - expect(rel.first.id, '1'); - }); - - test('200 OK to-many', () async { - final r = await client.fetchRelationship('books', '1', 'authors'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - final rel = r.relationship; - expect(rel.isSingular, false); - expect(rel.isPlural, true); - expect(rel.length, 2); - expect(rel.first.id, '1'); - expect(rel.first.type, 'people'); - expect(rel.last.id, '2'); - expect(rel.last.type, 'people'); - }); - - test('404 on collection', () async { - try { - await client.fetchRelationship('unicorns', '1', 'corns'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchRelationship('books', '42', 'authors'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.fetchRelationship('books', '1', 'readers'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "Relationship 'readers' does not exist in this resource"); - } - }); - }); -} diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart deleted file mode 100644 index ded0cf78..00000000 --- a/test/functional/crud/fetching_resources_test.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, routing); - - await seedResources(client); - }); - - group('Primary Resource', () { - test('200 OK', () async { - final r = await client.fetchResource('books', '1'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.resource.id, '1'); - expect(r.resource.attributes['title'], 'Refactoring'); - expect(r.links['self'].toString(), '/books/1'); - expect(r.links['self'].toString(), '/books/1'); - final authors = r.resource.relationships['authors']; - expect( - authors.links['self'].toString(), '/books/1/relationships/authors'); - expect(authors.links['related'].toString(), '/books/1/authors'); - final publisher = r.resource.relationships['publisher']; - expect(publisher.links['self'].toString(), - '/books/1/relationships/publisher'); - expect(publisher.links['related'].toString(), '/books/1/publisher'); - }); - - test('404 on collection', () async { - try { - await client.fetchResource('unicorns', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchResource('people', '42'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'people'"); - } - }); - }); - - group('Primary collections', () { - test('200 OK', () async { - final r = await client.fetchCollection('people'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.links['self'].uri.toString(), '/people'); - expect(r.length, 3); - expect(r.first.links['self'].toString(), '/people/1'); - expect(r.last.links['self'].toString(), '/people/3'); - expect(r.first.attributes['name'], 'Martin Fowler'); - expect(r.last.attributes['name'], 'Robert Martin'); - }); - - test('404 on collection', () async { - try { - await client.fetchCollection('unicorns'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - }); - - group('Related Resource', () { - test('200 OK', () async { - final r = await client.fetchRelatedResource('books', '1', 'publisher'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.resource().type, 'companies'); - expect(r.resource().id, '1'); - expect(r.links['self'].toString(), '/books/1/publisher'); - expect(r.resource().links['self'].toString(), '/companies/1'); - }); - - test('200 OK with empty resource', () async { - final r = await client.fetchRelatedResource('books', '1', 'reviewer'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(() => r.resource(), throwsStateError); - expect(r.resource(orElse: () => null), isNull); - expect(r.links['self'].toString(), '/books/1/reviewer'); - }); - - test('404 on collection', () async { - try { - await client.fetchRelatedResource('unicorns', '1', 'publisher'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchRelatedResource('books', '42', 'publisher'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.fetchRelatedResource('books', '1', 'owner'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "Relationship 'owner' does not exist in this resource"); - } - }); - }); - - group('Related Collection', () { - test('successful', () async { - final r = await client.fetchRelatedCollection('books', '1', 'authors'); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.links['self'].uri.toString(), '/books/1/authors'); - expect(r.length, 2); - expect(r.first.links['self'].toString(), '/people/1'); - expect(r.last.links['self'].toString(), '/people/2'); - expect(r.first.attributes['name'], 'Martin Fowler'); - expect(r.last.attributes['name'], 'Kent Beck'); - }); - - test('404 on collection', () async { - try { - await client.fetchRelatedCollection('unicorns', '1', 'corns'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.fetchRelatedCollection('books', '42', 'authors'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.fetchRelatedCollection('books', '1', 'owner'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "Relationship 'owner' does not exist in this resource"); - } - }); - }); -} diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart deleted file mode 100644 index a9ab59ca..00000000 --- a/test/functional/crud/seed_resources.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/client.dart'; - -Future seedResources(JsonApiClient client) async { - await client - .createResource('people', '1', attributes: {'name': 'Martin Fowler'}); - await client.createResource('people', '2', attributes: {'name': 'Kent Beck'}); - await client - .createResource('people', '3', attributes: {'name': 'Robert Martin'}); - await client.createResource('companies', '1', - attributes: {'name': 'Addison-Wesley Professional'}); - await client - .createResource('companies', '2', attributes: {'name': 'Prentice Hall'}); - await client.createResource('books', '1', attributes: { - 'title': 'Refactoring', - 'ISBN-10': '0134757599' - }, one: { - 'publisher': 'companies:1', - 'reviewer': null, - }, many: { - 'authors': ['people:1', 'people:2'] - }); -} diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart deleted file mode 100644 index 06e93ab5..00000000 --- a/test/functional/crud/updating_relationships_test.dart +++ /dev/null @@ -1,268 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, routing); - - await seedResources(client); - }); - - group('Updating a to-one relationship', () { - test('204 No Content', () async { - final r = await client.replaceOne( - 'books', '1', 'publisher', Identifier('companies', '2')); - expect(r.http.statusCode, 204); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.one('publisher').identifier().id, '2'); - }); - - test('404 on collection', () async { - try { - await client.replaceOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.replaceOne( - 'books', '42', 'publisher', Identifier('companies', '2')); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - }); - - group('Deleting a to-one relationship', () { - test('204 No Content', () async { - final r = await client.deleteOne('books', '1', 'publisher'); - expect(r.http.statusCode, 204); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.one('publisher').isEmpty, true); - }); - - test('404 on collection', () async { - try { - await client.deleteOne('unicorns', '1', 'breed'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.deleteOne('books', '42', 'publisher'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - }); - - group('Replacing a to-many relationship', () { - test('204 No Content', () async { - final r = await client - .replaceMany('books', '1', 'authors', [Identifier('people', '1')]); - expect(r.http.statusCode, 204); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.many('authors').length, 1); - expect(r1.resource.many('authors').first.id, '1'); - }); - - test('404 on collection', () async { - try { - await client.replaceMany('unicorns', '1', 'breed', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.replaceMany('books', '42', 'publisher', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - }); - - group('Adding to a to-many relationship', () { - test('successfully adding a new identifier', () async { - final r = - await client.addMany('books', '1', 'authors', [Identifier('people', '3')]); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.relationship().length, 3); - expect(r.relationship().first.id, '1'); - expect(r.relationship().last.id, '3'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.many('authors').length, 3); - }); - - test('successfully adding an existing identifier', () async { - final r = - await client.addMany('books', '1', 'authors', [Identifier('people', '2')]); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.relationship().length, 2); - expect(r.relationship().first.id, '1'); - expect(r.relationship().last.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.many('authors').length, 2); - expect(r1.http.headers['content-type'], ContentType.jsonApi); - }); - - test('404 on collection', () async { - try { - await client.addMany('unicorns', '1', 'breed', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.addMany('books', '42', 'publisher', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.addMany('books', '1', 'sellers', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "There is no to-many relationship 'sellers' in this resource"); - } - }); - }); - - group('Deleting from a to-many relationship', () { - test('successfully deleting an identifier', () async { - final r = await client - .deleteMany('books', '1', 'authors', [Identifier('people', '1')]); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.relationship().length, 1); - expect(r.relationship().first.id, '2'); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.many('authors').length, 1); - }); - - test('404 on collection', () async { - try { - await client.deleteMany('unicorns', '1', 'breed', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Collection not found'); - expect(e.errors.first.detail, "Collection 'unicorns' does not exist"); - } - }); - - test('404 on resource', () async { - try { - await client.deleteMany('books', '42', 'publisher', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect( - e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); - - test('404 on relationship', () async { - try { - await client.deleteMany('books', '1', 'sellers', []); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Relationship not found'); - expect(e.errors.first.detail, - "There is no to-many relationship 'sellers' in this resource"); - } - }); - }); -} diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart deleted file mode 100644 index 185ddfbe..00000000 --- a/test/functional/crud/updating_resources_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final urls = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server, urls); - - await seedResources(client); - }); - - test('200 OK', () async { - final r = await client.updateResource('books', '1', attributes: { - 'title': 'Refactoring. Improving the Design of Existing Code', - 'pages': 448 - }, one: { - 'publisher': null, - }, many: { - 'authors': ['people:1'], - 'reviewers': ['people:2'] - }); - expect(r.http.statusCode, 200); - expect(r.http.headers['content-type'], ContentType.jsonApi); - expect(r.resource().attributes['title'], - 'Refactoring. Improving the Design of Existing Code'); - expect(r.resource().attributes['pages'], 448); - expect(r.resource().attributes['ISBN-10'], '0134757599'); - expect(r.resource().one('publisher').isEmpty, true); - expect(r.resource().many('authors').toList().first.key, equals('people:1')); - expect( - r.resource().many('reviewers').toList().first.key, equals('people:2')); - - final r1 = await client.fetchResource('books', '1'); - expect(r1.resource.attributes, r.resource().attributes); - }); - - test('204 No Content', () async { - final r = await client.updateResource('books', '1'); - expect(r.http.statusCode, 204); - }); - - test('404 on the target resource', () async { - try { - await client.updateResource('books', '42'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - expect(e.http.headers['content-type'], ContentType.jsonApi); - expect(e.errors.first.status, '404'); - expect(e.errors.first.title, 'Resource not found'); - expect(e.errors.first.detail, "Resource '42' does not exist in 'books'"); - } - }); -// -// test('409 when the resource type does not match the collection', () async { -// final r = await client.send( -// Request.updateResource( -// Document(ResourceData.fromResource(Resource('books', '1')))), -// urls.resource('people', '1')); -// expect(r.isSuccessful, isFalse); -// expect(r.http.statusCode, 409); -// expect(r.http.headers['content-type'], ContentType.jsonApi); -// expect(r.decodeDocument().data, isNull); -// final error = r.decodeDocument().errors.first; -// expect(error.status, '409'); -// expect(error.title, 'Invalid resource type'); -// expect(error.detail, "Type 'books' does not belong in 'people'"); -// }); -} diff --git a/test/helper/expect_same_json.dart b/test/helper/expect_same_json.dart deleted file mode 100644 index 3efa266f..00000000 --- a/test/helper/expect_same_json.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'dart:convert'; - -import 'package:test/test.dart'; - -void expectSameJson(Object actual, Object expected) => - expect(jsonDecode(jsonEncode(actual)), jsonDecode(jsonEncode(expected))); diff --git a/test/helper/test_http_handler.dart b/test/helper/test_http_handler.dart deleted file mode 100644 index ef437885..00000000 --- a/test/helper/test_http_handler.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/http.dart'; - -class TestHttpHandler implements HttpHandler { - final requestLog = []; - HttpResponse response; - - @override - Future call(HttpRequest request) async { - requestLog.add(request); - return response; - } -} diff --git a/test/performance/encode_decode.dart b/test/performance/encode_decode.dart deleted file mode 100644 index b45ecde4..00000000 --- a/test/performance/encode_decode.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; - -void main() { - final meta = { - 'bool': true, - 'array': [1, 2, 3], - 'string': 'foo' - }; - final json = { - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page=2', - 'last': 'http://example.com/articles?page=10' - }, - 'meta': meta, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'meta': meta, - 'relationships': { - 'author': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/author', - 'related': 'http://example.com/articles/1/author' - }, - 'data': {'type': 'people', 'id': '9'} - }, - 'comments': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/comments', - 'related': 'http://example.com/articles/1/comments' - }, - 'data': [ - { - 'type': 'comments', - 'id': '5', - 'meta': meta, - }, - {'type': 'comments', 'id': '12'} - ] - } - }, - 'links': {'self': 'http://example.com/articles/1'} - } - ], - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' - }, - 'links': {'self': 'http://example.com/people/9'} - }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } - }, - 'links': {'self': 'http://example.com/comments/5'} - }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ], - 'jsonapi': {'version': '1.0', 'meta': meta} - }; - - Object j; - - final count = 100000; - final start = DateTime.now().millisecondsSinceEpoch; - for (var i = 0; i < count; i++) { - j = Document.fromJson(json, ResourceCollectionData.fromJson).toJson(); - } - assert(jsonEncode(j) == jsonEncode(json)); - final stop = DateTime.now().millisecondsSinceEpoch; - print('$count iterations took ${stop - start} ms'); -} diff --git a/test/responses.dart b/test/responses.dart new file mode 100644 index 00000000..ad4692bc --- /dev/null +++ b/test/responses.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:json_api/json_api.dart'; +import 'package:json_api_common/http.dart'; + +final fetchCollection200 = HttpResponse(200, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10' + }, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })); + +final fetchResource200 = HttpResponse(200, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1'}, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'} + } + } + } + })); +final fetchRelatedResourceNull200 = HttpResponse(200, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1/author'}, + 'data': null + })); +final error422 = HttpResponse(422, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'errors': [ + { + 'status': '422', + 'source': {'pointer': '/data/attributes/firstName'}, + 'title': 'Invalid Attribute', + 'detail': 'First name must contain at least three characters.' + } + ] + })); diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart deleted file mode 100644 index a53de6c9..00000000 --- a/test/unit/document/api_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('Api can be json-encoded', () { - final api = - Api.fromJson(json.decode(json.encode(Api()..meta['foo'] = 'bar'))); - expect('1.0', api.version); - expect('bar', api.meta['foo']); - }); - - test('Throws exception when can not be decoded', () { - expect(() => Api.fromJson([]), throwsA(TypeMatcher())); - }); - - test('Empty/null properties are not encoded', () { - expect(Api(), encodesToJson({})); - }); -} diff --git a/test/unit/document/document_test.dart b/test/unit/document/document_test.dart deleted file mode 100644 index 5dc2b45f..00000000 --- a/test/unit/document/document_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('Unrecognized structure', () { - expect(() => Document.fromJson({}, ResourceData.fromJson), - throwsA(TypeMatcher())); - }); -} diff --git a/test/unit/document/identifier_object_test.dart b/test/unit/document/identifier_object_test.dart deleted file mode 100644 index bf49737a..00000000 --- a/test/unit/document/identifier_object_test.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:test/test.dart'; - -void main() { - test('throws DocumentException when can not be decoded', () { - expect(() => Identifier.fromJson([]), - throwsA(TypeMatcher())); - }); -} diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart deleted file mode 100644 index adec37bb..00000000 --- a/test/unit/document/json_api_error_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('links', () { - test('recognizes custom links', () { - final e = ErrorObject( - links: {'my-link': Link(Uri.parse('http://example.com'))}); - expect(e.links['my-link'].toString(), 'http://example.com'); - }); - - test('"links" may contain the "about" key', () { - final e = ErrorObject(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.links['about'].toString(), '/about'); - }); - - test('custom "links" survives json serialization', () { - final e = ErrorObject( - links: {'my-link': Link(Uri.parse('http://example.com'))}); - expect( - ErrorObject.fromJson(json.decode(json.encode(e))) - .links['my-link'] - .toString(), - 'http://example.com'); - }); - }); -} diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart deleted file mode 100644 index bbd7bc29..00000000 --- a/test/unit/document/link_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:test/test.dart'; - -void main() { - test('link can encoded and decoded', () { - final link = Link(Uri.parse('http://example.com')); - expect(Link.fromJson(json.decode(json.encode(link))).uri.toString(), - 'http://example.com'); - }); - - test('link object can be parsed from JSON', () { - final link = Link(Uri.parse('http://example.com'), meta: {'foo': 'bar'}); - - final parsed = Link.fromJson(json.decode(json.encode(link))); - expect(parsed.uri.toString(), 'http://example.com'); - expect(parsed.meta['foo'], 'bar'); - }); - - test('a map of link object can be parsed from JSON', () { - final links = Link.mapFromJson({ - 'first': 'http://example.com/first', - 'last': 'http://example.com/last' - }); - expect(links['first'].uri.toString(), 'http://example.com/first'); - expect(links['last'].uri.toString(), 'http://example.com/last'); - }); - - test('link throws DocumentException on invalid JSON', () { - expect(() => Link.fromJson([]), throwsA(TypeMatcher())); - expect( - () => Link.mapFromJson([]), throwsA(TypeMatcher())); - }); -} diff --git a/test/unit/document/meta_members_test.dart b/test/unit/document/meta_members_test.dart deleted file mode 100644 index 6a595ef8..00000000 --- a/test/unit/document/meta_members_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('Meta members', () { - test('should be parsed correctly', () { - final meta = { - 'bool': true, - 'array': [1, 2, 3], - 'string': 'foo' - }; - final json = { - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page=2', - 'last': 'http://example.com/articles?page=10' - }, - 'meta': meta, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'meta': meta, - 'relationships': { - 'author': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/author', - 'related': 'http://example.com/articles/1/author' - }, - 'data': {'type': 'people', 'id': '9'} - }, - 'comments': { - 'links': { - 'self': - 'http://example.com/articles/1/relationships/comments', - 'related': 'http://example.com/articles/1/comments' - }, - 'data': [ - { - 'type': 'comments', - 'id': '5', - 'meta': meta, - }, - {'type': 'comments', 'id': '12'} - ] - } - }, - 'links': {'self': 'http://example.com/articles/1'} - } - ], - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' - }, - 'links': {'self': 'http://example.com/people/9'} - }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } - }, - 'links': {'self': 'http://example.com/comments/5'} - }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - }; - - final doc = Document.fromJson(json, ResourceCollectionData.fromJson); - expect(doc.meta['bool'], true); - expect(doc.data.collection.first.meta, meta); - expect( - (doc.data.collection.first.relationships['comments'] as ToManyObject) - .linkage - .first - .meta, - meta); - }); - }); -} diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart deleted file mode 100644 index 11dd1d1c..00000000 --- a/test/unit/document/relationship_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 = RelationshipObject(links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "related" and "self" keys', () { - final r = RelationshipObject(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.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - }); - - test('custom "links" survives json serialization', () { - final r = RelationshipObject(links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - RelationshipObject.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 deleted file mode 100644 index b90b22e9..00000000 --- a/test/unit/document/resource_collection_data_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('unwrapToMap() returns a map by id', () { - final fruits = ResourceCollectionData( - [ResourceObject('apples', '1'), ResourceObject('pears', '2')]) - .unwrapToMap(); - expect(fruits['1'].type, 'apples'); - expect(fruits['2'].type, 'pears'); - expect(fruits.length, 2); - }); - 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('"links" may contain the "self" key', () { - final r = ResourceCollectionData([], 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.links['self'].toString(), '/self'); - }); - - 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 deleted file mode 100644 index b8f82c0c..00000000 --- a/test/unit/document/resource_data_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('Can decode a primary resource with missing id', () { - 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.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': null} - }))); - expect(data.unwrap().type, 'apples'); - expect(data.unwrap().id, isNull); - }); - - test('Can decode a related resource which is null', () { - final data = - ResourceData.fromJson(json.decode(json.encode({'data': null}))); - expect(data.unwrap(), null); - }); - - 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('"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.links['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 deleted file mode 100644 index 0608af68..00000000 --- a/test/unit/document/resource_object_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - group('ResourceObject', () { - /// id:null should not be included in JSON - /// https://jsonapi.org/format/#crud-creating - test('id:null should not be included in JSON', () { - final res = ResourceObject('photos', null, attributes: { - 'title': 'Ember Hamster', - 'src': 'http://example.com/images/productivity.png' - }, relationships: { - 'photographer': ToOneObject(Identifier('people', '9')) - }); - - expect( - res, - encodesToJson({ - 'type': 'photos', - 'attributes': { - 'title': 'Ember Hamster', - 'src': 'http://example.com/images/productivity.png' - }, - 'relationships': { - 'photographer': { - 'data': {'type': 'people', 'id': '9'} - } - } - })); - }); - }); - - 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('"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('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/resource_test.dart b/test/unit/document/resource_test.dart deleted file mode 100644 index 73f657aa..00000000 --- a/test/unit/document/resource_test.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('Removes duplicate identifiers in toMany relationships', () { - final r = Resource('type', 'id', toMany: { - 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] - }); - expect(r.many('rel').length, 1); - }); - - test('toString', () { - expect(Resource('apples', '42').toString(), 'Resource(apples:42)'); - }); -} diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart deleted file mode 100644 index 8861b982..00000000 --- a/test/unit/document/to_many_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 = ToManyObject([], links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "related" and "self" keys', () { - final r = ToManyObject([], 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.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - }); - - test('custom "links" survives json serialization', () { - final r = ToManyObject([], links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ToManyObject.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 deleted file mode 100644 index f9af2c98..00000000 --- a/test/unit/document/to_one_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 = ToOneObject(null, links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "related" and "self" keys', () { - final r = ToOneObject(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.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - }); - - test('custom "links" survives json serialization', () { - final r = ToOneObject(null, links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ToOneObject.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/http/logging_http_handler_test.dart b/test/unit/http/logging_http_handler_test.dart deleted file mode 100644 index bdaeb2ee..00000000 --- a/test/unit/http/logging_http_handler_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - test('Logging handler can log', () async { - final rq = HttpRequest('get', Uri.parse('http://localhost')); - final rs = HttpResponse(200, body: 'Hello'); - HttpRequest loggedRq; - HttpResponse loggedRs; - final logger = LoggingHttpHandler( - HttpHandler.fromFunction(((_) async => rs)), - onResponse: (_) => loggedRs = _, - onRequest: (_) => loggedRq = _); - await logger(rq); - expect(loggedRq, same(rq)); - expect(loggedRs, same(rs)); - }); -} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart deleted file mode 100644 index a5dfb041..00000000 --- a/test/unit/query/fields_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_api/src/query/fields.dart'; -import 'package:test/test.dart'; - -void main() { - test('emptiness', () { - expect(Fields({}).isEmpty, isTrue); - expect(Fields({}).isNotEmpty, isFalse); - - expect( - Fields({ - 'foo': ['bar'] - }).isEmpty, - isFalse); - expect( - Fields({ - 'foo': ['bar'] - }).isNotEmpty, - isTrue); - }); - test('Can decode url without duplicate keys', () { - final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); - final fields = Fields.fromUri(uri); - expect(fields['articles'], ['title', 'body']); - expect(fields['people'], ['name']); - }); - - test('Can decode url with duplicate keys', () { - final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); - final fields = Fields.fromUri(uri); - expect(fields['articles'], ['title', 'body']); - expect(fields['people'], ['name', 'age']); - }); - - test('Can add to uri', () { - final fields = Fields({ - 'articles': ['title', 'body'], - 'people': ['name'] - }); - final uri = Uri.parse('/articles'); - - 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 deleted file mode 100644 index bac10ffa..00000000 --- a/test/unit/query/include_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:json_api/src/query/include.dart'; -import 'package:test/test.dart'; - -void main() { - test('emptiness', () { - expect(Include([]).isEmpty, isTrue); - expect(Include([]).isNotEmpty, isFalse); - expect(Include(['foo']).isEmpty, isFalse); - expect(Include(['foo']).isNotEmpty, isTrue); - }); - - test('Can decode url without duplicate keys', () { - final uri = Uri.parse('/articles/1?include=author,comments.author'); - final include = Include.fromUri(uri); - expect(include, equals(['author', 'comments.author'])); - }); - - test('Can decode url with duplicate keys', () { - final uri = - Uri.parse('/articles/1?include=author,comments.author&include=tags'); - final include = Include.fromUri(uri); - expect(include, equals(['author', 'comments.author', 'tags'])); - }); - - test('Can add to uri', () { - final uri = Uri.parse('/articles/1'); - final include = Include(['author', 'comments.author']); - expect(include.addToUri(uri).toString(), - '/articles/1?include=author%2Ccomments.author'); - }); -} diff --git a/test/unit/query/merge_test.dart b/test/unit/query/merge_test.dart deleted file mode 100644 index d62110da..00000000 --- a/test/unit/query/merge_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:test/test.dart'; - -void main() { - test('parameters can be merged', () { - final params = Fields({ - 'comments': ['author'] - }) & - Include(['author']) & - Page({'limit': '10'}); - expect(params.addToUri(Uri()).query, - 'fields%5Bcomments%5D=author&include=author&page%5Blimit%5D=10'); - }); -} diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart deleted file mode 100644 index d84c80bb..00000000 --- a/test/unit/query/page_test.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:test/test.dart'; - -void main() { - test('emptiness', () { - expect(Page({}).isEmpty, isTrue); - expect(Page({}).isNotEmpty, isFalse); - expect(Page({'foo': 'bar'}).isEmpty, isFalse); - expect(Page({'foo': 'bar'}).isNotEmpty, isTrue); - }); - - test('Can decode url', () { - final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); - final page = Page.fromUri(uri); - expect(page['limit'], '10'); - expect(page['offset'], '20'); - }); - - test('Can add to uri', () { - final fields = Page({'limit': '10', 'offset': '20'}); - final uri = Uri.parse('/articles'); - - expect(fields.addToUri(uri).toString(), - '/articles?page%5Blimit%5D=10&page%5Boffset%5D=20'); - }); -} diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart deleted file mode 100644 index 8075ff91..00000000 --- a/test/unit/query/sort_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_api/src/query/sort.dart'; -import 'package:test/test.dart'; - -void main() { - test('emptiness', () { - expect(Sort([]).isEmpty, isTrue); - expect(Sort([]).isNotEmpty, isFalse); - expect(Sort([Desc('created')]).isEmpty, isFalse); - expect(Sort([Desc('created')]).isNotEmpty, isTrue); - }); - - test('Can decode url wthout duplicate keys', () { - final uri = Uri.parse('/articles?sort=-created,title'); - final sort = Sort.fromUri(uri); - expect(sort.length, 2); - expect(sort.first.isDesc, true); - expect(sort.first.name, 'created'); - expect(sort.last.isAsc, true); - expect(sort.last.name, 'title'); - }); - - test('Can decode url with duplicate keys', () { - final uri = Uri.parse('/articles?sort=-created&sort=title'); - final sort = Sort.fromUri(uri); - expect(sort.length, 2); - expect(sort.first.isDesc, true); - expect(sort.first.name, 'created'); - expect(sort.last.isAsc, true); - expect(sort.last.name, 'title'); - }); - - test('Can add to uri', () { - final sort = Sort([Desc('created'), Asc('title')]); - final uri = Uri.parse('/articles'); - expect(sort.addToUri(uri).toString(), '/articles?sort=-created%2Ctitle'); - }); -} diff --git a/test/unit/routing/standard_routing_test.dart b/test/unit/routing/standard_routing_test.dart deleted file mode 100644 index a7f278f8..00000000 --- a/test/unit/routing/standard_routing_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_api/routing.dart'; -import 'package:test/test.dart'; - -void main() { - test('URIs start with slashes when no base provided', () { - final r = StandardRouting(); - expect(r.collection('books').toString(), '/books'); - expect(r.resource('books', '42').toString(), '/books/42'); - expect(r.related('books', '42', 'author').toString(), '/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - '/books/42/relationships/author'); - }); - - test('Authority is retained if exists in base', () { - final r = StandardRouting(Uri.parse('https://example.com')); - expect(r.collection('books').toString(), 'https://example.com/books'); - expect( - r.resource('books', '42').toString(), 'https://example.com/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/books/42/relationships/author'); - }); - - test('Authority is retained if exists in base (non-directory path)', () { - final r = StandardRouting(Uri.parse('https://example.com/foo')); - expect(r.collection('books').toString(), 'https://example.com/books'); - expect( - r.resource('books', '42').toString(), 'https://example.com/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/books/42/relationships/author'); - }); - - test('Authority and path is retained if exists in base (directory path)', () { - final r = StandardRouting(Uri.parse('https://example.com/foo/')); - expect(r.collection('books').toString(), 'https://example.com/foo/books'); - expect(r.resource('books', '42').toString(), - 'https://example.com/foo/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/relationships/author'); - }); -} diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart deleted file mode 100644 index 1ae27cb8..00000000 --- a/test/unit/server/json_api_server_test.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; - -void main() { - final routing = StandardRouting(Uri.parse('http://example.com')); - final server = JsonApiServer(RepositoryController(InMemoryRepository({}))); - - group('JsonApiServer', () { - test('returns `bad request` on incomplete relationship', () async { - final rq = HttpRequest( - 'PATCH', routing.relationship('books', '1', 'author'), - body: '{}'); - final rs = await server(rq); - expect(rs.statusCode, 400); - expect(rs.headers['content-type'], Document.contentType); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad Request'); - expect(error.detail, 'Incomplete relationship object'); - }); - - test('returns `bad request` when payload is not a valid JSON', () async { - final rq = - HttpRequest('POST', routing.collection('books'), body: '"ololo"abc'); - final rs = await server(rq); - expect(rs.statusCode, 400); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad Request'); - expect(error.detail, startsWith('Invalid JSON. ')); - }); - - test('returns `bad request` when payload is not a valid JSON:API object', - () async { - final rq = - HttpRequest('POST', routing.collection('books'), body: '"oops"'); - final rs = await server(rq); - expect(rs.statusCode, 400); - expect(rs.headers['content-type'], Document.contentType); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad Request'); - expect(error.detail, - "A JSON:API resource document must be a JSON object and contain the 'data' member"); - }); - - test('returns `bad request` when payload violates JSON:API', () async { - final rq = HttpRequest('POST', routing.collection('books'), - body: '{"data": {}}'); - final rs = await server(rq); - expect(rs.statusCode, 400); - expect(rs.headers['content-type'], Document.contentType); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad Request'); - expect(error.detail, 'Invalid JSON:API resource object'); - }); - - test('returns `not found` if URI is not recognized', () async { - final rq = HttpRequest('GET', Uri.parse('http://localhost/a/b/c/d/e')); - final rs = await server(rq); - expect(rs.statusCode, 404); - expect(rs.headers['content-type'], Document.contentType); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '404'); - expect(error.title, 'Not Found'); - expect(error.detail, 'The requested URL does exist on the server'); - }); - - test('returns `method not allowed` for resource collection', () async { - final rq = HttpRequest('DELETE', routing.collection('books')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET, POST, OPTIONS'); - }); - - test('returns `method not allowed` for resource', () async { - final rq = HttpRequest('POST', routing.resource('books', '1')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH, OPTIONS'); - }); - - test('returns `method not allowed` for related', () async { - final rq = HttpRequest('POST', routing.related('books', '1', 'author')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET, OPTIONS'); - }); - - test('returns `method not allowed` for relationship', () async { - final rq = - HttpRequest('PUT', routing.relationship('books', '1', 'author')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST, OPTIONS'); - }); - - test('options request contains no body', () async { - final rq = - HttpRequest('OPTIONS', routing.relationship('books', '1', 'author')); - final rs = await server(rq); - expect(rs.headers['access-control-allow-methods'], - 'DELETE, GET, PATCH, POST, OPTIONS'); - expect(rs.headers['access-control-allow-headers'], 'Content-Type'); - expect(rs.headers['access-control-allow-origin'], '*'); - expect(rs.headers['access-control-allow-max-age'], '3600'); - expect(rs.statusCode, 204); - expect(rs.body, ''); - }); - }); -} diff --git a/test/unit/server/numbered_page_test.dart b/test/unit/server/numbered_page_test.dart deleted file mode 100644 index 1da56662..00000000 --- a/test/unit/server/numbered_page_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:test/test.dart'; - -void main() { - test('page size must be posititve', () { - expect(() => FixedSizePage(0), throwsArgumentError); - }); - - test('no pages after last', () { - final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); - expect(pagination.next(page, 10), isNull); - }); - - test('no pages before first', () { - final page = Page({'number': '1'}); - final pagination = FixedSizePage(3); - expect(pagination.prev(page), isNull); - }); - - test('pagination', () { - final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); - expect(pagination.prev(page)['number'], '3'); - expect(pagination.next(page, 100)['number'], '5'); - expect(pagination.first()['number'], '1'); - expect(pagination.last(100)['number'], '34'); - }); -} diff --git a/test/e2e/browser_test.dart b/tmp/e2e/browser_test.dart similarity index 86% rename from test/e2e/browser_test.dart rename to tmp/e2e/browser_test.dart index 43267180..ddc9a50b 100644 --- a/test/e2e/browser_test.dart +++ b/tmp/e2e/browser_test.dart @@ -1,12 +1,12 @@ import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; +import 'package:json_api/json_api.dart'; +import 'package:json_api_common/routing.dart'; import 'package:test/test.dart'; void main() async { final port = 8081; final host = 'localhost'; - final routing = StandardRouting(Uri(host: host, port: port, scheme: 'http')); + final routing = Routing(Uri(host: host, port: port, scheme: 'http')); Client httpClient; setUp(() { diff --git a/test/e2e/client_server_interaction_test.dart b/tmp/e2e/client_server_interaction_test.dart similarity index 82% rename from test/e2e/client_server_interaction_test.dart rename to tmp/e2e/client_server_interaction_test.dart index c0b0d812..b64e2a2a 100644 --- a/test/e2e/client_server_interaction_test.dart +++ b/tmp/e2e/client_server_interaction_test.dart @@ -1,10 +1,10 @@ import 'dart:io'; import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; +import 'package:json_api/json_api.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/client/dart_http.dart'; +import 'package:json_api/src/dart_http.dart'; +import 'package:json_api_common/routing.dart'; import 'package:pedantic/pedantic.dart'; import 'package:test/test.dart'; @@ -12,8 +12,7 @@ void main() { group('Client-Server interaction over HTTP', () { final port = 8088; final host = 'localhost'; - final routing = - StandardRouting(Uri(host: host, port: port, scheme: 'http')); + final routing = Routing(Uri(host: host, port: port, scheme: 'http')); final repo = InMemoryRepository({'writers': {}, 'books': {}}); final jsonApiServer = JsonApiServer(RepositoryController(repo)); final serverHandler = DartServer(jsonApiServer); @@ -39,7 +38,8 @@ void main() { await client .createResource('books', '2', attributes: {'title': 'Refactoring'}); await client.updateResource('books', '2', many: {'authors': []}); - await client.addMany('books', '2', 'authors', [Identifier('writers', '1')]); + await client + .addMany('books', '2', 'authors', [Identifier('writers', '1')]); final response = await client.fetchResource('books', '2', include: ['authors']); diff --git a/test/e2e/hybrid_server.dart b/tmp/e2e/hybrid_server.dart similarity index 100% rename from test/e2e/hybrid_server.dart rename to tmp/e2e/hybrid_server.dart From c02300b3340756041509edea85e4c2fe2a447fb6 Mon Sep 17 00:00:00 2001 From: f3ath Date: Thu, 28 May 2020 21:05:37 -0700 Subject: [PATCH 74/99] wip --- analysis_options.yaml | 3 - lib/src/document.dart | 319 +++++++----------- lib/src/json_api_client.dart | 48 +-- lib/src/request.dart | 20 +- lib/src/response.dart | 117 +++---- pubspec.yaml | 2 +- test/client_test.dart | 634 +++++++++++++++++++++++++++++++++-- test/responses.dart | 25 +- 8 files changed, 849 insertions(+), 319 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index fd5782a0..d9406f33 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,7 +1,4 @@ include: package:pedantic/analysis_options.yaml -analyzer: - enable-experiment: - - non-nullable linter: rules: - sort_constructors_first diff --git a/lib/src/document.dart b/lib/src/document.dart index 5d71a2eb..8ee9a874 100644 --- a/lib/src/document.dart +++ b/lib/src/document.dart @@ -2,69 +2,39 @@ import 'dart:collection'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; -class ResourceDocument { - ResourceDocument(this._resource); +/// A generic response document +class Document { + Document(dynamic json) + : json = json is Map + ? json + : throw ArgumentError('Invalid JSON'); - final Resource _resource; + final Map json; - Map toJson() => {'data': _resource.toJson()}; -} + bool get hasData => json.containsKey('data'); -class Document { - Document({Map meta}) { - Maybe(meta).ifPresent(this.meta.addAll); - } + Maybe get data => Maybe(json['data']); - static Document fromJson(Object json) { - if (json is Map) { - return Document(meta: json.containsKey('meta') ? json['meta'] : null); - } - throw ArgumentError('Map expected'); - } + Maybe> get meta => + Maybe(json['meta']).cast>(); - final meta = {}; -} + Maybe> get links => path(['links']).map((_) => + _.map((key, value) => MapEntry(key.toString(), Link.fromJson(value)))); -/// Generic JSON:API document with data -class DataDocument extends Document { - DataDocument(this.data, - {Map meta, - Map links, - Iterable included}) - : _included = Maybe(included), - super(meta: meta) { - Maybe(links).ifPresent(this.links.addAll); - } + Maybe> get included => path(['included']) + .map((_) => _.map(ResourceWithIdentity.fromJson).toList()); - static DataDocument fromJson(Object json) { - if (json is Map) { - if (!json.containsKey('data')) throw ArgumentError('No "data" key found'); - final meta = json.containsKey('meta') ? json['meta'] : null; - final links = - json.containsKey('links') ? Link.mapFromJson(json['links']) : null; - - if (json.containsKey('included')) { - final error = ArgumentError('Invalid "included" value'); - final included = Maybe(json['included']) - .map((_) => _ is List ? _ : throw error) - .map((_) => _.map(ResourceWithIdentity.fromJson)) - .orThrow(() => error); - return DataDocument(json['data'], - meta: meta, links: links, included: included); - } - return DataDocument(json['data'], meta: meta, links: links); - } - throw ArgumentError('Map expected'); - } + /// Returns the value at the [path] if both are true: + /// - the path exists + /// - the value is of type T + Maybe path(List path) => _path(path, Maybe(json)); - final Object data; - final Maybe> _included; - final links = {}; - - Iterable included( - {Iterable Function() orElse}) => - _included.orGet(() => - Maybe(orElse).orThrow(() => StateError('No "included" key found'))()); + Maybe _path(List path, Maybe json) { + if (path.isEmpty) throw ArgumentError('Empty path'); + final value = json.flatMap((_) => Maybe(_[path.first])); + if (path.length == 1) return value.cast(); + return _path(path.sublist(1), value.cast()); + } } /// [ErrorObject] represents an error occurred on the server. @@ -76,36 +46,39 @@ class ErrorObject { /// passed through the [links['about']] argument takes precedence and will overwrite /// the `about` key in [links]. ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - ErrorSource source, - Map links, + String id = '', + String status = '', + String code = '', + String title = '', + String detail = '', + Map meta = const {}, + String sourceParameter = '', + String sourcePointer = '', + Map links = const {}, }) : id = id ?? '', status = status ?? '', code = code ?? '', title = title ?? '', detail = detail ?? '', - source = source ?? ErrorSource(), + sourcePointer = sourcePointer ?? '', + sourceParameter = sourceParameter ?? '', meta = Map.unmodifiable(meta ?? {}), links = Map.unmodifiable(links ?? {}); - static ErrorObject fromJson(Object json) { + static ErrorObject fromJson(dynamic json) { if (json is Map) { + final document = Document(json); return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: Maybe(json['source']) - .map(ErrorSource.fromJson) - .orGet(() => ErrorSource()), - meta: json['meta'], - links: Maybe(json['links']).map(Link.mapFromJson).orGet(() => {})); + id: Maybe(json['id']).cast().or(''), + status: Maybe(json['status']).cast().or(''), + code: Maybe(json['code']).cast().or(''), + title: Maybe(json['title']).cast().or(''), + detail: Maybe(json['detail']).cast().or(''), + sourceParameter: + document.path(['source', 'parameter']).or(''), + sourcePointer: document.path(['source', 'pointer']).or(''), + meta: document.meta.or(const {}), + links: document.links.or(const {})); } throw ArgumentError('A JSON:API error must be a JSON object'); } @@ -132,59 +105,25 @@ class ErrorObject { /// May be empty. final String detail; - /// The `source` object. - final ErrorSource source; - - final Map meta; - final Map links; - - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } -} - -/// An object containing references to the source of the error, optionally including any of the following members: -/// - pointer: a JSON Pointer (RFC6901) to the associated entity in the request document, -/// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. -/// - parameter: a string indicating which URI query parameter caused the error. -class ErrorSource { - ErrorSource({String pointer, String parameter}) - : pointer = pointer ?? '', - parameter = parameter ?? ''; - - static ErrorSource fromJson(Object json) { - if (json is Map) { - return ErrorSource( - pointer: json['pointer'], parameter: json['parameter']); - } - throw ArgumentError('Can not parse ErrorSource'); - } - - final String pointer; + /// A JSON Pointer (RFC6901) to the associated entity in the request document, + /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. + final String sourcePointer; - final String parameter; + /// A string indicating which URI query parameter caused the error. + final String sourceParameter; - bool get isNotEmpty => pointer.isNotEmpty || parameter.isNotEmpty; + /// Meta data. + final Map meta; - Map toJson() => { - if (pointer.isNotEmpty) 'pointer': pointer, - if (parameter.isNotEmpty) 'parameter': parameter - }; + /// Error links. May be empty. + final Map links; } /// A JSON:API link /// https://jsonapi.org/format/#document-links class Link { - Link(this.uri, {Map meta = const {}}) : meta = meta ?? {} { + Link(this.uri, {Map meta = const {}}) + : meta = Map.unmodifiable(meta ?? const {}) { ArgumentError.checkNotNull(uri, 'uri'); } @@ -192,10 +131,13 @@ class Link { final Map meta; /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { + static Link fromJson(dynamic json) { if (json is String) return Link(Uri.parse(json)); if (json is Map) { - return Link(Uri.parse(json['href']), meta: json['meta']); + final document = Document(json); + return Link( + Maybe(json['href']).cast().map(Uri.parse).orGet(() => Uri()), + meta: document.meta.or(const {})); } throw ArgumentError( 'A JSON:API link must be a JSON string or a JSON object'); @@ -203,41 +145,31 @@ class Link { /// Reconstructs the document's `links` member into a map. /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Link.fromJson(v))); - } - throw ArgumentError('A JSON:API links object must be a JSON object'); - } - - Object toJson() => - meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; + static Maybe> mapFromJson(dynamic json) => Maybe(json) + .cast() + .map((_) => _.map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))); @override String toString() => uri.toString(); } -class ResourceCollection with IterableMixin { - ResourceCollection(Iterable resources) - : _map = Map.fromEntries(resources.map((_) => MapEntry(_.key, _))); +class IdentityCollection with IterableMixin { + IdentityCollection(Iterable resources) + : _map = Map.fromIterable(resources, key: (_) => _.key); - static ResourceCollection fromJson(Object json) => - ResourceCollection(Maybe(json) - .map((_) => _ is List ? _ : throw ArgumentError('List expected')) - .map((_) => _.map(ResourceWithIdentity.fromJson)) - .orThrow(() => ArgumentError('Invalid json'))); + final Map _map; - final Map _map; + Maybe get(String key) => Maybe(_map[key]); @override - Iterator get iterator => _map.values.iterator; + Iterator get iterator => _map.values.iterator; } class Resource { Resource(this.type, - {Map meta, - Map attributes, - Map relationships}) + {Map meta = const {}, + Map attributes = const {}, + Map relationships = const {}}) : meta = Map.unmodifiable(meta ?? {}), relationships = Map.unmodifiable(relationships ?? {}), attributes = Map.unmodifiable(attributes ?? {}); @@ -257,34 +189,31 @@ class Resource { class ResourceWithIdentity extends Resource with Identity { ResourceWithIdentity(this.type, this.id, - {Map links, - Map meta, - Map attributes, - Map relationships}) + {Map links = const {}, + Map meta = const {}, + Map attributes = const {}, + Map relationships = const {}}) : links = Map.unmodifiable(links ?? {}), super(type, attributes: attributes, relationships: relationships, meta: meta); - static ResourceWithIdentity fromJson(Object json) { + static ResourceWithIdentity fromJson(dynamic json) { if (json is Map) { - final relationships = json['relationships']; - final attributes = json['attributes']; - final type = json['type']; - if ((relationships == null || relationships is Map) && - (attributes == null || attributes is Map) && - type is String && - type.isNotEmpty) { - return ResourceWithIdentity(json['type'], json['id'], - attributes: attributes, - relationships: Maybe(relationships) - .map((_) => _ is Map ? _ : throw ArgumentError('Map expected')) - .map((t) => t.map((key, value) => - MapEntry(key.toString(), Relationship.fromJson(value)))) - .orGet(() => {}), - links: Link.mapFromJson(json['links'] ?? {}), - meta: json['meta']); - } - throw ArgumentError('Invalid JSON:API resource object'); + return ResourceWithIdentity( + Maybe(json['type']) + .cast() + .orThrow(() => ArgumentError('Invalid type')), + Maybe(json['id']) + .cast() + .orThrow(() => ArgumentError('Invalid id')), + attributes: Maybe(json['attributes']).cast().or(const {}), + relationships: Maybe(json['relationships']) + .cast() + .map((t) => t.map((key, value) => + MapEntry(key.toString(), Relationship.fromJson(value)))) + .orGet(() => {}), + links: Link.mapFromJson(json['links']).or(const {}), + meta: json['meta']); } throw ArgumentError('A JSON:API resource must be a JSON object'); } @@ -295,13 +224,9 @@ class ResourceWithIdentity extends Resource with Identity { final String id; final Map links; - Many many(String key, {Many Function() orElse}) => Maybe(relationships[key]) - .cast() - .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); + Maybe many(String key) => Maybe(relationships[key]).cast(); - One one(String key, {One Function() orElse}) => Maybe(relationships[key]) - .cast() - .orGet(() => Maybe(orElse).orThrow(() => StateError('No element'))()); + Maybe one(String key) => Maybe(relationships[key]).cast(); @override Map toJson() => { @@ -312,15 +237,17 @@ class ResourceWithIdentity extends Resource with Identity { } abstract class Relationship with IterableMixin { - Relationship({Map links, Map meta}) + Relationship( + {Map links = const {}, Map meta = const {}}) : links = Map.unmodifiable(links ?? {}), meta = Map.unmodifiable(meta ?? {}); /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(Object json) { + static Relationship fromJson(dynamic json) { if (json is Map) { - final links = Maybe(json['links']).map(Link.mapFromJson).or(const {}); - final meta = json['meta']; + final document = Document(json); + final links = document.links.or(const {}); + final meta = document.meta.or(const {}); if (json.containsKey('data')) { final data = json['data']; if (data == null) { @@ -350,46 +277,46 @@ abstract class Relationship with IterableMixin { }; @override - Iterator get iterator => const [].iterator; + Iterator get iterator => const [].iterator; /// Narrows the type down to R if possible. Otherwise throws the [TypeError]. - R as() => this is R ? this : throw TypeError(); + R as() => this is R ? this as R : throw TypeError(); } class IncompleteRelationship extends Relationship { - IncompleteRelationship({Map links, Map meta}) + IncompleteRelationship( + {Map links = const {}, Map meta = const {}}) : super(links: links, meta: meta); } class One extends Relationship { One(Identifier identifier, - {Map links, Map meta}) - : _id = Just(identifier), + {Map links = const {}, Map meta = const {}}) + : identifier = Just(identifier), super(links: links, meta: meta); - One.empty({Map links, Map meta}) - : _id = Nothing(), + One.empty( + {Map links = const {}, Map meta = const {}}) + : identifier = Nothing(), super(links: links, meta: meta); - final Maybe _id; - @override final isSingular = true; @override - Map toJson() => {...super.toJson(), 'data': _id.or(null)}; + Map toJson() => + {...super.toJson(), 'data': identifier.or(null)}; - Identifier identifier({Identifier Function() ifEmpty}) => _id.orGet( - () => Maybe(ifEmpty).orThrow(() => StateError('Empty relationship'))()); + Maybe identifier; @override Iterator get iterator => - _id.map((_) => [_]).or(const []).iterator; + identifier.map((_) => [_]).or(const []).iterator; } class Many extends Relationship { Many(Iterable identifiers, - {Map links, Map meta}) + {Map links = const {}, Map meta = const {}}) : super(links: links, meta: meta) { identifiers.forEach((_) => _map[_.key] = _); } @@ -408,10 +335,10 @@ class Many extends Relationship { } class Identifier with Identity { - Identifier(this.type, this.id, {Map meta}) + Identifier(this.type, this.id, {Map meta = const {}}) : meta = Map.unmodifiable(meta ?? {}); - static Identifier fromJson(Object json) { + static Identifier fromJson(dynamic json) { if (json is Map) { return Identifier(json['type'], json['id'], meta: json['meta']); } @@ -419,9 +346,9 @@ class Identifier with Identity { } static Identifier fromKey(String key) { - final i = key.indexOf(Identity.delimiter); - if (i < 1) throw ArgumentError('Invalid key'); - return Identifier(key.substring(0, i), key.substring(i + 1)); + final parts = key.split(Identity.delimiter); + if (parts.length != 2) throw ArgumentError('Invalid key'); + return Identifier(parts.first, parts.last); } @override diff --git a/lib/src/json_api_client.dart b/lib/src/json_api_client.dart index 4cc99047..f8980710 100644 --- a/lib/src/json_api_client.dart +++ b/lib/src/json_api_client.dart @@ -87,11 +87,12 @@ class JsonApiClient { Map> many = const {}, Map headers}) async => CreateResource(await call( - JsonApiRequest('POST', - headers: headers, - document: ResourceDocument(Resource(type, - attributes: attributes, - relationships: _relationships(one, many)))), + JsonApiRequest('POST', headers: headers, document: { + 'data': Resource(type, + attributes: attributes, + relationships: _relationships(one, many)) + .toJson() + }), _url.collection(type))); /// Creates a resource on the server. @@ -127,10 +128,11 @@ class JsonApiClient { /// Replaces the to-one [relationship] of [type] : [id]. Future> replaceOne( - String type, String id, String relationship, Identifier identifier, + String type, String id, String relationship, String identifier, {Map headers}) async => UpdateRelationship(await call( - JsonApiRequest('PATCH', headers: headers, document: One(identifier)), + JsonApiRequest('PATCH', + headers: headers, document: One(Identifier.fromKey(identifier))), _url.relationship(type, id, relationship))); /// Deletes the to-one [relationship] of [type] : [id]. @@ -143,28 +145,32 @@ class JsonApiClient { /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) async => UpdateRelationship(await call( JsonApiRequest('DELETE', - headers: headers, document: Many(identifiers)), + headers: headers, + document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers}) async => UpdateRelationship(await call( JsonApiRequest('PATCH', - headers: headers, document: Many(identifiers)), + headers: headers, + document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. Future> addMany(String type, String id, - String relationship, Iterable identifiers, + String relationship, Iterable identifiers, {Map headers = const {}}) async => UpdateRelationship(await call( - JsonApiRequest('POST', headers: headers, document: Many(identifiers)), + JsonApiRequest('POST', + headers: headers, + document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); /// Sends the [request] to [uri]. @@ -184,14 +190,14 @@ class JsonApiClient { return response; } - ResourceDocument _resource( - String type, - String id, - Map attributes, - Map one, - Map> many) => - ResourceDocument(ResourceWithIdentity(type, id, - attributes: attributes, relationships: _relationships(one, many))); + Object _resource(String type, String id, Map attributes, + Map one, Map> many) => + { + 'data': ResourceWithIdentity(type, id, + attributes: attributes, + relationships: _relationships(one, many)) + .toJson() + }; Map _relationships( Map one, Map> many) => diff --git a/lib/src/request.dart b/lib/src/request.dart index 8f0d5125..bbac5c03 100644 --- a/lib/src/request.dart +++ b/lib/src/request.dart @@ -8,14 +8,14 @@ import 'package:maybe_just_nothing/maybe_just_nothing.dart'; class JsonApiRequest { /// Created an instance of JSON:API HTTP request. /// - /// - [method] the HTTP method - /// - [document] if passed, will be JSON-encoded and sent in the HTTP body - /// - [headers] any arbitrary HTTP headers - /// - [include] related resources to include (for GET requests) - /// - [fields] sparse fieldsets (for GET requests) - /// - [sort] sorting options (for GET collection requests) - /// - [page] pagination options (for GET collection requests) - /// - [query] any arbitrary query parameters (for GET requests) + /// - [method] - the HTTP method + /// - [document] - if passed, will be JSON-encoded and sent in the HTTP body + /// - [headers] - any arbitrary HTTP headers + /// - [include] - related resources to include (for GET requests) + /// - [fields] - sparse fieldsets (for GET requests) + /// - [sort] - sorting options (for GET collection requests) + /// - [page] - pagination options (for GET collection requests) + /// - [query] - any arbitrary query parameters (for GET requests) JsonApiRequest(String method, {Object document, Map headers, @@ -34,8 +34,8 @@ class JsonApiRequest { ...?query, }), headers = Map.unmodifiable({ - 'Accept': ContentType.jsonApi, - if (document != null) 'Content-Type': ContentType.jsonApi, + 'accept': ContentType.jsonApi, + if (document != null) 'content-type': ContentType.jsonApi, ...?headers, }); diff --git a/lib/src/response.dart b/lib/src/response.dart index 42f715bc..7670415f 100644 --- a/lib/src/response.dart +++ b/lib/src/response.dart @@ -8,24 +8,27 @@ import 'package:maybe_just_nothing/maybe_just_nothing.dart'; class FetchCollection with IterableMixin { factory FetchCollection(HttpResponse http) { - final document = DataDocument.fromJson(jsonDecode(http.body)); + final document = Document(jsonDecode(http.body)); return FetchCollection._(http, - resources: ResourceCollection.fromJson(document.data), - included: ResourceCollection(document.included(orElse: () => [])), - links: document.links); + resources: IdentityCollection(document.data + .cast() + .map((_) => _.map(ResourceWithIdentity.fromJson)) + .or(const [])), + included: IdentityCollection(document.included.or([])), + links: document.links.or(const {})); } FetchCollection._(this.http, - {ResourceCollection resources, - ResourceCollection included, + {IdentityCollection resources, + IdentityCollection included, Map links = const {}}) - : resources = resources ?? ResourceCollection(const []), + : resources = resources ?? IdentityCollection(const []), links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + included = included ?? IdentityCollection(const []); final HttpResponse http; - final ResourceCollection resources; - final ResourceCollection included; + final IdentityCollection resources; + final IdentityCollection included; final Map links; @override @@ -34,29 +37,36 @@ class FetchCollection with IterableMixin { class FetchPrimaryResource { factory FetchPrimaryResource(HttpResponse http) { - final document = DataDocument.fromJson(jsonDecode(http.body)); + final document = Document(jsonDecode(http.body)); return FetchPrimaryResource._( - http, ResourceWithIdentity.fromJson(document.data), - included: ResourceCollection(document.included(orElse: () => [])), - links: document.links); + http, + document.data + .map(ResourceWithIdentity.fromJson) + .orThrow(() => ArgumentError('Invalid response')), + included: IdentityCollection(document.included.or([])), + links: document.links.or(const {})); } FetchPrimaryResource._(this.http, this.resource, - {ResourceCollection included, Map links = const {}}) + {IdentityCollection included, Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + included = included ?? IdentityCollection(const []); final HttpResponse http; final ResourceWithIdentity resource; - final ResourceCollection included; + final IdentityCollection included; final Map links; } class CreateResource { factory CreateResource(HttpResponse http) { - final document = DataDocument.fromJson(jsonDecode(http.body)); - return CreateResource._(http, ResourceWithIdentity.fromJson(document.data), - links: document.links); + final document = Document(jsonDecode(http.body)); + return CreateResource._( + http, + document.data + .map(ResourceWithIdentity.fromJson) + .orThrow(() => ArgumentError('Invalid response')), + links: document.links.or(const {})); } CreateResource._(this.http, this.resource, @@ -73,34 +83,34 @@ class UpdateResource { if (http.body.isEmpty) { return UpdateResource._empty(http); } - final document = DataDocument.fromJson(jsonDecode(http.body)); - return UpdateResource._(http, ResourceWithIdentity.fromJson(document.data), - links: document.links); + final document = Document(jsonDecode(http.body)); + return UpdateResource._( + http, + document.data + .map(ResourceWithIdentity.fromJson) + .orThrow(() => ArgumentError('Invalid response')), + links: document.links.or(const {})); } UpdateResource._(this.http, ResourceWithIdentity resource, {Map links = const {}}) - : _resource = Just(resource), + : resource = Just(resource), links = Map.unmodifiable(links ?? const {}); UpdateResource._empty(this.http) - : _resource = Nothing(), + : resource = Nothing(), links = const {}; final HttpResponse http; final Map links; - final Maybe _resource; - - ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => - _resource.orGet(() => - Maybe(orElse).orThrow(() => StateError('No content returned'))()); + final Maybe resource; } class DeleteResource { DeleteResource(this.http) : meta = http.body.isEmpty ? const {} - : Document.fromJson(jsonDecode(http.body)).meta; + : Document(jsonDecode(http.body)).meta.or(const {}); final HttpResponse http; final Map meta; @@ -116,41 +126,34 @@ class FetchRelationship { class UpdateRelationship { UpdateRelationship(this.http) - : _relationship = Maybe(http.body) + : relationship = Maybe(http.body) .filter((_) => _.isNotEmpty) .map(jsonDecode) .map(Relationship.fromJson) .map((_) => _.as()); final HttpResponse http; - final Maybe _relationship; - - R relationship({R Function() orElse}) => _relationship.orGet( - () => Maybe(orElse).orThrow(() => StateError('No content returned'))()); + final Maybe relationship; } class FetchRelatedResource { factory FetchRelatedResource(HttpResponse http) { - final document = DataDocument.fromJson(jsonDecode(http.body)); + final document = Document(jsonDecode(http.body)); return FetchRelatedResource._( - http, Maybe(document.data).map(ResourceWithIdentity.fromJson), - included: ResourceCollection(document.included(orElse: () => [])), - links: document.links); + http, document.data.map(ResourceWithIdentity.fromJson), + included: IdentityCollection(document.included.or([])), + links: document.links.or(const {})); } - FetchRelatedResource._(this.http, this._resource, - {ResourceCollection included, Map links = const {}}) + FetchRelatedResource._(this.http, this.resource, + {IdentityCollection included, Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), - included = included ?? ResourceCollection(const []); + included = included ?? IdentityCollection(const []); - final Maybe _resource; + final Maybe resource; final HttpResponse http; - final ResourceCollection included; + final IdentityCollection included; final Map links; - - ResourceWithIdentity resource({ResourceWithIdentity Function() orElse}) => - _resource.orGet(() => Maybe(orElse) - .orThrow(() => StateError('Related resource is empty'))()); } class RequestFailure { @@ -163,14 +166,14 @@ class RequestFailure { http.headers['content-type'] != ContentType.jsonApi) { return RequestFailure(http); } - final errors = Maybe(jsonDecode(http.body)) - .map((_) => _ is Map ? _ : throw ArgumentError('Invalid json')) - .map((_) => _['errors']) - .map((_) => _ is List ? _ : throw ArgumentError('Invalid json')) - .map((_) => _.map(ErrorObject.fromJson)) - .or([]); - - return RequestFailure(http, errors: errors); + + return RequestFailure(http, + errors: Maybe(jsonDecode(http.body)) + .cast() + .flatMap((_) => Maybe(_['errors'])) + .cast() + .map((_) => _.map(ErrorObject.fromJson)) + .or([])); } final HttpResponse http; diff --git a/pubspec.yaml b/pubspec.yaml index 5640d4f1..50ad8365 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: Framework-agnostic implementations of JSON:API Client. Supports JSO environment: sdk: '>=2.8.0 <3.0.0' dependencies: - json_api_common: ^0.0.2 + json_api_common: ^0.0.3 maybe_just_nothing: ^0.1.0 dev_dependencies: json_api_server: diff --git a/test/client_test.dart b/test/client_test.dart index 293cbbf7..612d20b0 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:json_api/json_api.dart'; import 'package:json_api_common/http.dart'; import 'package:json_api_common/url_design.dart'; @@ -15,17 +17,17 @@ void main() { }); group('Fetch Collection', () { - test('Sends correct request when given no arguments', () async { - http.response = mock.fetchCollection200; + test('Sends correct request when given minimum arguments', () async { + http.response = mock.collection; final response = await client.fetchCollection('articles'); expect(response.length, 1); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, {'accept': 'application/vnd.api+json'}); }); test('Sends correct request when given all possible arguments', () async { - http.response = mock.fetchCollection200; + http.response = mock.collection; final response = await client.fetchCollection('articles', headers: { 'foo': 'bar' }, include: [ @@ -41,9 +43,9 @@ void main() { 'foo': 'bar' }); expect(response.length, 1); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), - '/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); + r'/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); expect(http.request.headers, {'accept': 'application/vnd.api+json', 'foo': 'bar'}); }); @@ -61,18 +63,18 @@ void main() { }); group('Fetch Related Collection', () { - test('Sends correct request when given no arguments', () async { - http.response = mock.fetchCollection200; + test('Sends correct request when given minimum arguments', () async { + http.response = mock.collection; final response = await client.fetchRelatedCollection('people', '1', 'articles'); expect(response.length, 1); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/people/1/articles'); expect(http.request.headers, {'accept': 'application/vnd.api+json'}); }); test('Sends correct request when given all possible arguments', () async { - http.response = mock.fetchCollection200; + http.response = mock.collection; final response = await client .fetchRelatedCollection('people', '1', 'articles', headers: { 'foo': 'bar' @@ -89,9 +91,9 @@ void main() { 'foo': 'bar' }); expect(response.length, 1); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), - '/people/1/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); + r'/people/1/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); expect(http.request.headers, {'accept': 'application/vnd.api+json', 'foo': 'bar'}); }); @@ -109,17 +111,17 @@ void main() { }); group('Fetch Primary Resource', () { - test('Sends correct request when given no arguments', () async { - http.response = mock.fetchResource200; + test('Sends correct request when given minimum arguments', () async { + http.response = mock.primaryResource; final response = await client.fetchResource('articles', '1'); expect(response.resource.type, 'articles'); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, {'accept': 'application/vnd.api+json'}); }); test('Sends correct request when given all possible arguments', () async { - http.response = mock.fetchResource200; + http.response = mock.primaryResource; final response = await client.fetchResource('articles', '1', headers: { 'foo': 'bar' }, include: [ @@ -130,9 +132,9 @@ void main() { 'foo': 'bar' }); expect(response.resource.type, 'articles'); - expect(http.request.method, 'GET'); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), - '/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); + r'/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); expect(http.request.headers, {'accept': 'application/vnd.api+json', 'foo': 'bar'}); }); @@ -150,19 +152,20 @@ void main() { }); group('Fetch Related Resource', () { - test('Sends correct request when given no arguments', () async { - http.response = mock.fetchRelatedResourceNull200; + test('Sends correct request when given minimum arguments', () async { + http.response = mock.relatedResourceNull; final response = await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource, isA>()); - expect(http.request.method, 'GET'); - expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/author'); expect(http.request.headers, {'accept': 'application/vnd.api+json'}); }); test('Sends correct request when given all possible arguments', () async { - http.response = mock.fetchResource200; - final response = await client.fetchResource('articles', '1', headers: { + http.response = mock.relatedResourceNull; + final response = await client + .fetchRelatedResource('articles', '1', 'author', headers: { 'foo': 'bar' }, include: [ 'author' @@ -171,10 +174,10 @@ void main() { }, query: { 'foo': 'bar' }); - expect(response.resource.type, 'articles'); - expect(http.request.method, 'GET'); + expect(response.resource, isA>()); + expect(http.request.method, 'get'); expect(http.request.uri.toString(), - '/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); + r'/articles/1/author?include=author&fields%5Bauthor%5D=name&foo=bar'); expect(http.request.headers, {'accept': 'application/vnd.api+json', 'foo': 'bar'}); }); @@ -182,7 +185,582 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client.fetchResource('articles', '1'); + await client.fetchRelatedResource('articles', '1', 'author'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Fetch Relationship', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.one; + final response = + await client.fetchRelationship('articles', '1', 'author'); + expect(response.relationship, isA()); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.one; + final response = await client.fetchRelationship( + 'articles', '1', 'author', + headers: {'foo': 'bar'}, query: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), + '/articles/1/relationships/author?foo=bar'); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchRelationship('articles', '1', 'author'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Create New Resource', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.primaryResource; + final response = await client.createNewResource('articles'); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles'} + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.primaryResource; + final response = await client.createNewResource('articles', attributes: { + 'cool': true + }, one: { + 'author': 'people:42' + }, many: { + 'tags': ['tags:1', 'tags:2'] + }, headers: { + 'foo': 'bar' + }); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '42'} + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + } + } + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.createNewResource('articles'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Create Resource', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.primaryResource; + final response = await client.createResource('articles', '1'); + expect(response.resource, isA>()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.primaryResource; + final response = + await client.createResource('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': 'people:42' + }, many: { + 'tags': ['tags:1', 'tags:2'] + }, headers: { + 'foo': 'bar' + }); + expect(response.resource, isA>()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '42'} + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + } + } + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.createResource('articles', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Create Resource', () { + test('Sends correct request when given minimum arguments', () async { + http.response = HttpResponse(204); + final response = await client.deleteResource('articles', '1'); + expect(response.meta, isEmpty); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + }); + expect(http.request.body, isEmpty); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = HttpResponse(204); + final response = + await client.deleteResource('articles', '1', headers: {'foo': 'bar'}); + expect(response.meta, isEmpty); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'foo': 'bar', + }); + expect(http.request.body, isEmpty); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.deleteResource('articles', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Update Resource', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.primaryResource; + final response = await client.updateResource('articles', '1'); + expect(response.resource, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.primaryResource; + final response = + await client.updateResource('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': 'people:42' + }, many: { + 'tags': ['tags:1', 'tags:2'] + }, headers: { + 'foo': 'bar' + }); + expect(response.resource, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '42'} + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + } + } + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.updateResource('articles', '1'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Replace One', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.one; + final response = + await client.replaceOne('articles', '1', 'author', 'people:42'); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.one; + final response = await client.replaceOne( + 'articles', '1', 'author', 'people:42', + headers: {'foo': 'bar'}); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.replaceOne('articles', '1', 'author', 'people:42'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Delete One', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.one; + final response = await client.deleteOne('articles', '1', 'author'); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.one; + final response = await client + .deleteOne('articles', '1', 'author', headers: {'foo': 'bar'}); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.deleteOne('articles', '1', 'author'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Delete Many', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.many; + final response = + await client.deleteMany('articles', '1', 'tags', ['tags:1']); + expect(response.relationship, isA>()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.many; + final response = await client.deleteMany( + 'articles', '1', 'tags', ['tags:1'], + headers: {'foo': 'bar'}); + expect(response.relationship, isA>()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.deleteMany('articles', '1', 'tags', ['tags:1']); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Replace Many', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.many; + final response = + await client.replaceMany('articles', '1', 'tags', ['tags:1']); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.many; + final response = await client.replaceMany( + 'articles', '1', 'tags', ['tags:1'], + headers: {'foo': 'bar'}); + expect(response.relationship, isA>()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.replaceMany('articles', '1', 'tags', ['tags:1']); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Add Many', () { + test('Sends correct request when given minimum arguments', () async { + http.response = mock.many; + final response = + await client.addMany('articles', '1', 'tags', ['tags:1']); + expect(response.relationship, isA>()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = mock.many; + final response = await client.addMany('articles', '1', 'tags', ['tags:1'], + headers: {'foo': 'bar'}); + expect(response.relationship, isA>()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.addMany('articles', '1', 'tags', ['tags:1']); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + }); + + group('Call', () { + test('Sends correct request when given minimum arguments', () async { + http.response = HttpResponse(204); + final response = + await client.call(JsonApiRequest('get'), Uri.parse('/foo')); + expect(response, http.response); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/foo'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + }); + expect(http.request.body, isEmpty); + }); + + test('Sends correct request when given all possible arguments', () async { + http.response = HttpResponse(204); + final response = await client.call( + JsonApiRequest('get', document: { + 'data': null + }, headers: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, sort: [ + 'title', + '-date' + ], page: { + 'limit': '10' + }, query: { + 'foo': 'bar' + }), + Uri.parse('/foo')); + expect(response, http.response); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), + r'/foo?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.call(JsonApiRequest('get'), Uri.parse('/foo')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); diff --git a/test/responses.dart b/test/responses.dart index ad4692bc..d39ecabc 100644 --- a/test/responses.dart +++ b/test/responses.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:json_api/json_api.dart'; import 'package:json_api_common/http.dart'; -final fetchCollection200 = HttpResponse(200, +final collection = HttpResponse(200, headers: {'Content-Type': ContentType.jsonApi}, body: jsonEncode({ 'links': { @@ -74,7 +74,7 @@ final fetchCollection200 = HttpResponse(200, ] })); -final fetchResource200 = HttpResponse(200, +final primaryResource = HttpResponse(200, headers: {'Content-Type': ContentType.jsonApi}, body: jsonEncode({ 'links': {'self': 'http://example.com/articles/1'}, @@ -89,12 +89,31 @@ final fetchResource200 = HttpResponse(200, } } })); -final fetchRelatedResourceNull200 = HttpResponse(200, +final relatedResourceNull = HttpResponse(200, headers: {'Content-Type': ContentType.jsonApi}, body: jsonEncode({ 'links': {'self': 'http://example.com/articles/1/author'}, 'data': null })); +final one = HttpResponse(200, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': {'type': 'people', 'id': '12'} + })); + +final many = HttpResponse(200, + headers: {'Content-Type': ContentType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [{'type': 'tags', 'id': '12'}] + })); final error422 = HttpResponse(422, headers: {'Content-Type': ContentType.jsonApi}, body: jsonEncode({ From 872539cf01bc18c7eef1b35119c25466f6a4a0b2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 1 Jun 2020 10:56:10 -0700 Subject: [PATCH 75/99] wip --- lib/json_api.dart | 22 +- lib/src/document.dart | 377 ------------------ lib/src/document/document.dart | 38 ++ lib/src/document/error_object.dart | 83 ++++ lib/src/document/identifier.dart | 30 ++ lib/src/document/identity.dart | 12 + lib/src/document/identity_collection.dart | 16 + lib/src/document/incomplete_relationship.dart | 9 + lib/src/document/link.dart | 35 ++ lib/src/document/many.dart | 20 + lib/src/document/one.dart | 28 ++ lib/src/document/relationship.dart | 51 +++ lib/src/document/resource.dart | 23 ++ lib/src/document/resource_with_identity.dart | 56 +++ lib/src/json_api_client.dart | 46 ++- lib/src/response.dart | 180 --------- lib/src/response/create_resource.dart | 26 ++ lib/src/response/delete_resource.dart | 16 + lib/src/response/fetch_collection.dart | 37 ++ lib/src/response/fetch_primary_resource.dart | 30 ++ lib/src/response/fetch_related_resource.dart | 27 ++ lib/src/response/fetch_relationship.dart | 14 + lib/src/response/request_failure.dart | 32 ++ lib/src/response/update_relationship.dart | 21 + lib/src/response/update_resource.dart | 34 ++ 25 files changed, 688 insertions(+), 575 deletions(-) delete mode 100644 lib/src/document.dart create mode 100644 lib/src/document/document.dart create mode 100644 lib/src/document/error_object.dart create mode 100644 lib/src/document/identifier.dart create mode 100644 lib/src/document/identity.dart create mode 100644 lib/src/document/identity_collection.dart create mode 100644 lib/src/document/incomplete_relationship.dart create mode 100644 lib/src/document/link.dart create mode 100644 lib/src/document/many.dart create mode 100644 lib/src/document/one.dart create mode 100644 lib/src/document/relationship.dart create mode 100644 lib/src/document/resource.dart create mode 100644 lib/src/document/resource_with_identity.dart delete mode 100644 lib/src/response.dart create mode 100644 lib/src/response/create_resource.dart create mode 100644 lib/src/response/delete_resource.dart create mode 100644 lib/src/response/fetch_collection.dart create mode 100644 lib/src/response/fetch_primary_resource.dart create mode 100644 lib/src/response/fetch_related_resource.dart create mode 100644 lib/src/response/fetch_relationship.dart create mode 100644 lib/src/response/request_failure.dart create mode 100644 lib/src/response/update_relationship.dart create mode 100644 lib/src/response/update_resource.dart diff --git a/lib/json_api.dart b/lib/json_api.dart index f6ec69c0..f91bf6f1 100644 --- a/lib/json_api.dart +++ b/lib/json_api.dart @@ -2,9 +2,27 @@ library json_api; export 'package:json_api/src/content_type.dart'; export 'package:json_api/src/dart_http.dart'; -export 'package:json_api/src/document.dart'; +export 'package:json_api/src/document/document.dart'; +export 'package:json_api/src/document/error_object.dart'; +export 'package:json_api/src/document/identifier.dart'; +export 'package:json_api/src/document/identity.dart'; +export 'package:json_api/src/document/incomplete_relationship.dart'; +export 'package:json_api/src/document/link.dart'; +export 'package:json_api/src/document/many.dart'; +export 'package:json_api/src/document/one.dart'; +export 'package:json_api/src/document/relationship.dart'; +export 'package:json_api/src/document/resource.dart'; +export 'package:json_api/src/document/resource_with_identity.dart'; export 'package:json_api/src/json_api_client.dart'; export 'package:json_api/src/json_api_client.dart'; export 'package:json_api/src/request.dart'; -export 'package:json_api/src/response.dart'; +export 'package:json_api/src/response/create_resource.dart'; +export 'package:json_api/src/response/delete_resource.dart'; +export 'package:json_api/src/response/fetch_collection.dart'; +export 'package:json_api/src/response/fetch_primary_resource.dart'; +export 'package:json_api/src/response/fetch_related_resource.dart'; +export 'package:json_api/src/response/fetch_relationship.dart'; +export 'package:json_api/src/response/request_failure.dart'; +export 'package:json_api/src/response/update_relationship.dart'; +export 'package:json_api/src/response/update_relationship.dart'; export 'package:json_api/src/status_code.dart'; diff --git a/lib/src/document.dart b/lib/src/document.dart deleted file mode 100644 index 8ee9a874..00000000 --- a/lib/src/document.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'dart:collection'; - -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// A generic response document -class Document { - Document(dynamic json) - : json = json is Map - ? json - : throw ArgumentError('Invalid JSON'); - - final Map json; - - bool get hasData => json.containsKey('data'); - - Maybe get data => Maybe(json['data']); - - Maybe> get meta => - Maybe(json['meta']).cast>(); - - Maybe> get links => path(['links']).map((_) => - _.map((key, value) => MapEntry(key.toString(), Link.fromJson(value)))); - - Maybe> get included => path(['included']) - .map((_) => _.map(ResourceWithIdentity.fromJson).toList()); - - /// Returns the value at the [path] if both are true: - /// - the path exists - /// - the value is of type T - Maybe path(List path) => _path(path, Maybe(json)); - - Maybe _path(List path, Maybe json) { - if (path.isEmpty) throw ArgumentError('Empty path'); - final value = json.flatMap((_) => Maybe(_[path.first])); - if (path.length == 1) return value.cast(); - return _path(path.sublist(1), value.cast()); - } -} - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id = '', - String status = '', - String code = '', - String title = '', - String detail = '', - Map meta = const {}, - String sourceParameter = '', - String sourcePointer = '', - Map links = const {}, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - sourcePointer = sourcePointer ?? '', - sourceParameter = sourceParameter ?? '', - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(dynamic json) { - if (json is Map) { - final document = Document(json); - return ErrorObject( - id: Maybe(json['id']).cast().or(''), - status: Maybe(json['status']).cast().or(''), - code: Maybe(json['code']).cast().or(''), - title: Maybe(json['title']).cast().or(''), - detail: Maybe(json['detail']).cast().or(''), - sourceParameter: - document.path(['source', 'parameter']).or(''), - sourcePointer: document.path(['source', 'pointer']).or(''), - meta: document.meta.or(const {}), - links: document.links.or(const {})); - } - throw ArgumentError('A JSON:API error must be a JSON object'); - } - - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// A JSON Pointer (RFC6901) to the associated entity in the request document, - /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. - final String sourcePointer; - - /// A string indicating which URI query parameter caused the error. - final String sourceParameter; - - /// Meta data. - final Map meta; - - /// Error links. May be empty. - final Map links; -} - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri, {Map meta = const {}}) - : meta = Map.unmodifiable(meta ?? const {}) { - ArgumentError.checkNotNull(uri, 'uri'); - } - - final Uri uri; - final Map meta; - - /// Reconstructs the link from the [json] object - static Link fromJson(dynamic json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - final document = Document(json); - return Link( - Maybe(json['href']).cast().map(Uri.parse).orGet(() => Uri()), - meta: document.meta.or(const {})); - } - throw ArgumentError( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Maybe> mapFromJson(dynamic json) => Maybe(json) - .cast() - .map((_) => _.map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))); - - @override - String toString() => uri.toString(); -} - -class IdentityCollection with IterableMixin { - IdentityCollection(Iterable resources) - : _map = Map.fromIterable(resources, key: (_) => _.key); - - final Map _map; - - Maybe get(String key) => Maybe(_map[key]); - - @override - Iterator get iterator => _map.values.iterator; -} - -class Resource { - Resource(this.type, - {Map meta = const {}, - Map attributes = const {}, - Map relationships = const {}}) - : meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}), - attributes = Map.unmodifiable(attributes ?? {}); - - final String type; - final Map meta; - final Map attributes; - final Map relationships; - - Map toJson() => { - 'type': type, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (meta.isNotEmpty) 'meta': meta, - }; -} - -class ResourceWithIdentity extends Resource with Identity { - ResourceWithIdentity(this.type, this.id, - {Map links = const {}, - Map meta = const {}, - Map attributes = const {}, - Map relationships = const {}}) - : links = Map.unmodifiable(links ?? {}), - super(type, - attributes: attributes, relationships: relationships, meta: meta); - - static ResourceWithIdentity fromJson(dynamic json) { - if (json is Map) { - return ResourceWithIdentity( - Maybe(json['type']) - .cast() - .orThrow(() => ArgumentError('Invalid type')), - Maybe(json['id']) - .cast() - .orThrow(() => ArgumentError('Invalid id')), - attributes: Maybe(json['attributes']).cast().or(const {}), - relationships: Maybe(json['relationships']) - .cast() - .map((t) => t.map((key, value) => - MapEntry(key.toString(), Relationship.fromJson(value)))) - .orGet(() => {}), - links: Link.mapFromJson(json['links']).or(const {}), - meta: json['meta']); - } - throw ArgumentError('A JSON:API resource must be a JSON object'); - } - - @override - final String type; - @override - final String id; - final Map links; - - Maybe many(String key) => Maybe(relationships[key]).cast(); - - Maybe one(String key) => Maybe(relationships[key]).cast(); - - @override - Map toJson() => { - 'id': id, - ...super.toJson(), - if (links.isNotEmpty) 'links': links, - }; -} - -abstract class Relationship with IterableMixin { - Relationship( - {Map links = const {}, Map meta = const {}}) - : links = Map.unmodifiable(links ?? {}), - meta = Map.unmodifiable(meta ?? {}); - - /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(dynamic json) { - if (json is Map) { - final document = Document(json); - final links = document.links.or(const {}); - final meta = document.meta.or(const {}); - if (json.containsKey('data')) { - final data = json['data']; - if (data == null) { - return One.empty(links: links, meta: meta); - } - if (data is Map) { - return One(Identifier.fromJson(data), links: links, meta: meta); - } - if (data is List) { - return Many(data.map(Identifier.fromJson), links: links, meta: meta); - } - } - return IncompleteRelationship(links: links, meta: meta); - } - throw ArgumentError('A JSON:API relationship object must be a JSON object'); - } - - final Map links; - final Map meta; - final isSingular = false; - final isPlural = false; - final hasData = false; - - Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; - - @override - Iterator get iterator => const [].iterator; - - /// Narrows the type down to R if possible. Otherwise throws the [TypeError]. - R as() => this is R ? this as R : throw TypeError(); -} - -class IncompleteRelationship extends Relationship { - IncompleteRelationship( - {Map links = const {}, Map meta = const {}}) - : super(links: links, meta: meta); -} - -class One extends Relationship { - One(Identifier identifier, - {Map links = const {}, Map meta = const {}}) - : identifier = Just(identifier), - super(links: links, meta: meta); - - One.empty( - {Map links = const {}, Map meta = const {}}) - : identifier = Nothing(), - super(links: links, meta: meta); - - @override - final isSingular = true; - - @override - Map toJson() => - {...super.toJson(), 'data': identifier.or(null)}; - - Maybe identifier; - - @override - Iterator get iterator => - identifier.map((_) => [_]).or(const []).iterator; -} - -class Many extends Relationship { - Many(Iterable identifiers, - {Map links = const {}, Map meta = const {}}) - : super(links: links, meta: meta) { - identifiers.forEach((_) => _map[_.key] = _); - } - - final _map = {}; - - @override - final isPlural = true; - - @override - Map toJson() => - {...super.toJson(), 'data': _map.values.toList()}; - - @override - Iterator get iterator => _map.values.iterator; -} - -class Identifier with Identity { - Identifier(this.type, this.id, {Map meta = const {}}) - : meta = Map.unmodifiable(meta ?? {}); - - static Identifier fromJson(dynamic json) { - if (json is Map) { - return Identifier(json['type'], json['id'], meta: json['meta']); - } - throw ArgumentError('A JSON:API identifier must be a JSON object'); - } - - static Identifier fromKey(String key) { - final parts = key.split(Identity.delimiter); - if (parts.length != 2) throw ArgumentError('Invalid key'); - return Identifier(parts.first, parts.last); - } - - @override - final String type; - - @override - final String id; - - final Map meta; - - Map toJson() => - {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; -} - -mixin Identity { - static final delimiter = ':'; - - String get type; - - String get id; - - String get key => '$type$delimiter$id'; - - @override - String toString() => key; -} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart new file mode 100644 index 00000000..f5a82c7c --- /dev/null +++ b/lib/src/document/document.dart @@ -0,0 +1,38 @@ +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +/// A generic response document +class Document { + Document(dynamic json) + : _json = json is Map + ? json + : throw ArgumentError('Invalid JSON'); + + final Map _json; + + bool has(String key) => _json.containsKey(key); + + Maybe get(String key) => Maybe(_json[key]); + + Maybe> meta() => + Maybe(_json['meta']).cast>(); + + Maybe> links() => readPath(['links']).map((_) => + _.map((key, value) => MapEntry(key.toString(), Link.fromJson(value)))); + + Maybe> included() => readPath(['included']) + .map((_) => _.map(ResourceWithIdentity.fromJson).toList()); + + /// Returns the value at the [path] if both are true: + /// - the path exists + /// - the value is of type T + Maybe readPath(List path) => _path(path, Maybe(_json)); + + Maybe _path(List path, Maybe json) { + if (path.isEmpty) throw ArgumentError('Empty path'); + final value = json.flatMap((_) => Maybe(_[path.first])); + if (path.length == 1) return value.cast(); + return _path(path.sublist(1), value.cast()); + } +} diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart new file mode 100644 index 00000000..51338ce1 --- /dev/null +++ b/lib/src/document/error_object.dart @@ -0,0 +1,83 @@ +import 'package:json_api/src/document/link.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject({ + String id = '', + String status = '', + String code = '', + String title = '', + String detail = '', + Map meta = const {}, + String sourceParameter = '', + String sourcePointer = '', + Map links = const {}, + }) : id = id ?? '', + status = status ?? '', + code = code ?? '', + title = title ?? '', + detail = detail ?? '', + sourcePointer = sourcePointer ?? '', + sourceParameter = sourceParameter ?? '', + meta = Map.unmodifiable(meta ?? {}), + links = Map.unmodifiable(links ?? {}); + + static ErrorObject fromJson(dynamic json) { + if (json is Map) { + final source = Maybe(json['source']).cast().or(const {}); + return ErrorObject( + id: Maybe(json['id']).cast().or(''), + status: Maybe(json['status']).cast().or(''), + code: Maybe(json['code']).cast().or(''), + title: Maybe(json['title']).cast().or(''), + detail: Maybe(json['detail']).cast().or(''), + sourceParameter: Maybe(source['parameter']).cast().or(''), + sourcePointer: Maybe(source['pointer']).cast().or(''), + meta: Maybe(json['meta']).cast>().or(const {}), + links: Link.mapFromJson(json['links'])); + } + throw FormatException('A JSON:API error must be a JSON object'); + } + + /// A unique identifier for this particular occurrence of the problem. + /// May be empty. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + /// May be empty. + final String status; + + /// An application-specific error code, expressed as a string value. + /// May be empty. + 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. + /// May be empty. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + /// May be empty. + final String detail; + + /// A JSON Pointer (RFC6901) to the associated entity in the request document, + /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. + final String sourcePointer; + + /// A string indicating which URI query parameter caused the error. + final String sourceParameter; + + /// Meta data. + final Map meta; + + /// Error links. May be empty. + final Map links; +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart new file mode 100644 index 00000000..9438e3bf --- /dev/null +++ b/lib/src/document/identifier.dart @@ -0,0 +1,30 @@ +import 'package:json_api/src/document/identity.dart'; + +class Identifier with Identity { + Identifier(this.type, this.id, {Map meta = const {}}) + : meta = Map.unmodifiable(meta ?? {}); + + static Identifier fromJson(dynamic json) { + if (json is Map) { + return Identifier(json['type'], json['id'], meta: json['meta']); + } + throw FormatException('A JSON:API identifier must be a JSON object'); + } + + static Identifier fromKey(String key) { + final parts = key.split(Identity.delimiter); + if (parts.length != 2) throw ArgumentError('Invalid key'); + return Identifier(parts.first, parts.last); + } + + @override + final String type; + + @override + final String id; + + final Map meta; + + Map toJson() => + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; +} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart new file mode 100644 index 00000000..17eb94f0 --- /dev/null +++ b/lib/src/document/identity.dart @@ -0,0 +1,12 @@ +mixin Identity { + static final delimiter = ':'; + + String get type; + + String get id; + + String get key => '$type$delimiter$id'; + + @override + String toString() => key; +} diff --git a/lib/src/document/identity_collection.dart b/lib/src/document/identity_collection.dart new file mode 100644 index 00000000..a082e3ff --- /dev/null +++ b/lib/src/document/identity_collection.dart @@ -0,0 +1,16 @@ +import 'dart:collection'; + +import 'package:json_api/src/document/identity.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class IdentityCollection with IterableMixin { + IdentityCollection(Iterable resources) + : _map = Map.fromIterable(resources, key: (_) => _.key); + + final Map _map; + + Maybe get(String key) => Maybe(_map[key]); + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/document/incomplete_relationship.dart b/lib/src/document/incomplete_relationship.dart new file mode 100644 index 00000000..8f015d76 --- /dev/null +++ b/lib/src/document/incomplete_relationship.dart @@ -0,0 +1,9 @@ + +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; + +class IncompleteRelationship extends Relationship { + IncompleteRelationship( + {Map links = const {}, Map meta = const {}}) + : super(links: links, meta: meta); +} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart new file mode 100644 index 00000000..52ee7b6a --- /dev/null +++ b/lib/src/document/link.dart @@ -0,0 +1,35 @@ +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri, {Map meta = const {}}) + : meta = Map.unmodifiable(meta ?? const {}) { + ArgumentError.checkNotNull(uri, 'uri'); + } + + final Uri uri; + final Map meta; + + /// Reconstructs the link from the [json] object + static Link fromJson(dynamic json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link( + Maybe(json['href']).cast().map(Uri.parse).orGet(() => Uri()), + meta: Maybe(json['meta']).cast().or(const {})); + } + throw FormatException( + 'A JSON:API link must be a JSON string or a JSON object'); + } + + /// Reconstructs the document's `links` member into a map. + /// Details on the `links` member: https://jsonapi.org/format/#document-links + static Map mapFromJson(dynamic json) => Maybe(json) + .cast() + .map((_) => _.map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))) + .or(const {}); + + @override + String toString() => uri.toString(); +} diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart new file mode 100644 index 00000000..c815661a --- /dev/null +++ b/lib/src/document/many.dart @@ -0,0 +1,20 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; + +class Many extends Relationship { + Many(Iterable identifiers, + {Map links = const {}, Map meta = const {}}) + : super(links: links, meta: meta) { + identifiers.forEach((_) => _map[_.key] = _); + } + + final _map = {}; + + @override + Map toJson() => + {...super.toJson(), 'data': _map.values.toList()}; + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart new file mode 100644 index 00000000..f2d2e933 --- /dev/null +++ b/lib/src/document/one.dart @@ -0,0 +1,28 @@ + +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class One extends Relationship { + One(Identifier identifier, + {Map links = const {}, Map meta = const {}}) + : identifier = Just(identifier), + super(links: links, meta: meta); + + One.empty( + {Map links = const {}, Map meta = const {}}) + : identifier = Nothing(), + super(links: links, meta: meta); + + + @override + Map toJson() => + {...super.toJson(), 'data': identifier.or(null)}; + + Maybe identifier; + + @override + Iterator get iterator => + identifier.map((_) => [_]).or(const []).iterator; +} diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart new file mode 100644 index 00000000..4743c92e --- /dev/null +++ b/lib/src/document/relationship.dart @@ -0,0 +1,51 @@ +import 'dart:collection'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/incomplete_relationship.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; + +abstract class Relationship with IterableMixin { + Relationship( + {Map links = const {}, Map meta = const {}}) + : links = Map.unmodifiable(links ?? {}), + meta = Map.unmodifiable(meta ?? {}); + + /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. + static Relationship fromJson(dynamic json) { + final document = Document(json); + final links = document.links().or(const {}); + final meta = document.meta().or(const {}); + if (document.has('data')) { + final data = document.get('data').or(null); + if (data == null) { + return One.empty(links: links, meta: meta); + } + if (data is Map) { + return One(Identifier.fromJson(data), links: links, meta: meta); + } + if (data is List) { + return Many(data.map(Identifier.fromJson), links: links, meta: meta); + } + throw FormatException('Invalid relationship object'); + } + + return IncompleteRelationship(links: links, meta: meta); + } + + final Map links; + final Map meta; + + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; + + @override + Iterator get iterator => const [].iterator; + + /// Narrows the type down to R if possible. Otherwise throws the [TypeError]. + R as() => this is R ? this : throw TypeError(); +} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart new file mode 100644 index 00000000..ce31c168 --- /dev/null +++ b/lib/src/document/resource.dart @@ -0,0 +1,23 @@ +import 'package:json_api/src/document/relationship.dart'; + +class Resource { + Resource(this.type, + {Map meta = const {}, + Map attributes = const {}, + Map relationships = const {}}) + : meta = Map.unmodifiable(meta ?? {}), + relationships = Map.unmodifiable(relationships ?? {}), + attributes = Map.unmodifiable(attributes ?? {}); + + final String type; + final Map meta; + final Map attributes; + final Map relationships; + + Map toJson() => { + 'type': type, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/resource_with_identity.dart b/lib/src/document/resource_with_identity.dart new file mode 100644 index 00000000..edff7025 --- /dev/null +++ b/lib/src/document/resource_with_identity.dart @@ -0,0 +1,56 @@ +import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class ResourceWithIdentity extends Resource with Identity { + ResourceWithIdentity(this.type, this.id, + {Map links = const {}, + Map meta = const {}, + Map attributes = const {}, + Map relationships = const {}}) + : links = Map.unmodifiable(links ?? {}), + super(type, + attributes: attributes, relationships: relationships, meta: meta); + + static ResourceWithIdentity fromJson(dynamic json) { + if (json is Map) { + return ResourceWithIdentity( + Maybe(json['type']) + .cast() + .orThrow(() => ArgumentError('Invalid type')), + Maybe(json['id']) + .cast() + .orThrow(() => ArgumentError('Invalid id')), + attributes: Maybe(json['attributes']).cast().or(const {}), + relationships: Maybe(json['relationships']) + .cast() + .map((t) => t.map((key, value) => + MapEntry(key.toString(), Relationship.fromJson(value)))) + .orGet(() => {}), + links: Link.mapFromJson(json['links']), + meta: json['meta']); + } + throw FormatException('A JSON:API resource must be a JSON object'); + } + + @override + final String type; + @override + final String id; + final Map links; + + Maybe many(String key) => Maybe(relationships[key]).cast(); + + Maybe one(String key) => Maybe(relationships[key]).cast(); + + @override + Map toJson() => { + 'id': id, + ...super.toJson(), + if (links.isNotEmpty) 'links': links, + }; +} diff --git a/lib/src/json_api_client.dart b/lib/src/json_api_client.dart index f8980710..c8fb0e24 100644 --- a/lib/src/json_api_client.dart +++ b/lib/src/json_api_client.dart @@ -1,5 +1,19 @@ import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/response/create_resource.dart'; +import 'package:json_api/src/response/delete_resource.dart'; +import 'package:json_api/src/response/fetch_collection.dart'; +import 'package:json_api/src/response/fetch_primary_resource.dart'; +import 'package:json_api/src/response/fetch_related_resource.dart'; +import 'package:json_api/src/response/fetch_relationship.dart'; +import 'package:json_api/src/response/request_failure.dart'; +import 'package:json_api/src/response/update_relationship.dart'; +import 'package:json_api/src/response/update_resource.dart'; import 'package:json_api_common/http.dart'; import 'package:json_api_common/url_design.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; @@ -19,7 +33,7 @@ class JsonApiClient { Iterable sort, Map page, Map query}) async => - FetchCollection(await call( + FetchCollection.decode(await call( JsonApiRequest('GET', headers: headers, include: include, @@ -38,7 +52,7 @@ class JsonApiClient { Iterable sort, Map page, Map query}) async => - FetchCollection(await call( + FetchCollection.decode(await call( JsonApiRequest('GET', headers: headers, include: include, @@ -54,7 +68,7 @@ class JsonApiClient { Iterable include, Map> fields, Map query}) async => - FetchPrimaryResource(await call( + FetchPrimaryResource.decode(await call( JsonApiRequest('GET', headers: headers, include: include, fields: fields, query: query), _url.resource(type, id))); @@ -66,7 +80,7 @@ class JsonApiClient { Iterable include, Map> fields, Map query}) async => - FetchRelatedResource(await call( + FetchRelatedResource.decode(await call( JsonApiRequest('GET', headers: headers, include: include, fields: fields, query: query), _url.related(type, id, relationship))); @@ -75,7 +89,7 @@ class JsonApiClient { Future> fetchRelationship( String type, String id, String relationship, {Map headers, Map query}) async => - FetchRelationship(await call( + FetchRelationship.decode(await call( JsonApiRequest('GET', headers: headers, query: query), _url.relationship(type, id, relationship))); @@ -86,7 +100,7 @@ class JsonApiClient { Map one = const {}, Map> many = const {}, Map headers}) async => - CreateResource(await call( + CreateResource.decode(await call( JsonApiRequest('POST', headers: headers, document: { 'data': Resource(type, attributes: attributes, @@ -102,7 +116,7 @@ class JsonApiClient { Map one = const {}, Map> many = const {}, Map headers}) async => - UpdateResource(await call( + UpdateResource.decode(await call( JsonApiRequest('POST', headers: headers, document: _resource(type, id, attributes, one, many)), @@ -111,7 +125,7 @@ class JsonApiClient { /// Deletes the resource by [type] and [id]. Future deleteResource(String type, String id, {Map headers}) async => - DeleteResource(await call( + DeleteResource.decode(await call( JsonApiRequest('DELETE', headers: headers), _url.resource(type, id))); /// Updates the resource by [type] and [id]. @@ -120,7 +134,7 @@ class JsonApiClient { Map one = const {}, Map> many = const {}, Map headers}) async => - UpdateResource(await call( + UpdateResource.decode(await call( JsonApiRequest('PATCH', headers: headers, document: _resource(type, id, attributes, one, many)), @@ -130,7 +144,7 @@ class JsonApiClient { Future> replaceOne( String type, String id, String relationship, String identifier, {Map headers}) async => - UpdateRelationship(await call( + UpdateRelationship.decode(await call( JsonApiRequest('PATCH', headers: headers, document: One(Identifier.fromKey(identifier))), _url.relationship(type, id, relationship))); @@ -139,7 +153,7 @@ class JsonApiClient { Future> deleteOne( String type, String id, String relationship, {Map headers}) async => - UpdateRelationship(await call( + UpdateRelationship.decode(await call( JsonApiRequest('PATCH', headers: headers, document: One.empty()), _url.relationship(type, id, relationship))); @@ -147,7 +161,7 @@ class JsonApiClient { Future> deleteMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async => - UpdateRelationship(await call( + UpdateRelationship.decode(await call( JsonApiRequest('DELETE', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), @@ -157,7 +171,7 @@ class JsonApiClient { Future> replaceMany(String type, String id, String relationship, Iterable identifiers, {Map headers}) async => - UpdateRelationship(await call( + UpdateRelationship.decode(await call( JsonApiRequest('PATCH', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), @@ -167,7 +181,7 @@ class JsonApiClient { Future> addMany(String type, String id, String relationship, Iterable identifiers, {Map headers = const {}}) async => - UpdateRelationship(await call( + UpdateRelationship.decode(await call( JsonApiRequest('POST', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), @@ -185,7 +199,7 @@ class JsonApiClient { body: request.body, headers: request.headers)); if (StatusCode(response.statusCode).isFailed) { - throw RequestFailure.fromHttp(response); + throw RequestFailure.decode(response); } return response; } diff --git a/lib/src/response.dart b/lib/src/response.dart deleted file mode 100644 index 7670415f..00000000 --- a/lib/src/response.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document.dart'; -import 'package:json_api_common/http.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class FetchCollection with IterableMixin { - factory FetchCollection(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchCollection._(http, - resources: IdentityCollection(document.data - .cast() - .map((_) => _.map(ResourceWithIdentity.fromJson)) - .or(const [])), - included: IdentityCollection(document.included.or([])), - links: document.links.or(const {})); - } - - FetchCollection._(this.http, - {IdentityCollection resources, - IdentityCollection included, - Map links = const {}}) - : resources = resources ?? IdentityCollection(const []), - links = Map.unmodifiable(links ?? const {}), - included = included ?? IdentityCollection(const []); - - final HttpResponse http; - final IdentityCollection resources; - final IdentityCollection included; - final Map links; - - @override - Iterator get iterator => resources.iterator; -} - -class FetchPrimaryResource { - factory FetchPrimaryResource(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchPrimaryResource._( - http, - document.data - .map(ResourceWithIdentity.fromJson) - .orThrow(() => ArgumentError('Invalid response')), - included: IdentityCollection(document.included.or([])), - links: document.links.or(const {})); - } - - FetchPrimaryResource._(this.http, this.resource, - {IdentityCollection included, Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}), - included = included ?? IdentityCollection(const []); - - final HttpResponse http; - final ResourceWithIdentity resource; - final IdentityCollection included; - final Map links; -} - -class CreateResource { - factory CreateResource(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return CreateResource._( - http, - document.data - .map(ResourceWithIdentity.fromJson) - .orThrow(() => ArgumentError('Invalid response')), - links: document.links.or(const {})); - } - - CreateResource._(this.http, this.resource, - {Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}); - - final HttpResponse http; - final Map links; - final ResourceWithIdentity resource; -} - -class UpdateResource { - factory UpdateResource(HttpResponse http) { - if (http.body.isEmpty) { - return UpdateResource._empty(http); - } - final document = Document(jsonDecode(http.body)); - return UpdateResource._( - http, - document.data - .map(ResourceWithIdentity.fromJson) - .orThrow(() => ArgumentError('Invalid response')), - links: document.links.or(const {})); - } - - UpdateResource._(this.http, ResourceWithIdentity resource, - {Map links = const {}}) - : resource = Just(resource), - links = Map.unmodifiable(links ?? const {}); - - UpdateResource._empty(this.http) - : resource = Nothing(), - links = const {}; - - final HttpResponse http; - final Map links; - final Maybe resource; -} - -class DeleteResource { - DeleteResource(this.http) - : meta = http.body.isEmpty - ? const {} - : Document(jsonDecode(http.body)).meta.or(const {}); - - final HttpResponse http; - final Map meta; -} - -class FetchRelationship { - FetchRelationship(this.http) - : relationship = Relationship.fromJson(jsonDecode(http.body)).as(); - - final HttpResponse http; - final R relationship; -} - -class UpdateRelationship { - UpdateRelationship(this.http) - : relationship = Maybe(http.body) - .filter((_) => _.isNotEmpty) - .map(jsonDecode) - .map(Relationship.fromJson) - .map((_) => _.as()); - - final HttpResponse http; - final Maybe relationship; -} - -class FetchRelatedResource { - factory FetchRelatedResource(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchRelatedResource._( - http, document.data.map(ResourceWithIdentity.fromJson), - included: IdentityCollection(document.included.or([])), - links: document.links.or(const {})); - } - - FetchRelatedResource._(this.http, this.resource, - {IdentityCollection included, Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}), - included = included ?? IdentityCollection(const []); - - final Maybe resource; - final HttpResponse http; - final IdentityCollection included; - final Map links; -} - -class RequestFailure { - RequestFailure(this.http, {Iterable errors = const []}) - : errors = List.unmodifiable(errors ?? const []); - final List errors; - - static RequestFailure fromHttp(HttpResponse http) { - if (http.body.isEmpty || - http.headers['content-type'] != ContentType.jsonApi) { - return RequestFailure(http); - } - - return RequestFailure(http, - errors: Maybe(jsonDecode(http.body)) - .cast() - .flatMap((_) => Maybe(_['errors'])) - .cast() - .map((_) => _.map(ErrorObject.fromJson)) - .or([])); - } - - final HttpResponse http; -} diff --git a/lib/src/response/create_resource.dart b/lib/src/response/create_resource.dart new file mode 100644 index 00000000..afa6253b --- /dev/null +++ b/lib/src/response/create_resource.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:json_api/json_api.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/http.dart'; + +class CreateResource { + CreateResource(this.resource, {Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}); + + static CreateResource decode(HttpResponse http) { + final document = Document(jsonDecode(http.body)); + return CreateResource( + document + .get('data') + .map(ResourceWithIdentity.fromJson) + .orThrow(() => FormatException('Invalid response')), + links: document.links().or(const {})); + } + + final Map links; + final ResourceWithIdentity resource; +} diff --git a/lib/src/response/delete_resource.dart b/lib/src/response/delete_resource.dart new file mode 100644 index 00000000..714db65a --- /dev/null +++ b/lib/src/response/delete_resource.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api_common/http.dart'; + +class DeleteResource { + DeleteResource({Map meta = const {}}) + : meta = Map.unmodifiable(meta ?? const {}); + + static DeleteResource decode(HttpResponse http) => DeleteResource( + meta: http.body.isEmpty + ? const {} + : Document(jsonDecode(http.body)).meta().or(const {})); + + final Map meta; +} diff --git a/lib/src/response/fetch_collection.dart b/lib/src/response/fetch_collection.dart new file mode 100644 index 00000000..e12e5c66 --- /dev/null +++ b/lib/src/response/fetch_collection.dart @@ -0,0 +1,37 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identity_collection.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/http.dart'; + +class FetchCollection with IterableMixin { + FetchCollection( + {Iterable resources = const [], + Iterable included = const [], + Map links = const {}}) + : resources = resources ?? IdentityCollection(const []), + links = Map.unmodifiable(links ?? const {}), + included = included ?? IdentityCollection(const []); + + static FetchCollection decode(HttpResponse http) { + final document = Document(jsonDecode(http.body)); + return FetchCollection( + resources: IdentityCollection(document + .get('data') + .cast() + .map((_) => _.map(ResourceWithIdentity.fromJson)) + .or(const [])), + included: IdentityCollection(document.included().or([])), + links: document.links().or(const {})); + } + + final IdentityCollection resources; + final IdentityCollection included; + final Map links; + + @override + Iterator get iterator => resources.iterator; +} diff --git a/lib/src/response/fetch_primary_resource.dart b/lib/src/response/fetch_primary_resource.dart new file mode 100644 index 00000000..f5a941db --- /dev/null +++ b/lib/src/response/fetch_primary_resource.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identity_collection.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/http.dart'; + +class FetchPrimaryResource { + FetchPrimaryResource(this.resource, + {Iterable included = const [], + Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}), + included = IdentityCollection(included ?? const []); + + static FetchPrimaryResource decode(HttpResponse http) { + final document = Document(jsonDecode(http.body)); + return FetchPrimaryResource( + document + .get('data') + .map(ResourceWithIdentity.fromJson) + .orThrow(() => ArgumentError('Invalid response')), + included: IdentityCollection(document.included().or([])), + links: document.links().or(const {})); + } + + final ResourceWithIdentity resource; + final IdentityCollection included; + final Map links; +} diff --git a/lib/src/response/fetch_related_resource.dart b/lib/src/response/fetch_related_resource.dart new file mode 100644 index 00000000..c82ab3e7 --- /dev/null +++ b/lib/src/response/fetch_related_resource.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identity_collection.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/http.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class FetchRelatedResource { + FetchRelatedResource(this.resource, + {IdentityCollection included, Map links = const {}}) + : links = Map.unmodifiable(links ?? const {}), + included = included ?? IdentityCollection(const []); + + static FetchRelatedResource decode(HttpResponse http) { + final document = Document(jsonDecode(http.body)); + return FetchRelatedResource( + document.get('data').map(ResourceWithIdentity.fromJson), + included: IdentityCollection(document.included().or([])), + links: document.links().or(const {})); + } + + final Maybe resource; + final IdentityCollection included; + final Map links; +} diff --git a/lib/src/response/fetch_relationship.dart b/lib/src/response/fetch_relationship.dart new file mode 100644 index 00000000..6642ed9a --- /dev/null +++ b/lib/src/response/fetch_relationship.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api_common/http.dart'; + +class FetchRelationship { + FetchRelationship(this.relationship); + + static FetchRelationship decode( + HttpResponse http) => + FetchRelationship(Relationship.fromJson(jsonDecode(http.body)).as()); + + final R relationship; +} diff --git a/lib/src/response/request_failure.dart b/lib/src/response/request_failure.dart new file mode 100644 index 00000000..f540bf9b --- /dev/null +++ b/lib/src/response/request_failure.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +import 'package:json_api/json_api.dart'; +import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api_common/http.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class RequestFailure { + RequestFailure(this.http, {Iterable errors = const []}) + : errors = List.unmodifiable(errors ?? const []); + + static RequestFailure decode(HttpResponse http) { + if (http.body.isEmpty || + http.headers['content-type'] != ContentType.jsonApi) { + return RequestFailure(http); + } + + return RequestFailure(http, + errors: Just(http.body) + .filter((_) => _.isNotEmpty) + .map(jsonDecode) + .cast() + .flatMap((_) => Maybe(_['errors'])) + .cast() + .map((_) => _.map(ErrorObject.fromJson)) + .or([])); + } + + final List errors; + + final HttpResponse http; +} diff --git a/lib/src/response/update_relationship.dart b/lib/src/response/update_relationship.dart new file mode 100644 index 00000000..2c9f5e59 --- /dev/null +++ b/lib/src/response/update_relationship.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api_common/http.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class UpdateRelationship { + UpdateRelationship({R relationship}) : relationship = Maybe(relationship); + + static UpdateRelationship decode( + HttpResponse http) => + Maybe(http.body) + .filter((_) => _.isNotEmpty) + .map(jsonDecode) + .map(Relationship.fromJson) + .cast() + .map((_) => UpdateRelationship(relationship: _)) + .orGet(() => UpdateRelationship()); + + final Maybe relationship; +} diff --git a/lib/src/response/update_resource.dart b/lib/src/response/update_resource.dart new file mode 100644 index 00000000..23700c93 --- /dev/null +++ b/lib/src/response/update_resource.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/http.dart'; +import 'package:maybe_just_nothing/maybe_just_nothing.dart'; + +class UpdateResource { + UpdateResource(ResourceWithIdentity resource, + {Map links = const {}}) + : resource = Just(resource), + links = Map.unmodifiable(links ?? const {}); + + UpdateResource.empty() + : resource = Nothing(), + links = const {}; + + static UpdateResource decode(HttpResponse http) { + if (http.body.isEmpty) { + return UpdateResource.empty(); + } + final document = Document(jsonDecode(http.body)); + return UpdateResource( + document + .get('data') + .map(ResourceWithIdentity.fromJson) + .orThrow(() => ArgumentError('Invalid response')), + links: document.links().or(const {})); + } + + final Map links; + final Maybe resource; +} From 8bbe8ee056f92ce96ce4c950f977836d5d932b1e Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 19 Jun 2020 16:52:55 -0700 Subject: [PATCH 76/99] wip --- lib/json_api.dart | 16 +--- lib/src/{document => }/document.dart | 7 +- lib/src/document/error_object.dart | 83 ------------------- lib/src/document/identifier.dart | 30 ------- lib/src/document/identity.dart | 12 --- lib/src/document/incomplete_relationship.dart | 9 -- lib/src/document/link.dart | 35 -------- lib/src/document/many.dart | 20 ----- lib/src/document/one.dart | 28 ------- lib/src/document/relationship.dart | 51 ------------ lib/src/document/resource.dart | 23 ----- lib/src/document/resource_with_identity.dart | 56 ------------- .../{document => }/identity_collection.dart | 2 +- lib/src/json_api_client.dart | 35 ++++---- lib/src/response/create_resource.dart | 10 +-- lib/src/response/delete_resource.dart | 2 +- lib/src/response/fetch_collection.dart | 21 +++-- lib/src/response/fetch_primary_resource.dart | 13 ++- lib/src/response/fetch_related_resource.dart | 12 ++- lib/src/response/fetch_relationship.dart | 2 +- lib/src/response/request_failure.dart | 1 - lib/src/response/update_relationship.dart | 2 +- lib/src/response/update_resource.dart | 14 ++-- pubspec.yaml | 3 +- test/client_test.dart | 14 ++-- test/responses.dart | 4 +- 26 files changed, 68 insertions(+), 437 deletions(-) rename lib/src/{document => }/document.dart (80%) delete mode 100644 lib/src/document/error_object.dart delete mode 100644 lib/src/document/identifier.dart delete mode 100644 lib/src/document/identity.dart delete mode 100644 lib/src/document/incomplete_relationship.dart delete mode 100644 lib/src/document/link.dart delete mode 100644 lib/src/document/many.dart delete mode 100644 lib/src/document/one.dart delete mode 100644 lib/src/document/relationship.dart delete mode 100644 lib/src/document/resource.dart delete mode 100644 lib/src/document/resource_with_identity.dart rename lib/src/{document => }/identity_collection.dart (88%) diff --git a/lib/json_api.dart b/lib/json_api.dart index f91bf6f1..281b2db1 100644 --- a/lib/json_api.dart +++ b/lib/json_api.dart @@ -2,18 +2,7 @@ library json_api; export 'package:json_api/src/content_type.dart'; export 'package:json_api/src/dart_http.dart'; -export 'package:json_api/src/document/document.dart'; -export 'package:json_api/src/document/error_object.dart'; -export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identity.dart'; -export 'package:json_api/src/document/incomplete_relationship.dart'; -export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/many.dart'; -export 'package:json_api/src/document/one.dart'; -export 'package:json_api/src/document/relationship.dart'; -export 'package:json_api/src/document/resource.dart'; -export 'package:json_api/src/document/resource_with_identity.dart'; -export 'package:json_api/src/json_api_client.dart'; +export 'package:json_api/src/document.dart'; export 'package:json_api/src/json_api_client.dart'; export 'package:json_api/src/request.dart'; export 'package:json_api/src/response/create_resource.dart'; @@ -26,3 +15,6 @@ export 'package:json_api/src/response/request_failure.dart'; export 'package:json_api/src/response/update_relationship.dart'; export 'package:json_api/src/response/update_relationship.dart'; export 'package:json_api/src/status_code.dart'; +export 'package:json_api_common/document.dart'; +export 'package:json_api_common/http.dart'; +export 'package:json_api_common/url_design.dart'; diff --git a/lib/src/document/document.dart b/lib/src/document.dart similarity index 80% rename from lib/src/document/document.dart rename to lib/src/document.dart index f5a82c7c..37bfee62 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document.dart @@ -1,5 +1,4 @@ -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api_common/document.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; /// A generic response document @@ -21,8 +20,8 @@ class Document { Maybe> links() => readPath(['links']).map((_) => _.map((key, value) => MapEntry(key.toString(), Link.fromJson(value)))); - Maybe> included() => readPath(['included']) - .map((_) => _.map(ResourceWithIdentity.fromJson).toList()); + Maybe> included() => readPath(['included']) + .map((_) => _.map(Resource.fromJson).toList()); /// Returns the value at the [path] if both are true: /// - the path exists diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart deleted file mode 100644 index 51338ce1..00000000 --- a/lib/src/document/error_object.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:json_api/src/document/link.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// [ErrorObject] represents an error occurred on the server. -/// -/// More on this: https://jsonapi.org/format/#errors -class ErrorObject { - /// Creates an instance of a JSON:API Error. - /// The [links] map may contain custom links. The about link - /// passed through the [links['about']] argument takes precedence and will overwrite - /// the `about` key in [links]. - ErrorObject({ - String id = '', - String status = '', - String code = '', - String title = '', - String detail = '', - Map meta = const {}, - String sourceParameter = '', - String sourcePointer = '', - Map links = const {}, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - sourcePointer = sourcePointer ?? '', - sourceParameter = sourceParameter ?? '', - meta = Map.unmodifiable(meta ?? {}), - links = Map.unmodifiable(links ?? {}); - - static ErrorObject fromJson(dynamic json) { - if (json is Map) { - final source = Maybe(json['source']).cast().or(const {}); - return ErrorObject( - id: Maybe(json['id']).cast().or(''), - status: Maybe(json['status']).cast().or(''), - code: Maybe(json['code']).cast().or(''), - title: Maybe(json['title']).cast().or(''), - detail: Maybe(json['detail']).cast().or(''), - sourceParameter: Maybe(source['parameter']).cast().or(''), - sourcePointer: Maybe(source['pointer']).cast().or(''), - meta: Maybe(json['meta']).cast>().or(const {}), - links: Link.mapFromJson(json['links'])); - } - throw FormatException('A JSON:API error must be a JSON object'); - } - - /// A unique identifier for this particular occurrence of the problem. - /// May be empty. - final String id; - - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. - final String status; - - /// An application-specific error code, expressed as a string value. - /// May be empty. - 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. - /// May be empty. - final String title; - - /// A human-readable explanation specific to this occurrence of the problem. - /// Like title, this field’s value can be localized. - /// May be empty. - final String detail; - - /// A JSON Pointer (RFC6901) to the associated entity in the request document, - /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. - final String sourcePointer; - - /// A string indicating which URI query parameter caused the error. - final String sourceParameter; - - /// Meta data. - final Map meta; - - /// Error links. May be empty. - final Map links; -} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart deleted file mode 100644 index 9438e3bf..00000000 --- a/lib/src/document/identifier.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/src/document/identity.dart'; - -class Identifier with Identity { - Identifier(this.type, this.id, {Map meta = const {}}) - : meta = Map.unmodifiable(meta ?? {}); - - static Identifier fromJson(dynamic json) { - if (json is Map) { - return Identifier(json['type'], json['id'], meta: json['meta']); - } - throw FormatException('A JSON:API identifier must be a JSON object'); - } - - static Identifier fromKey(String key) { - final parts = key.split(Identity.delimiter); - if (parts.length != 2) throw ArgumentError('Invalid key'); - return Identifier(parts.first, parts.last); - } - - @override - final String type; - - @override - final String id; - - final Map meta; - - Map toJson() => - {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; -} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart deleted file mode 100644 index 17eb94f0..00000000 --- a/lib/src/document/identity.dart +++ /dev/null @@ -1,12 +0,0 @@ -mixin Identity { - static final delimiter = ':'; - - String get type; - - String get id; - - String get key => '$type$delimiter$id'; - - @override - String toString() => key; -} diff --git a/lib/src/document/incomplete_relationship.dart b/lib/src/document/incomplete_relationship.dart deleted file mode 100644 index 8f015d76..00000000 --- a/lib/src/document/incomplete_relationship.dart +++ /dev/null @@ -1,9 +0,0 @@ - -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/relationship.dart'; - -class IncompleteRelationship extends Relationship { - IncompleteRelationship( - {Map links = const {}, Map meta = const {}}) - : super(links: links, meta: meta); -} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart deleted file mode 100644 index 52ee7b6a..00000000 --- a/lib/src/document/link.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link { - Link(this.uri, {Map meta = const {}}) - : meta = Map.unmodifiable(meta ?? const {}) { - ArgumentError.checkNotNull(uri, 'uri'); - } - - final Uri uri; - final Map meta; - - /// Reconstructs the link from the [json] object - static Link fromJson(dynamic json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - return Link( - Maybe(json['href']).cast().map(Uri.parse).orGet(() => Uri()), - meta: Maybe(json['meta']).cast().or(const {})); - } - throw FormatException( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(dynamic json) => Maybe(json) - .cast() - .map((_) => _.map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))) - .or(const {}); - - @override - String toString() => uri.toString(); -} diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart deleted file mode 100644 index c815661a..00000000 --- a/lib/src/document/many.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/relationship.dart'; - -class Many extends Relationship { - Many(Iterable identifiers, - {Map links = const {}, Map meta = const {}}) - : super(links: links, meta: meta) { - identifiers.forEach((_) => _map[_.key] = _); - } - - final _map = {}; - - @override - Map toJson() => - {...super.toJson(), 'data': _map.values.toList()}; - - @override - Iterator get iterator => _map.values.iterator; -} diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart deleted file mode 100644 index f2d2e933..00000000 --- a/lib/src/document/one.dart +++ /dev/null @@ -1,28 +0,0 @@ - -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class One extends Relationship { - One(Identifier identifier, - {Map links = const {}, Map meta = const {}}) - : identifier = Just(identifier), - super(links: links, meta: meta); - - One.empty( - {Map links = const {}, Map meta = const {}}) - : identifier = Nothing(), - super(links: links, meta: meta); - - - @override - Map toJson() => - {...super.toJson(), 'data': identifier.or(null)}; - - Maybe identifier; - - @override - Iterator get iterator => - identifier.map((_) => [_]).or(const []).iterator; -} diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart deleted file mode 100644 index 4743c92e..00000000 --- a/lib/src/document/relationship.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/incomplete_relationship.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/many.dart'; -import 'package:json_api/src/document/one.dart'; - -abstract class Relationship with IterableMixin { - Relationship( - {Map links = const {}, Map meta = const {}}) - : links = Map.unmodifiable(links ?? {}), - meta = Map.unmodifiable(meta ?? {}); - - /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(dynamic json) { - final document = Document(json); - final links = document.links().or(const {}); - final meta = document.meta().or(const {}); - if (document.has('data')) { - final data = document.get('data').or(null); - if (data == null) { - return One.empty(links: links, meta: meta); - } - if (data is Map) { - return One(Identifier.fromJson(data), links: links, meta: meta); - } - if (data is List) { - return Many(data.map(Identifier.fromJson), links: links, meta: meta); - } - throw FormatException('Invalid relationship object'); - } - - return IncompleteRelationship(links: links, meta: meta); - } - - final Map links; - final Map meta; - - Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (meta.isNotEmpty) 'meta': meta, - }; - - @override - Iterator get iterator => const [].iterator; - - /// Narrows the type down to R if possible. Otherwise throws the [TypeError]. - R as() => this is R ? this : throw TypeError(); -} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart deleted file mode 100644 index ce31c168..00000000 --- a/lib/src/document/resource.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/document/relationship.dart'; - -class Resource { - Resource(this.type, - {Map meta = const {}, - Map attributes = const {}, - Map relationships = const {}}) - : meta = Map.unmodifiable(meta ?? {}), - relationships = Map.unmodifiable(relationships ?? {}), - attributes = Map.unmodifiable(attributes ?? {}); - - final String type; - final Map meta; - final Map attributes; - final Map relationships; - - Map toJson() => { - 'type': type, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/resource_with_identity.dart b/lib/src/document/resource_with_identity.dart deleted file mode 100644 index edff7025..00000000 --- a/lib/src/document/resource_with_identity.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:json_api/src/document/identity.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/many.dart'; -import 'package:json_api/src/document/one.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class ResourceWithIdentity extends Resource with Identity { - ResourceWithIdentity(this.type, this.id, - {Map links = const {}, - Map meta = const {}, - Map attributes = const {}, - Map relationships = const {}}) - : links = Map.unmodifiable(links ?? {}), - super(type, - attributes: attributes, relationships: relationships, meta: meta); - - static ResourceWithIdentity fromJson(dynamic json) { - if (json is Map) { - return ResourceWithIdentity( - Maybe(json['type']) - .cast() - .orThrow(() => ArgumentError('Invalid type')), - Maybe(json['id']) - .cast() - .orThrow(() => ArgumentError('Invalid id')), - attributes: Maybe(json['attributes']).cast().or(const {}), - relationships: Maybe(json['relationships']) - .cast() - .map((t) => t.map((key, value) => - MapEntry(key.toString(), Relationship.fromJson(value)))) - .orGet(() => {}), - links: Link.mapFromJson(json['links']), - meta: json['meta']); - } - throw FormatException('A JSON:API resource must be a JSON object'); - } - - @override - final String type; - @override - final String id; - final Map links; - - Maybe many(String key) => Maybe(relationships[key]).cast(); - - Maybe one(String key) => Maybe(relationships[key]).cast(); - - @override - Map toJson() => { - 'id': id, - ...super.toJson(), - if (links.isNotEmpty) 'links': links, - }; -} diff --git a/lib/src/document/identity_collection.dart b/lib/src/identity_collection.dart similarity index 88% rename from lib/src/document/identity_collection.dart rename to lib/src/identity_collection.dart index a082e3ff..fcd90a13 100644 --- a/lib/src/document/identity_collection.dart +++ b/lib/src/identity_collection.dart @@ -1,6 +1,6 @@ import 'dart:collection'; -import 'package:json_api/src/document/identity.dart'; +import 'package:json_api_common/document.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; class IdentityCollection with IterableMixin { diff --git a/lib/src/json_api_client.dart b/lib/src/json_api_client.dart index c8fb0e24..92d7bc86 100644 --- a/lib/src/json_api_client.dart +++ b/lib/src/json_api_client.dart @@ -1,10 +1,4 @@ import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/many.dart'; -import 'package:json_api/src/document/one.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; import 'package:json_api/src/response/create_resource.dart'; import 'package:json_api/src/response/delete_resource.dart'; import 'package:json_api/src/response/fetch_collection.dart'; @@ -14,6 +8,7 @@ import 'package:json_api/src/response/fetch_relationship.dart'; import 'package:json_api/src/response/request_failure.dart'; import 'package:json_api/src/response/update_relationship.dart'; import 'package:json_api/src/response/update_resource.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; import 'package:json_api_common/url_design.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; @@ -34,7 +29,7 @@ class JsonApiClient { Map page, Map query}) async => FetchCollection.decode(await call( - JsonApiRequest('GET', + JsonApiRequest('get', headers: headers, include: include, fields: fields, @@ -53,7 +48,7 @@ class JsonApiClient { Map page, Map query}) async => FetchCollection.decode(await call( - JsonApiRequest('GET', + JsonApiRequest('get', headers: headers, include: include, fields: fields, @@ -69,7 +64,7 @@ class JsonApiClient { Map> fields, Map query}) async => FetchPrimaryResource.decode(await call( - JsonApiRequest('GET', + JsonApiRequest('get', headers: headers, include: include, fields: fields, query: query), _url.resource(type, id))); @@ -81,7 +76,7 @@ class JsonApiClient { Map> fields, Map query}) async => FetchRelatedResource.decode(await call( - JsonApiRequest('GET', + JsonApiRequest('get', headers: headers, include: include, fields: fields, query: query), _url.related(type, id, relationship))); @@ -90,7 +85,7 @@ class JsonApiClient { String type, String id, String relationship, {Map headers, Map query}) async => FetchRelationship.decode(await call( - JsonApiRequest('GET', headers: headers, query: query), + JsonApiRequest('get', headers: headers, query: query), _url.relationship(type, id, relationship))); /// Creates a new resource on the server. @@ -102,7 +97,7 @@ class JsonApiClient { Map headers}) async => CreateResource.decode(await call( JsonApiRequest('POST', headers: headers, document: { - 'data': Resource(type, + 'data': NewResource(type, attributes: attributes, relationships: _relationships(one, many)) .toJson() @@ -117,7 +112,7 @@ class JsonApiClient { Map> many = const {}, Map headers}) async => UpdateResource.decode(await call( - JsonApiRequest('POST', + JsonApiRequest('post', headers: headers, document: _resource(type, id, attributes, one, many)), _url.collection(type))); @@ -135,7 +130,7 @@ class JsonApiClient { Map> many = const {}, Map headers}) async => UpdateResource.decode(await call( - JsonApiRequest('PATCH', + JsonApiRequest('patch', headers: headers, document: _resource(type, id, attributes, one, many)), _url.resource(type, id))); @@ -145,7 +140,7 @@ class JsonApiClient { String type, String id, String relationship, String identifier, {Map headers}) async => UpdateRelationship.decode(await call( - JsonApiRequest('PATCH', + JsonApiRequest('patch', headers: headers, document: One(Identifier.fromKey(identifier))), _url.relationship(type, id, relationship))); @@ -154,7 +149,7 @@ class JsonApiClient { String type, String id, String relationship, {Map headers}) async => UpdateRelationship.decode(await call( - JsonApiRequest('PATCH', headers: headers, document: One.empty()), + JsonApiRequest('patch', headers: headers, document: One.empty()), _url.relationship(type, id, relationship))); /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. @@ -162,7 +157,7 @@ class JsonApiClient { String relationship, Iterable identifiers, {Map headers}) async => UpdateRelationship.decode(await call( - JsonApiRequest('DELETE', + JsonApiRequest('delete', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); @@ -172,7 +167,7 @@ class JsonApiClient { String relationship, Iterable identifiers, {Map headers}) async => UpdateRelationship.decode(await call( - JsonApiRequest('PATCH', + JsonApiRequest('patch', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); @@ -182,7 +177,7 @@ class JsonApiClient { String relationship, Iterable identifiers, {Map headers = const {}}) async => UpdateRelationship.decode(await call( - JsonApiRequest('POST', + JsonApiRequest('post', headers: headers, document: Many(identifiers.map(Identifier.fromKey))), _url.relationship(type, id, relationship))); @@ -207,7 +202,7 @@ class JsonApiClient { Object _resource(String type, String id, Map attributes, Map one, Map> many) => { - 'data': ResourceWithIdentity(type, id, + 'data': Resource(type, id, attributes: attributes, relationships: _relationships(one, many)) .toJson() diff --git a/lib/src/response/create_resource.dart b/lib/src/response/create_resource.dart index afa6253b..5663e83f 100644 --- a/lib/src/response/create_resource.dart +++ b/lib/src/response/create_resource.dart @@ -1,10 +1,8 @@ import 'dart:convert'; import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identity.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; class CreateResource { @@ -16,11 +14,11 @@ class CreateResource { return CreateResource( document .get('data') - .map(ResourceWithIdentity.fromJson) + .map(Resource.fromJson) .orThrow(() => FormatException('Invalid response')), links: document.links().or(const {})); } final Map links; - final ResourceWithIdentity resource; + final Resource resource; } diff --git a/lib/src/response/delete_resource.dart b/lib/src/response/delete_resource.dart index 714db65a..5dd93951 100644 --- a/lib/src/response/delete_resource.dart +++ b/lib/src/response/delete_resource.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document.dart'; import 'package:json_api_common/http.dart'; class DeleteResource { diff --git a/lib/src/response/fetch_collection.dart b/lib/src/response/fetch_collection.dart index e12e5c66..8efe295d 100644 --- a/lib/src/response/fetch_collection.dart +++ b/lib/src/response/fetch_collection.dart @@ -1,16 +1,15 @@ import 'dart:collection'; import 'dart:convert'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identity_collection.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api/src/identity_collection.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; -class FetchCollection with IterableMixin { +class FetchCollection with IterableMixin { FetchCollection( - {Iterable resources = const [], - Iterable included = const [], + {Iterable resources = const [], + Iterable included = const [], Map links = const {}}) : resources = resources ?? IdentityCollection(const []), links = Map.unmodifiable(links ?? const {}), @@ -22,16 +21,16 @@ class FetchCollection with IterableMixin { resources: IdentityCollection(document .get('data') .cast() - .map((_) => _.map(ResourceWithIdentity.fromJson)) + .map((_) => _.map(Resource.fromJson)) .or(const [])), included: IdentityCollection(document.included().or([])), links: document.links().or(const {})); } - final IdentityCollection resources; - final IdentityCollection included; + final IdentityCollection resources; + final IdentityCollection included; final Map links; @override - Iterator get iterator => resources.iterator; + Iterator get iterator => resources.iterator; } diff --git a/lib/src/response/fetch_primary_resource.dart b/lib/src/response/fetch_primary_resource.dart index f5a941db..70044849 100644 --- a/lib/src/response/fetch_primary_resource.dart +++ b/lib/src/response/fetch_primary_resource.dart @@ -1,14 +1,13 @@ import 'dart:convert'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identity_collection.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api/src/identity_collection.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; class FetchPrimaryResource { FetchPrimaryResource(this.resource, - {Iterable included = const [], + {Iterable included = const [], Map links = const {}}) : links = Map.unmodifiable(links ?? const {}), included = IdentityCollection(included ?? const []); @@ -18,13 +17,13 @@ class FetchPrimaryResource { return FetchPrimaryResource( document .get('data') - .map(ResourceWithIdentity.fromJson) + .map(Resource.fromJson) .orThrow(() => ArgumentError('Invalid response')), included: IdentityCollection(document.included().or([])), links: document.links().or(const {})); } - final ResourceWithIdentity resource; + final Resource resource; final IdentityCollection included; final Map links; } diff --git a/lib/src/response/fetch_related_resource.dart b/lib/src/response/fetch_related_resource.dart index c82ab3e7..cb092342 100644 --- a/lib/src/response/fetch_related_resource.dart +++ b/lib/src/response/fetch_related_resource.dart @@ -1,9 +1,8 @@ import 'dart:convert'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/identity_collection.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api/src/identity_collection.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; @@ -15,13 +14,12 @@ class FetchRelatedResource { static FetchRelatedResource decode(HttpResponse http) { final document = Document(jsonDecode(http.body)); - return FetchRelatedResource( - document.get('data').map(ResourceWithIdentity.fromJson), + return FetchRelatedResource(document.get('data').map(Resource.fromJson), included: IdentityCollection(document.included().or([])), links: document.links().or(const {})); } - final Maybe resource; + final Maybe resource; final IdentityCollection included; final Map links; } diff --git a/lib/src/response/fetch_relationship.dart b/lib/src/response/fetch_relationship.dart index 6642ed9a..baf8b994 100644 --- a/lib/src/response/fetch_relationship.dart +++ b/lib/src/response/fetch_relationship.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; class FetchRelationship { diff --git a/lib/src/response/request_failure.dart b/lib/src/response/request_failure.dart index f540bf9b..405aa544 100644 --- a/lib/src/response/request_failure.dart +++ b/lib/src/response/request_failure.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document/error_object.dart'; import 'package:json_api_common/http.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; diff --git a/lib/src/response/update_relationship.dart b/lib/src/response/update_relationship.dart index 2c9f5e59..7def346a 100644 --- a/lib/src/response/update_relationship.dart +++ b/lib/src/response/update_relationship.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; diff --git a/lib/src/response/update_resource.dart b/lib/src/response/update_resource.dart index 23700c93..84ed1f29 100644 --- a/lib/src/response/update_resource.dart +++ b/lib/src/response/update_resource.dart @@ -1,19 +1,17 @@ import 'dart:convert'; -import 'package:json_api/src/document/document.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_with_identity.dart'; +import 'package:json_api/src/document.dart'; +import 'package:json_api_common/document.dart'; import 'package:json_api_common/http.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; class UpdateResource { - UpdateResource(ResourceWithIdentity resource, - {Map links = const {}}) + UpdateResource(Resource resource, {Map links = const {}}) : resource = Just(resource), links = Map.unmodifiable(links ?? const {}); UpdateResource.empty() - : resource = Nothing(), + : resource = Nothing(), links = const {}; static UpdateResource decode(HttpResponse http) { @@ -24,11 +22,11 @@ class UpdateResource { return UpdateResource( document .get('data') - .map(ResourceWithIdentity.fromJson) + .map(Resource.fromJson) .orThrow(() => ArgumentError('Invalid response')), links: document.links().or(const {})); } final Map links; - final Maybe resource; + final Maybe resource; } diff --git a/pubspec.yaml b/pubspec.yaml index 50ad8365..1c348d7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,8 @@ description: Framework-agnostic implementations of JSON:API Client. Supports JSO environment: sdk: '>=2.8.0 <3.0.0' dependencies: - json_api_common: ^0.0.3 + json_api_common: + path: ../json-api-common maybe_just_nothing: ^0.1.0 dev_dependencies: json_api_server: diff --git a/test/client_test.dart b/test/client_test.dart index 612d20b0..fa18d434 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -1,8 +1,6 @@ import 'dart:convert'; import 'package:json_api/json_api.dart'; -import 'package:json_api_common/http.dart'; -import 'package:json_api_common/url_design.dart'; import 'package:maybe_just_nothing/maybe_just_nothing.dart'; import 'package:test/test.dart'; @@ -156,7 +154,7 @@ void main() { http.response = mock.relatedResourceNull; final response = await client.fetchRelatedResource('articles', '1', 'author'); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/author'); expect(http.request.headers, {'accept': 'application/vnd.api+json'}); @@ -174,7 +172,7 @@ void main() { }, query: { 'foo': 'bar' }); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'get'); expect(http.request.uri.toString(), r'/articles/1/author?include=author&fields%5Bauthor%5D=name&foo=bar'); @@ -300,7 +298,7 @@ void main() { test('Sends correct request when given minimum arguments', () async { http.response = mock.primaryResource; final response = await client.createResource('articles', '1'); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { @@ -324,7 +322,7 @@ void main() { }, headers: { 'foo': 'bar' }); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { @@ -407,7 +405,7 @@ void main() { test('Sends correct request when given minimum arguments', () async { http.response = mock.primaryResource; final response = await client.updateResource('articles', '1'); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { @@ -431,7 +429,7 @@ void main() { }, headers: { 'foo': 'bar' }); - expect(response.resource, isA>()); + expect(response.resource, isA>()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { diff --git a/test/responses.dart b/test/responses.dart index d39ecabc..2918e2db 100644 --- a/test/responses.dart +++ b/test/responses.dart @@ -112,7 +112,9 @@ final many = HttpResponse(200, 'self': '/articles/1/relationships/tags', 'related': '/articles/1/tags' }, - 'data': [{'type': 'tags', 'id': '12'}] + 'data': [ + {'type': 'tags', 'id': '12'} + ] })); final error422 = HttpResponse(422, headers: {'Content-Type': ContentType.jsonApi}, From f31519cc1122414fbc9887c3f855e89138faae5e Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 15 Nov 2020 17:43:35 -0800 Subject: [PATCH 77/99] WIP --- .github/workflows/dart.yml | 26 + .travis.yml | 15 - analysis_options.yaml | 4 + example/demo/dart_http_handler.dart | 38 + example/demo/demo_server.dart | 54 ++ example/demo/printing_logger.dart | 15 + example/demo/sqlite_controller.dart | 104 +++ lib/client.dart | 21 + lib/document.dart | 13 + lib/http.dart | 9 + lib/json_api.dart | 20 - lib/query.dart | 8 + lib/routing.dart | 9 + lib/server.dart | 3 + lib/src/{ => client}/dart_http.dart | 8 +- lib/src/client/identity_collection.dart | 18 + lib/src/client/json_api_client.dart | 39 + lib/src/client/json_api_request.dart | 14 + lib/src/client/request/add_many.dart | 20 + .../client/request/create_new_resource.dart | 32 + lib/src/client/request/create_resource.dart | 34 + lib/src/client/request/delete_many.dart | 20 + lib/src/client/request/delete_one.dart | 10 + lib/src/client/request/fetch_collection.dart | 17 + .../request/fetch_related_collection.dart | 18 + .../request/fetch_related_resource.dart | 18 + .../client/request/fetch_relationship.dart | 18 + lib/src/client/request/fetch_resource.dart | 17 + .../request/internal/payload_request.dart | 15 + .../request/internal/simple_request.dart | 55 ++ lib/src/client/request/replace.dart | 20 + lib/src/client/request/replace_many.dart | 11 + lib/src/client/request/replace_one.dart | 11 + lib/src/client/request/update_resource.dart | 35 + lib/src/client/request_failure.dart | 11 + .../client/response/collection_response.dart | 37 + .../response/new_resource_response.dart | 25 + .../response/relationship_response.dart | 26 + .../client/response/resource_response.dart | 27 + lib/src/client/uri_provider.dart | 3 + lib/src/document.dart | 37 - lib/src/document/error_object.dart | 56 ++ lib/src/document/error_source.dart | 19 + lib/src/document/identifier.dart | 17 + lib/src/document/identity.dart | 11 + lib/src/document/inbound_document.dart | 143 ++++ lib/src/document/link.dart | 17 + lib/src/document/new_resource.dart | 22 + lib/src/document/outbound_document.dart | 70 ++ lib/src/document/relationship/many.dart | 17 + lib/src/document/relationship/one.dart | 18 + .../document/relationship/relationship.dart | 17 + lib/src/document/resource.dart | 30 + lib/src/document/resource_properties.dart | 16 + lib/src/extensions.dart | 23 + lib/src/http/headers.dart | 29 + lib/src/http/http_handler.dart | 23 + lib/src/http/http_logger.dart | 7 + lib/src/http/http_request.dart | 32 + lib/src/http/http_response.dart | 38 + lib/src/http/last_value_logger.dart | 17 + lib/src/http/logging_http_handler.dart | 20 + .../media_type.dart} | 2 +- lib/src/identity_collection.dart | 16 - lib/src/json_api_client.dart | 224 ----- lib/src/nullable.dart | 2 + lib/src/query/fields.dart | 45 + lib/src/query/filter.dart | 42 + lib/src/query/include.dart | 27 + lib/src/query/page.dart | 44 + lib/src/query/sort.dart | 66 ++ lib/src/request.dart | 53 -- lib/src/response/create_resource.dart | 24 - lib/src/response/delete_resource.dart | 16 - lib/src/response/fetch_collection.dart | 36 - lib/src/response/fetch_primary_resource.dart | 29 - lib/src/response/fetch_related_resource.dart | 25 - lib/src/response/fetch_relationship.dart | 14 - lib/src/response/request_failure.dart | 31 - lib/src/response/update_relationship.dart | 21 - lib/src/response/update_resource.dart | 32 - lib/src/routing/recommended_url_design.dart | 65 ++ lib/src/routing/reference.dart | 32 + lib/src/routing/target.dart | 50 ++ lib/src/routing/target_matcher.dart | 6 + lib/src/routing/uri_factory.dart | 3 + lib/src/server/controller.dart | 10 + lib/src/server/cors_handler.dart | 26 + lib/src/server/entity.dart | 7 + lib/src/server/json_api_handler.dart | 42 + lib/src/server/method_not_allowed.dart | 5 + lib/src/server/model.dart | 11 + lib/src/server/router.dart | 24 + lib/src/status_code.dart | 19 - pubspec.yaml | 23 +- test/client/client_test.dart | 786 ++++++++++++++++++ test/client/response.dart | 255 ++++++ test/client_test.dart | 780 ----------------- test/document/error_object_test.dart | 39 + test/document/inbound_document_test.dart | 175 ++++ test/document/link_test.dart | 22 + test/document/new_resource_test.dart | 50 ++ test/document/outbound_document_test.dart | 132 +++ test/document/payload.dart | 140 ++++ test/document/relationship_test.dart | 45 + test/document/resource_test.dart | 58 ++ test/e2e/dart_http_handler.dart | 38 + test/e2e/e2e_test.dart | 80 ++ test/e2e/hybrid_server.dart | 9 + test/http/headers_test.dart | 28 + test/http/logging_http_handler_test.dart | 16 + test/http/request_test.dart | 13 + test/integration/integration_test.dart | 75 ++ test/query/fields_test.dart | 59 ++ test/query/filter_test.dart | 37 + test/query/include_test.dart | 31 + test/query/page_test.dart | 37 + test/query/sort_test.dart | 32 + test/responses.dart | 130 --- test/routing/url_test.dart | 36 + 120 files changed, 4210 insertions(+), 1542 deletions(-) create mode 100644 .github/workflows/dart.yml delete mode 100644 .travis.yml create mode 100644 example/demo/dart_http_handler.dart create mode 100644 example/demo/demo_server.dart create mode 100644 example/demo/printing_logger.dart create mode 100644 example/demo/sqlite_controller.dart create mode 100644 lib/client.dart create mode 100644 lib/document.dart create mode 100644 lib/http.dart delete mode 100644 lib/json_api.dart create mode 100644 lib/query.dart create mode 100644 lib/routing.dart create mode 100644 lib/server.dart rename lib/src/{ => client}/dart_http.dart (67%) create mode 100644 lib/src/client/identity_collection.dart create mode 100644 lib/src/client/json_api_client.dart create mode 100644 lib/src/client/json_api_request.dart create mode 100644 lib/src/client/request/add_many.dart create mode 100644 lib/src/client/request/create_new_resource.dart create mode 100644 lib/src/client/request/create_resource.dart create mode 100644 lib/src/client/request/delete_many.dart create mode 100644 lib/src/client/request/delete_one.dart create mode 100644 lib/src/client/request/fetch_collection.dart create mode 100644 lib/src/client/request/fetch_related_collection.dart create mode 100644 lib/src/client/request/fetch_related_resource.dart create mode 100644 lib/src/client/request/fetch_relationship.dart create mode 100644 lib/src/client/request/fetch_resource.dart create mode 100644 lib/src/client/request/internal/payload_request.dart create mode 100644 lib/src/client/request/internal/simple_request.dart create mode 100644 lib/src/client/request/replace.dart create mode 100644 lib/src/client/request/replace_many.dart create mode 100644 lib/src/client/request/replace_one.dart create mode 100644 lib/src/client/request/update_resource.dart create mode 100644 lib/src/client/request_failure.dart create mode 100644 lib/src/client/response/collection_response.dart create mode 100644 lib/src/client/response/new_resource_response.dart create mode 100644 lib/src/client/response/relationship_response.dart create mode 100644 lib/src/client/response/resource_response.dart create mode 100644 lib/src/client/uri_provider.dart delete mode 100644 lib/src/document.dart create mode 100644 lib/src/document/error_object.dart create mode 100644 lib/src/document/error_source.dart create mode 100644 lib/src/document/identifier.dart create mode 100644 lib/src/document/identity.dart create mode 100644 lib/src/document/inbound_document.dart create mode 100644 lib/src/document/link.dart create mode 100644 lib/src/document/new_resource.dart create mode 100644 lib/src/document/outbound_document.dart create mode 100644 lib/src/document/relationship/many.dart create mode 100644 lib/src/document/relationship/one.dart create mode 100644 lib/src/document/relationship/relationship.dart create mode 100644 lib/src/document/resource.dart create mode 100644 lib/src/document/resource_properties.dart create mode 100644 lib/src/extensions.dart create mode 100644 lib/src/http/headers.dart create mode 100644 lib/src/http/http_handler.dart create mode 100644 lib/src/http/http_logger.dart create mode 100644 lib/src/http/http_request.dart create mode 100644 lib/src/http/http_response.dart create mode 100644 lib/src/http/last_value_logger.dart create mode 100644 lib/src/http/logging_http_handler.dart rename lib/src/{content_type.dart => http/media_type.dart} (73%) delete mode 100644 lib/src/identity_collection.dart delete mode 100644 lib/src/json_api_client.dart create mode 100644 lib/src/nullable.dart create mode 100644 lib/src/query/fields.dart create mode 100644 lib/src/query/filter.dart create mode 100644 lib/src/query/include.dart create mode 100644 lib/src/query/page.dart create mode 100644 lib/src/query/sort.dart delete mode 100644 lib/src/request.dart delete mode 100644 lib/src/response/create_resource.dart delete mode 100644 lib/src/response/delete_resource.dart delete mode 100644 lib/src/response/fetch_collection.dart delete mode 100644 lib/src/response/fetch_primary_resource.dart delete mode 100644 lib/src/response/fetch_related_resource.dart delete mode 100644 lib/src/response/fetch_relationship.dart delete mode 100644 lib/src/response/request_failure.dart delete mode 100644 lib/src/response/update_relationship.dart delete mode 100644 lib/src/response/update_resource.dart create mode 100644 lib/src/routing/recommended_url_design.dart create mode 100644 lib/src/routing/reference.dart create mode 100644 lib/src/routing/target.dart create mode 100644 lib/src/routing/target_matcher.dart create mode 100644 lib/src/routing/uri_factory.dart create mode 100644 lib/src/server/controller.dart create mode 100644 lib/src/server/cors_handler.dart create mode 100644 lib/src/server/entity.dart create mode 100644 lib/src/server/json_api_handler.dart create mode 100644 lib/src/server/method_not_allowed.dart create mode 100644 lib/src/server/model.dart create mode 100644 lib/src/server/router.dart delete mode 100644 lib/src/status_code.dart create mode 100644 test/client/client_test.dart create mode 100644 test/client/response.dart delete mode 100644 test/client_test.dart create mode 100644 test/document/error_object_test.dart create mode 100644 test/document/inbound_document_test.dart create mode 100644 test/document/link_test.dart create mode 100644 test/document/new_resource_test.dart create mode 100644 test/document/outbound_document_test.dart create mode 100644 test/document/payload.dart create mode 100644 test/document/relationship_test.dart create mode 100644 test/document/resource_test.dart create mode 100644 test/e2e/dart_http_handler.dart create mode 100644 test/e2e/e2e_test.dart create mode 100644 test/e2e/hybrid_server.dart create mode 100644 test/http/headers_test.dart create mode 100644 test/http/logging_http_handler_test.dart create mode 100644 test/http/request_test.dart create mode 100644 test/integration/integration_test.dart create mode 100644 test/query/fields_test.dart create mode 100644 test/query/filter_test.dart create mode 100644 test/query/include_test.dart create mode 100644 test/query/page_test.dart create mode 100644 test/query/sort_test.dart delete mode 100644 test/responses.dart create mode 100644 test/routing/url_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 00000000..25c7b75b --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,26 @@ +name: Dart CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + container: + image: google/dart:latest + + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: dart pub get + - name: Format + run: dartfmt --dry-run --set-exit-if-changed lib test + - name: Analyzer + run: dart analyze --fatal-infos --fatal-warnings + - name: Tests + run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1bad916c..00000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: dart -dart: - - stable - - dev - - "2.6.0" - - "2.6.1" - - "2.7.0" - - "2.8.0" - - "2.8.1" -dart_task: - - test: --platform vm - - test: --platform chrome - - test: --platform firefox - - dartfmt: true - - dartanalyzer: --fatal-infos --fatal-warnings lib test example diff --git a/analysis_options.yaml b/analysis_options.yaml index d9406f33..4ea6fd01 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,3 +3,7 @@ linter: rules: - sort_constructors_first - sort_unnamed_constructors_first + +#analyzer: +# enable-experiment: +# - non-nullable \ No newline at end of file diff --git a/example/demo/dart_http_handler.dart b/example/demo/dart_http_handler.dart new file mode 100644 index 00000000..526437bf --- /dev/null +++ b/example/demo/dart_http_handler.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:json_api/http.dart'; + +class DartHttpHandler { + DartHttpHandler(this._handler); + + final HttpHandler _handler; + + Future call(io.HttpRequest ioRequest) async { + final request = await _convertRequest(ioRequest); + final response = await _handler(request); + await _sendResponse(response, ioRequest.response); + } + + Future _sendResponse( + HttpResponse response, io.HttpResponse ioResponse) async { + response.headers.forEach(ioResponse.headers.add); + ioResponse.statusCode = response.statusCode; + ioResponse.write(response.body); + await ioResponse.close(); + } + + Future _convertRequest(io.HttpRequest ioRequest) async => + HttpRequest(ioRequest.method, ioRequest.requestedUri, + body: await _readBody(ioRequest), + headers: _convertHeaders(ioRequest.headers)); + + Future _readBody(io.HttpRequest ioRequest) => + ioRequest.cast>().transform(utf8.decoder).join(); + + Map _convertHeaders(io.HttpHeaders ioHeaders) { + final headers = {}; + ioHeaders.forEach((k, v) => headers[k] = v.join(',')); + return headers; + } +} diff --git a/example/demo/demo_server.dart b/example/demo/demo_server.dart new file mode 100644 index 00000000..97afcf3a --- /dev/null +++ b/example/demo/demo_server.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:sqlite3/sqlite3.dart'; + +import 'dart_http_handler.dart'; +import 'printing_logger.dart'; +import 'sqlite_controller.dart'; + +class DemoServer { + DemoServer(this._initSql, {String address, int port = 8080}) + : _address = address ?? 'localhost', + _port = port; + + final String _address; + final int _port; + final String _initSql; + + Database _database; + HttpServer _server; + + bool get isStarted => _database != null || _server != null; + + String get uri => 'http://${_address}:$_port'; + + Future start() async { + if (isStarted) throw StateError('Server already started'); + try { + _database = sqlite3.openInMemory(); + _database.execute(_initSql); + _server = await HttpServer.bind(_address, _port); + final controller = SqliteController(_database); + final jsonApiServer = + JsonApiHandler(controller, exposeInternalErrors: true); + final _handler = + CorsHandler(LoggingHttpHandler(jsonApiServer, PrintingLogger())); + unawaited(_server.forEach(DartHttpHandler(_handler))); + } on Exception { + await stop(); + rethrow; + } + } + + Future stop({bool force = false}) async { + if (_database != null) { + _database.dispose(); + } + if (_server != null) { + await _server.close(force: force); + } + } +} diff --git a/example/demo/printing_logger.dart b/example/demo/printing_logger.dart new file mode 100644 index 00000000..39485891 --- /dev/null +++ b/example/demo/printing_logger.dart @@ -0,0 +1,15 @@ +import 'package:json_api/http.dart'; + +class PrintingLogger implements HttpLogger { + const PrintingLogger(); + + @override + void onRequest(HttpRequest request) { + // print('Rq: ${request.method} ${request.uri}\n${request.headers}'); + } + + @override + void onResponse(HttpResponse response) { + // print('Rs: ${response.statusCode}\n${response.headers}'); + } +} diff --git a/example/demo/sqlite_controller.dart b/example/demo/sqlite_controller.dart new file mode 100644 index 00000000..ca976fbc --- /dev/null +++ b/example/demo/sqlite_controller.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/http/media_type.dart'; +import 'package:json_api/src/server/model.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:uuid/uuid.dart'; + +class SqliteController implements JsonApiController> { + SqliteController(this.db); + + static SqliteController inMemory(String init) { + final db = sqlite3.openInMemory(); + db.execute(init); + return SqliteController(db); + } + + final Database db; + + final urlDesign = RecommendedUrlDesign.pathOnly; + + @override + Future fetchCollection( + HttpRequest request, CollectionTarget target) async { + final collection = db + .select('SELECT * FROM ${_sanitize(target.type)}') + .map(_resourceFromRow(target.type)); + final doc = OutboundDocument.collection(collection) + ..links['self'] = Link(target.map(urlDesign)); + return HttpResponse(200, body: jsonEncode(doc), headers: { + 'content-type': MediaType.jsonApi, + }); + } + + @override + Future fetchResource( + HttpRequest request, ResourceTarget target) async { + final resource = _fetchResource(target.type, target.id); + final self = Link(target.map(urlDesign)); + final doc = OutboundDocument.resource(resource)..links['self'] = self; + return HttpResponse(200, + body: jsonEncode(doc), headers: {'content-type': MediaType.jsonApi}); + } + + @override + Future createResource( + HttpRequest request, CollectionTarget target) async { + final doc = InboundDocument(jsonDecode(request.body)); + final res = doc.newResource(); + final model = Model(res.type)..attributes.addAll(res.attributes); + final id = res.id ?? Uuid().v4(); + if (res.id == null) { + _createResource(target.type, id, model); + final resource = _fetchResource(target.type, id); + + final self = + Link(ResourceTarget(resource.type, resource.id).map(urlDesign)); + resource.links['self'] = self; + final doc = OutboundDocument.resource(resource)..links['self'] = self; + return HttpResponse(201, body: jsonEncode(doc), headers: { + 'content-type': MediaType.jsonApi, + 'location': self.uri.toString() + }); + } + _createResource(target.type, res.id, model); + return HttpResponse(204); + } + + void _createResource(String type, String id, Model model) { + final columns = ['id', ...model.attributes.keys].map(_sanitize); + final values = [id, ...model.attributes.values]; + final sql = ''' + INSERT INTO ${_sanitize(type)} + (${columns.join(', ')}) + VALUES (${values.map((_) => '?').join(', ')}) + '''; + final s = db.prepare(sql); + s.execute(values); + } + + Resource _fetchResource(String type, String id) { + final sql = 'SELECT * FROM ${_sanitize(type)} WHERE id = ?'; + final results = db.select(sql, [id]); + if (results.isEmpty) throw ResourceNotFound(type, id); + return _resourceFromRow(type)(results.first); + } + + Resource Function(Row row) _resourceFromRow(String type) => + (Row row) => Resource(type, row['id'].toString()) + ..attributes.addAll({ + for (var _ in row.keys.where((_) => _ != 'id')) _.toString(): row[_] + }); + + String _sanitize(String value) => value.replaceAll(_nonAlpha, ''); + + static final _nonAlpha = RegExp('[^a-z]'); +} + +class ResourceNotFound implements Exception { + ResourceNotFound(String type, String id); +} diff --git a/lib/client.dart b/lib/client.dart new file mode 100644 index 00000000..42ba48ff --- /dev/null +++ b/lib/client.dart @@ -0,0 +1,21 @@ +library json_api; + +export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/request_failure.dart'; +export 'package:json_api/src/client/dart_http.dart'; +export 'package:json_api/src/client/request/add_many.dart'; +export 'package:json_api/src/client/request/create_new_resource.dart'; +export 'package:json_api/src/client/request/create_resource.dart'; +export 'package:json_api/src/client/request/delete_many.dart'; +export 'package:json_api/src/client/request/delete_one.dart'; +export 'package:json_api/src/client/request/fetch_collection.dart'; +export 'package:json_api/src/client/request/fetch_related_collection.dart'; +export 'package:json_api/src/client/request/fetch_related_resource.dart'; +export 'package:json_api/src/client/request/fetch_relationship.dart'; +export 'package:json_api/src/client/request/fetch_resource.dart'; +export 'package:json_api/src/client/request/replace_many.dart'; +export 'package:json_api/src/client/request/replace_one.dart'; +export 'package:json_api/src/client/request/update_resource.dart'; +export 'package:json_api/src/client/response/collection_response.dart'; +export 'package:json_api/src/client/response/relationship_response.dart'; +export 'package:json_api/src/client/response/resource_response.dart'; diff --git a/lib/document.dart b/lib/document.dart new file mode 100644 index 00000000..cd880752 --- /dev/null +++ b/lib/document.dart @@ -0,0 +1,13 @@ +library document; + +export 'package:json_api/src/document/error_object.dart'; +export 'package:json_api/src/document/identifier.dart'; +export 'package:json_api/src/document/identity.dart'; +export 'package:json_api/src/document/inbound_document.dart'; +export 'package:json_api/src/document/link.dart'; +export 'package:json_api/src/document/new_resource.dart'; +export 'package:json_api/src/document/outbound_document.dart'; +export 'package:json_api/src/document/relationship/many.dart'; +export 'package:json_api/src/document/relationship/one.dart'; +export 'package:json_api/src/document/relationship/relationship.dart'; +export 'package:json_api/src/document/resource.dart'; diff --git a/lib/http.dart b/lib/http.dart new file mode 100644 index 00000000..c2435423 --- /dev/null +++ b/lib/http.dart @@ -0,0 +1,9 @@ +/// This is a thin HTTP layer abstraction used by the client +library http; + +export 'package:json_api/src/http/headers.dart'; +export 'package:json_api/src/http/http_handler.dart'; +export 'package:json_api/src/http/http_logger.dart'; +export 'package:json_api/src/http/http_request.dart'; +export 'package:json_api/src/http/http_response.dart'; +export 'package:json_api/src/http/logging_http_handler.dart'; diff --git a/lib/json_api.dart b/lib/json_api.dart deleted file mode 100644 index 281b2db1..00000000 --- a/lib/json_api.dart +++ /dev/null @@ -1,20 +0,0 @@ -library json_api; - -export 'package:json_api/src/content_type.dart'; -export 'package:json_api/src/dart_http.dart'; -export 'package:json_api/src/document.dart'; -export 'package:json_api/src/json_api_client.dart'; -export 'package:json_api/src/request.dart'; -export 'package:json_api/src/response/create_resource.dart'; -export 'package:json_api/src/response/delete_resource.dart'; -export 'package:json_api/src/response/fetch_collection.dart'; -export 'package:json_api/src/response/fetch_primary_resource.dart'; -export 'package:json_api/src/response/fetch_related_resource.dart'; -export 'package:json_api/src/response/fetch_relationship.dart'; -export 'package:json_api/src/response/request_failure.dart'; -export 'package:json_api/src/response/update_relationship.dart'; -export 'package:json_api/src/response/update_relationship.dart'; -export 'package:json_api/src/status_code.dart'; -export 'package:json_api_common/document.dart'; -export 'package:json_api_common/http.dart'; -export 'package:json_api_common/url_design.dart'; diff --git a/lib/query.dart b/lib/query.dart new file mode 100644 index 00000000..1265a94c --- /dev/null +++ b/lib/query.dart @@ -0,0 +1,8 @@ +/// A set of builders/parsers for special query parameters used in JSON:API. +library query; + +export 'package:json_api/src/query/fields.dart'; +export 'package:json_api/src/query/filter.dart'; +export 'package:json_api/src/query/include.dart'; +export 'package:json_api/src/query/page.dart'; +export 'package:json_api/src/query/sort.dart'; diff --git a/lib/routing.dart b/lib/routing.dart new file mode 100644 index 00000000..d707e384 --- /dev/null +++ b/lib/routing.dart @@ -0,0 +1,9 @@ +/// Routing describes the design of URLs on the server. +/// See https://jsonapi.org/recommendations/#urls +library routing; + +export 'package:json_api/src/routing/recommended_url_design.dart'; +export 'package:json_api/src/routing/reference.dart'; +export 'package:json_api/src/routing/target.dart'; +export 'package:json_api/src/routing/target_matcher.dart'; +export 'package:json_api/src/routing/uri_factory.dart'; diff --git a/lib/server.dart b/lib/server.dart new file mode 100644 index 00000000..d0141648 --- /dev/null +++ b/lib/server.dart @@ -0,0 +1,3 @@ +export 'package:json_api/src/server/controller.dart'; +export 'package:json_api/src/server/cors_handler.dart'; +export 'package:json_api/src/server/json_api_handler.dart'; diff --git a/lib/src/dart_http.dart b/lib/src/client/dart_http.dart similarity index 67% rename from lib/src/dart_http.dart rename to lib/src/client/dart_http.dart index 92d7aeef..f583cba2 100644 --- a/lib/src/dart_http.dart +++ b/lib/src/client/dart_http.dart @@ -1,7 +1,7 @@ import 'package:http/http.dart'; -import 'package:json_api_common/http.dart'; +import 'package:json_api/http.dart'; -/// A handler using the Dart's built-in http client +/// A handler using the built-in http client class DartHttp implements HttpHandler { DartHttp(this._client); @@ -16,6 +16,6 @@ class DartHttp implements HttpHandler { final Client _client; - Future _send(Request dartRequest) async => - Response.fromStream(await _client.send(dartRequest)); + Future _send(Request request) async => + Response.fromStream(await _client.send(request)); } diff --git a/lib/src/client/identity_collection.dart b/lib/src/client/identity_collection.dart new file mode 100644 index 00000000..c9419145 --- /dev/null +++ b/lib/src/client/identity_collection.dart @@ -0,0 +1,18 @@ +import 'dart:collection'; + +import 'package:json_api/document.dart'; + +/// A collection of [Identity] objects. +class IdentityCollection with IterableMixin { + IdentityCollection(Iterable resources) { + resources.forEach((element) => _map[element.key] = element); + } + + final _map = {}; + + /// Returns the element by [key] or null. + T /*?*/ operator [](String key) => _map[key]; + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart new file mode 100644 index 00000000..7fc4da12 --- /dev/null +++ b/lib/src/client/json_api_client.dart @@ -0,0 +1,39 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/json_api_request.dart'; +import 'package:json_api/src/client/request_failure.dart'; +import 'package:json_api/src/http/media_type.dart'; + +/// The JSON:API client +class JsonApiClient { + JsonApiClient(this._http, this._uriFactory); + + final HttpHandler _http; + final UriFactory _uriFactory; + + /// Sends the [request] to the server. + /// Returns the response when the server responds with a JSON:API document. + /// Throws a [RequestFailure] if the server responds with a JSON:API error. + /// Throws a [ServerError] if the server responds with a non-JSON:API error. + Future call(JsonApiRequest request) async { + final response = await _http.call(_toHttp(request)); + if (!response.isSuccessful && !response.isPending) { + throw RequestFailure(response, + document: response.hasDocument + ? InboundDocument.decode(response.body) + : null); + } + return request.response(response); + } + + HttpRequest _toHttp(JsonApiRequest request) { + final headers = {'accept': MediaType.jsonApi}; + if (request.body.isNotEmpty) { + headers['content-type'] = MediaType.jsonApi; + } + headers.addAll(request.headers); + return HttpRequest(request.method, request.uri(_uriFactory), + body: request.body, headers: headers); + } +} diff --git a/lib/src/client/json_api_request.dart b/lib/src/client/json_api_request.dart new file mode 100644 index 00000000..755b1da4 --- /dev/null +++ b/lib/src/client/json_api_request.dart @@ -0,0 +1,14 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; + +abstract class JsonApiRequest { + String get method; + + String get body; + + Map get headers; + + Uri uri(TargetMapper urls); + + T response(HttpResponse response); +} diff --git a/lib/src/client/request/add_many.dart b/lib/src/client/request/add_many.dart new file mode 100644 index 00000000..1470d9a1 --- /dev/null +++ b/lib/src/client/request/add_many.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; + +class AddMany extends PayloadRequest> { + AddMany(this.target, Many many) : super('post', many); + + AddMany.build( + String type, String id, String relationship, List identifiers) + : this(RelationshipTarget(type, id, relationship), Many(identifiers)); + + @override + final RelationshipTarget target; + + @override + RelationshipResponse response(HttpResponse response) => + RelationshipResponse.decode(response); +} diff --git a/lib/src/client/request/create_new_resource.dart b/lib/src/client/request/create_new_resource.dart new file mode 100644 index 00000000..f7dc399f --- /dev/null +++ b/lib/src/client/request/create_new_resource.dart @@ -0,0 +1,32 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/new_resource_response.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class CreateNewResource extends PayloadRequest { + CreateNewResource(this.target, NewResource properties) + : super('post', {'data': properties}); + + CreateNewResource.build(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}}) + : this( + CollectionTarget(type), + NewResource(type) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta)); + + @override + final CollectionTarget target; + + @override + NewResourceResponse response(HttpResponse response) => + NewResourceResponse.decode(response); +} diff --git a/lib/src/client/request/create_resource.dart b/lib/src/client/request/create_resource.dart new file mode 100644 index 00000000..8e247f1b --- /dev/null +++ b/lib/src/client/request/create_resource.dart @@ -0,0 +1,34 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class CreateResource extends PayloadRequest { + CreateResource(this.target, Resource resource) + : super('post', {'data': resource}); + + CreateResource.build( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + }) : this( + CollectionTarget(type), + Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((k, v) => MapEntry(k, One(v))), + ...many.map((k, v) => MapEntry(k, Many(v))), + }) + ..meta.addAll(meta)); + + @override + final CollectionTarget target; + + @override + ResourceResponse response(HttpResponse response) => + ResourceResponse.decode(response); +} diff --git a/lib/src/client/request/delete_many.dart b/lib/src/client/request/delete_many.dart new file mode 100644 index 00000000..c17b2688 --- /dev/null +++ b/lib/src/client/request/delete_many.dart @@ -0,0 +1,20 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class DeleteMany extends PayloadRequest> { + DeleteMany(this.target, Many many) : super('delete', many); + + DeleteMany.build( + String type, String id, String relationship, List identifiers) + : this(RelationshipTarget(type, id, relationship), Many(identifiers)); + + @override + final RelationshipTarget target; + + @override + RelationshipResponse response(HttpResponse response) => + RelationshipResponse.decode(response); +} diff --git a/lib/src/client/request/delete_one.dart b/lib/src/client/request/delete_one.dart new file mode 100644 index 00000000..e9d25e7a --- /dev/null +++ b/lib/src/client/request/delete_one.dart @@ -0,0 +1,10 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class DeleteOne extends ReplaceOne { + DeleteOne(RelationshipTarget target) : super(target, One.empty()); + + DeleteOne.build(String type, String id, String relationship) + : this(RelationshipTarget(type, id, relationship)); +} diff --git a/lib/src/client/request/fetch_collection.dart b/lib/src/client/request/fetch_collection.dart new file mode 100644 index 00000000..a1313762 --- /dev/null +++ b/lib/src/client/request/fetch_collection.dart @@ -0,0 +1,17 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/client/response/collection_response.dart'; +import 'package:json_api/routing.dart'; + +class FetchCollection extends SimpleRequest { + FetchCollection(String type) : this.build(CollectionTarget(type)); + + FetchCollection.build(this.target) : super('get'); + + @override + final CollectionTarget target; + + @override + CollectionResponse response(HttpResponse response) => + CollectionResponse.decode(response); +} diff --git a/lib/src/client/request/fetch_related_collection.dart b/lib/src/client/request/fetch_related_collection.dart new file mode 100644 index 00000000..cd4a3ffc --- /dev/null +++ b/lib/src/client/request/fetch_related_collection.dart @@ -0,0 +1,18 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/client/response/collection_response.dart'; +import 'package:json_api/routing.dart'; + +class FetchRelatedCollection extends SimpleRequest { + FetchRelatedCollection(String type, String id, String relationship) + : this.build(RelatedTarget(type, id, relationship)); + + FetchRelatedCollection.build(this.target) : super('get'); + + @override + final RelatedTarget target; + + @override + CollectionResponse response(HttpResponse response) => + CollectionResponse.decode(response); +} diff --git a/lib/src/client/request/fetch_related_resource.dart b/lib/src/client/request/fetch_related_resource.dart new file mode 100644 index 00000000..e86cd08c --- /dev/null +++ b/lib/src/client/request/fetch_related_resource.dart @@ -0,0 +1,18 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; +import 'package:json_api/routing.dart'; + +class FetchRelatedResource extends SimpleRequest { + FetchRelatedResource(String type, String id, String relationship) + : this.build(RelatedTarget(type, id, relationship)); + + FetchRelatedResource.build(this.target) : super('get'); + + @override + final RelatedTarget target; + + @override + ResourceResponse response(HttpResponse response) => + ResourceResponse.decode(response); +} diff --git a/lib/src/client/request/fetch_relationship.dart b/lib/src/client/request/fetch_relationship.dart new file mode 100644 index 00000000..93174234 --- /dev/null +++ b/lib/src/client/request/fetch_relationship.dart @@ -0,0 +1,18 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; +import 'package:json_api/routing.dart'; + +class FetchRelationship extends SimpleRequest { + FetchRelationship(String type, String id, String relationship) + : this.build(RelationshipTarget(type, id, relationship)); + + FetchRelationship.build(this.target) : super('get'); + + @override + final RelationshipTarget target; + + @override + RelationshipResponse response(HttpResponse response) => + RelationshipResponse.decode(response); +} diff --git a/lib/src/client/request/fetch_resource.dart b/lib/src/client/request/fetch_resource.dart new file mode 100644 index 00000000..09dcd35d --- /dev/null +++ b/lib/src/client/request/fetch_resource.dart @@ -0,0 +1,17 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; +import 'package:json_api/routing.dart'; + +class FetchResource extends SimpleRequest { + FetchResource(this.target) : super('get'); + + FetchResource.build(String type, String id) : this(ResourceTarget(type, id)); + + @override + final ResourceTarget target; + + @override + ResourceResponse response(HttpResponse response) => + ResourceResponse.decode(response); +} diff --git a/lib/src/client/request/internal/payload_request.dart b/lib/src/client/request/internal/payload_request.dart new file mode 100644 index 00000000..ce915770 --- /dev/null +++ b/lib/src/client/request/internal/payload_request.dart @@ -0,0 +1,15 @@ +import 'dart:convert'; + +import 'package:json_api/src/client/request/internal/simple_request.dart'; +import 'package:json_api/src/http/media_type.dart'; + +abstract class PayloadRequest extends SimpleRequest { + PayloadRequest(String method, Object payload) + : body = jsonEncode(payload), + super(method) { + headers['content-type'] = MediaType.jsonApi; + } + + @override + final String body; +} diff --git a/lib/src/client/request/internal/simple_request.dart b/lib/src/client/request/internal/simple_request.dart new file mode 100644 index 00000000..c5cd7efb --- /dev/null +++ b/lib/src/client/request/internal/simple_request.dart @@ -0,0 +1,55 @@ +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/json_api_request.dart'; +import 'package:json_api/src/http/media_type.dart'; + +abstract class SimpleRequest implements JsonApiRequest { + SimpleRequest(this.method); + + Target get target; + + @override + final String method; + + @override + final body = ''; + + @override + final headers = {'accept': MediaType.jsonApi}; + + @override + Uri uri(TargetMapper urls) { + final path = target.map(urls); + return query.isEmpty + ? path + : path.replace(queryParameters: {...path.queryParameters, ...query}); + } + + /// URL Query String parameters + final query = {}; + + /// Adds the request to include the [related] resources to the [query]. + void include(Iterable related) { + query.addAll(Include(related).asQueryParameters); + } + + /// Adds the request for the sparse [fields] to the [query]. + void fields(Map> fields) { + query.addAll(Fields(fields).asQueryParameters); + } + + /// Adds the request for pagination to the [query]. + void page(Map page) { + query.addAll(Page(page).asQueryParameters); + } + + /// Adds the filter parameters to the [query]. + void filter(Map page) { + query.addAll(Filter(page).asQueryParameters); + } + + /// Adds the request for page sorting to the [query]. + void sort(Iterable fields) { + query.addAll(Sort(fields).asQueryParameters); + } +} diff --git a/lib/src/client/request/replace.dart b/lib/src/client/request/replace.dart new file mode 100644 index 00000000..558f0f35 --- /dev/null +++ b/lib/src/client/request/replace.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; + +class Replace + extends PayloadRequest> { + Replace(this.target, R data) : super('patch', data); + + Replace.build(String type, String id, String relationship, R data) + : this(RelationshipTarget(type, id, relationship), data); + + @override + final RelationshipTarget target; + + @override + RelationshipResponse response(HttpResponse response) => + RelationshipResponse.decode(response); +} diff --git a/lib/src/client/request/replace_many.dart b/lib/src/client/request/replace_many.dart new file mode 100644 index 00000000..b097f0dc --- /dev/null +++ b/lib/src/client/request/replace_many.dart @@ -0,0 +1,11 @@ +import 'package:json_api/src/client/request/replace.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class ReplaceMany extends Replace { + ReplaceMany(RelationshipTarget target, Many many) : super(target, many); + + ReplaceMany.build(String type, String id, String relationship, + Iterable identifiers) + : super.build(type, id, relationship, Many(identifiers)); +} diff --git a/lib/src/client/request/replace_one.dart b/lib/src/client/request/replace_one.dart new file mode 100644 index 00000000..f3e501af --- /dev/null +++ b/lib/src/client/request/replace_one.dart @@ -0,0 +1,11 @@ +import 'package:json_api/src/client/request/replace.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class ReplaceOne extends Replace { + ReplaceOne(RelationshipTarget target, One one) : super(target, one); + + ReplaceOne.build( + String type, String id, String relationship, Identifier identifier) + : super.build(type, id, relationship, One(identifier)); +} diff --git a/lib/src/client/request/update_resource.dart b/lib/src/client/request/update_resource.dart new file mode 100644 index 00000000..9e612dd9 --- /dev/null +++ b/lib/src/client/request/update_resource.dart @@ -0,0 +1,35 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request/internal/payload_request.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; + +class UpdateResource extends PayloadRequest { + UpdateResource( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + }) : this.build( + ResourceTarget(type, id), + Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta)); + + UpdateResource.build(this.target, Resource resource) + : super('patch', {'data': resource}); + + @override + final ResourceTarget target; + + /// Returns [ResourceResponse] + @override + ResourceResponse response(HttpResponse response) => + ResourceResponse.decode(response); +} diff --git a/lib/src/client/request_failure.dart b/lib/src/client/request_failure.dart new file mode 100644 index 00000000..93449aff --- /dev/null +++ b/lib/src/client/request_failure.dart @@ -0,0 +1,11 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// Thrown when the server returns a non-successful response. +class RequestFailure implements Exception { + RequestFailure(this.http, {this.document}); + + /// The response itself. + final HttpResponse http; + final InboundDocument /*?*/ document; +} diff --git a/lib/src/client/response/collection_response.dart b/lib/src/client/response/collection_response.dart new file mode 100644 index 00000000..78bcaad3 --- /dev/null +++ b/lib/src/client/response/collection_response.dart @@ -0,0 +1,37 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/identity_collection.dart'; + +/// A response to a fetch collection request. +/// +/// See https://jsonapi.org/format/#fetching-resources-responses +class CollectionResponse { + CollectionResponse(this.http, + {Iterable collection = const [], + Iterable included = const [], + Map links = const {}}) + : collection = IdentityCollection(collection), + included = IdentityCollection(included) { + this.links.addAll(links); + } + + static CollectionResponse decode(HttpResponse response) { + final doc = InboundDocument.decode(response.body); + return CollectionResponse(response, + collection: doc.resourceCollection(), + included: doc.included, + links: doc.links); + } + + /// Original HttpResponse + final HttpResponse http; + + /// The resource collection fetched from the server + final IdentityCollection collection; + + /// Included resources + final IdentityCollection included; + + /// Links to iterate the collection + final links = {}; +} diff --git a/lib/src/client/response/new_resource_response.dart b/lib/src/client/response/new_resource_response.dart new file mode 100644 index 00000000..8a89f977 --- /dev/null +++ b/lib/src/client/response/new_resource_response.dart @@ -0,0 +1,25 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/identity_collection.dart'; + +class NewResourceResponse { + NewResourceResponse(this.http, this.resource, + {Map links = const {}, + Iterable included = const []}) + : included = IdentityCollection(included) { + this.links.addAll(links); + } + + static NewResourceResponse decode(HttpResponse response) { + final doc = InboundDocument.decode(response.body); + return NewResourceResponse(response, doc.resource(), + links: doc.links, included: doc.included); + } + + /// Original HTTP response + final HttpResponse http; + + final Resource /*?*/ resource; + final IdentityCollection included; + final links = {}; +} diff --git a/lib/src/client/response/relationship_response.dart b/lib/src/client/response/relationship_response.dart new file mode 100644 index 00000000..8c02a8ff --- /dev/null +++ b/lib/src/client/response/relationship_response.dart @@ -0,0 +1,26 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/identity_collection.dart'; + +class RelationshipResponse { + RelationshipResponse(this.http, this.relationship, + {Iterable included = const []}) + : included = IdentityCollection(included); + + static RelationshipResponse decode( + HttpResponse response) { + final doc = InboundDocument.decode(response.body); + final rel = doc.dataAsRelationship(); + if (rel is T) { + return RelationshipResponse(response, rel, included: doc.included); + } + throw FormatException(); + } + + /// Original HTTP response + final HttpResponse http; + final T relationship; + + /// Included resources + final IdentityCollection included; +} diff --git a/lib/src/client/response/resource_response.dart b/lib/src/client/response/resource_response.dart new file mode 100644 index 00000000..8ca5c1bd --- /dev/null +++ b/lib/src/client/response/resource_response.dart @@ -0,0 +1,27 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/identity_collection.dart'; + +class ResourceResponse { + ResourceResponse(this.http, this.resource, + {Map links = const {}, + Iterable included = const []}) + : included = IdentityCollection(included) { + this.links.addAll(links); + } + + static ResourceResponse decode(HttpResponse response) { + if (response.isNoContent) return ResourceResponse(response, null); + final doc = InboundDocument.decode(response.body); + return ResourceResponse(response, doc.resource(), + links: doc.links, included: doc.included); + } + + /// Original HTTP response + final HttpResponse http; + + /// The created resource. Null for "204 No Content" responses. + final Resource /*?*/ resource; + final IdentityCollection included; + final links = {}; +} diff --git a/lib/src/client/uri_provider.dart b/lib/src/client/uri_provider.dart new file mode 100644 index 00000000..78599b4f --- /dev/null +++ b/lib/src/client/uri_provider.dart @@ -0,0 +1,3 @@ +import 'package:json_api/routing.dart'; + +typedef UriProvider = Uri Function(UriFactory design); diff --git a/lib/src/document.dart b/lib/src/document.dart deleted file mode 100644 index 37bfee62..00000000 --- a/lib/src/document.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:json_api_common/document.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// A generic response document -class Document { - Document(dynamic json) - : _json = json is Map - ? json - : throw ArgumentError('Invalid JSON'); - - final Map _json; - - bool has(String key) => _json.containsKey(key); - - Maybe get(String key) => Maybe(_json[key]); - - Maybe> meta() => - Maybe(_json['meta']).cast>(); - - Maybe> links() => readPath(['links']).map((_) => - _.map((key, value) => MapEntry(key.toString(), Link.fromJson(value)))); - - Maybe> included() => readPath(['included']) - .map((_) => _.map(Resource.fromJson).toList()); - - /// Returns the value at the [path] if both are true: - /// - the path exists - /// - the value is of type T - Maybe readPath(List path) => _path(path, Maybe(_json)); - - Maybe _path(List path, Maybe json) { - if (path.isEmpty) throw ArgumentError('Empty path'); - final value = json.flatMap((_) => Maybe(_[path.first])); - if (path.length == 1) return value.cast(); - return _path(path.sublist(1), value.cast()); - } -} diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart new file mode 100644 index 00000000..6698ef00 --- /dev/null +++ b/lib/src/document/error_object.dart @@ -0,0 +1,56 @@ +import 'package:json_api/src/document/error_source.dart'; +import 'package:json_api/src/document/link.dart'; + +/// [ErrorObject] represents an error occurred on the server. +/// +/// More on this: https://jsonapi.org/format/#errors +class ErrorObject { + /// Creates an instance of a JSON:API Error. + /// The [links] map may contain custom links. The about link + /// passed through the [links['about']] argument takes precedence and will overwrite + /// the `about` key in [links]. + ErrorObject( + {this.id = '', + this.status = '', + this.code = '', + this.title = '', + this.detail = '', + this.source = const ErrorSource()}); + + /// A unique identifier for this particular occurrence of the problem. + final String id; + + /// The HTTP status code applicable to this problem, expressed as a string value. + final String status; + + /// An application-specific error code, expressed as a string value. + final String code; + + /// A short, human-readable summary of the problem that SHOULD NOT change + /// from occurrence to occurrence of the problem, except for purposes of localization. + final String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + final String detail; + + /// Error source. + final ErrorSource source; + + /// Error links. + final links = {}; + + /// Meta data. + final meta = {}; + + Map toJson() => { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (source.isNotEmpty) 'source': source, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/error_source.dart b/lib/src/document/error_source.dart new file mode 100644 index 00000000..ae8c6cee --- /dev/null +++ b/lib/src/document/error_source.dart @@ -0,0 +1,19 @@ +/// An object containing references to the source of the error. +class ErrorSource { + const ErrorSource({this.pointer = '', this.parameter = ''}); + + /// A JSON Pointer [RFC6901] to the associated entity in the request document. + final String pointer; + + /// A string indicating which URI query parameter caused the error. + final String parameter; + + bool get isEmpty => pointer.isEmpty && parameter.isEmpty; + + bool get isNotEmpty => !isEmpty; + + Map toJson() => { + if (parameter.isNotEmpty) 'parameter': parameter, + if (pointer.isNotEmpty) 'pointer': pointer + }; +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart new file mode 100644 index 00000000..f12a4476 --- /dev/null +++ b/lib/src/document/identifier.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/document/identity.dart'; + +/// A Resource Identifier object +class Identifier with Identity { + Identifier(this.type, this.id); + + @override + final String type; + + @override + final String id; + + final meta = {}; + + Map toJson() => + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; +} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart new file mode 100644 index 00000000..1a6fc514 --- /dev/null +++ b/lib/src/document/identity.dart @@ -0,0 +1,11 @@ +/// Resource identity. +mixin Identity { + /// Resource type + String get type; + + /// Resource id + String get id; + + /// Compound key, uniquely identifying the resource + String get key => '$type:$id'; +} diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart new file mode 100644 index 00000000..fc509a61 --- /dev/null +++ b/lib/src/document/inbound_document.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/error_source.dart'; +import 'package:json_api/src/document/relationship/many.dart'; +import 'package:json_api/src/document/relationship/one.dart'; +import 'package:json_api/src/extensions.dart'; +import 'package:json_api/src/nullable.dart'; + +/// A generic inbound JSON:API document +class InboundDocument { + InboundDocument(this._json) { + included.addAll(_json + .get('included', orGet: () => []) + .whereType() + .map(_resource)); + + errors.addAll(_json + .get('errors', orGet: () => []) + .whereType() + .map(_errorObject)); + + meta.addAll(_meta(_json)); + + links.addAll(_links(_json)); + } + + static InboundDocument decode(String body) { + final json = jsonDecode(body); + if (json is Map) return InboundDocument(json); + throw FormatException('Invalid JSON body'); + } + + final Map _json; + + /// Included resources + final included = []; + + /// Error objects + final errors = []; + + /// Document meta + final meta = {}; + + /// Document links + final links = {}; + + Iterable resourceCollection() => + _json.get('data').whereType().map(_resource); + + Resource resource() => + _resource(_json.get>('data')); + + NewResource newResource() => + _newResource(_json.get>('data')); + + Resource /*?*/ nullableResource() { + return nullable(_resource)(_json.getNullable('data')); + } + + Relationship dataAsRelationship() => _relationship(_json); + + static Map _links(Map json) => json + .get('links', orGet: () => {}) + .map((k, v) => MapEntry(k.toString(), _link(v))); + + static Relationship _relationship(Map json) { + final links = _links(json); + final meta = _meta(json); + if (json.containsKey('data')) { + final data = json['data']; + if (data == null) { + return One.empty()..links.addAll(links)..meta.addAll(meta); + } + if (data is Map) { + return One(_identifier(data))..links.addAll(links)..meta.addAll(meta); + } + if (data is List) { + return Many(data.whereType().map(_identifier)) + ..links.addAll(links) + ..meta.addAll(meta); + } + throw FormatException('Invalid relationship object'); + } + return Relationship()..links.addAll(links)..meta.addAll(meta); + } + + static Map _meta(Map json) => + json.get>('meta', orGet: () => {}); + + static Resource _resource(Map json) => + Resource(json.get('type'), json.get('id')) + ..attributes.addAll(_getAttributes(json)) + ..relationships.addAll(_getRelationships(json)) + ..links.addAll(_links(json)) + ..meta.addAll(_meta(json)); + + static NewResource _newResource(Map json) => NewResource( + json.get('type'), + json.get('id', orGet: () => null)) + ..attributes.addAll(_getAttributes(json)) + ..relationships.addAll(_getRelationships(json)) + ..meta.addAll(_meta(json)); + + /// Decodes Identifier from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + static Identifier _identifier(Map json) => + Identifier(json.get('type'), json.get('id')) + ..meta.addAll(_meta(json)); + + static ErrorObject _errorObject(Map json) => ErrorObject( + id: json.get('id', orGet: () => ''), + status: json.get('status', orGet: () => ''), + code: json.get('code', orGet: () => ''), + title: json.get('title', orGet: () => ''), + detail: json.get('detail', orGet: () => ''), + source: _errorSource(json.get('source', orGet: () => {}))) + ..meta.addAll(_meta(json)) + ..links.addAll(_links(json)); + + /// Decodes ErrorSource from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + static ErrorSource _errorSource(Map json) => ErrorSource( + pointer: json.get('pointer', orGet: () => ''), + parameter: json.get('parameter', orGet: () => '')); + + /// Decodes Link from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + static Link _link(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']))..meta.addAll(_meta(json)); + } + throw FormatException('Invalid JSON'); + } + + static Map _getAttributes(Map json) => + json.get>('attributes', orGet: () => {}); + + static Map _getRelationships(Map json) => json + .get('relationships', orGet: () => {}) + .map((key, value) => MapEntry(key, _relationship(value))); +} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart new file mode 100644 index 00000000..1655d6a0 --- /dev/null +++ b/lib/src/document/link.dart @@ -0,0 +1,17 @@ +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + Link(this.uri); + + /// Link URL + final Uri uri; + + /// Link meta data + final meta = {}; + + @override + String toString() => uri.toString(); + + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; +} diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart new file mode 100644 index 00000000..8d6dcceb --- /dev/null +++ b/lib/src/document/new_resource.dart @@ -0,0 +1,22 @@ +import 'package:json_api/src/document/resource_properties.dart'; + +/// A set of properties for a to-be-created resource which does not have the id yet. +class NewResource with ResourceProperties { + NewResource(this.type, [this.id]) { + ArgumentError.checkNotNull(type); + } + + /// Resource type + final String type; + + /// Nullable. Resource id. + final String /*?*/ id; + + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart new file mode 100644 index 00000000..bc1f6eda --- /dev/null +++ b/lib/src/document/outbound_document.dart @@ -0,0 +1,70 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource.dart'; + +/// An empty outbound document. +class OutboundDocument { + /// Creates an instance of a document containing a single resource as the primary data. + static OutboundDataDocument resource(Resource resource) => + OutboundDataDocument._(resource); + + /// Creates an instance of a document containing a collection of resources as the primary data. + static OutboundDataDocument> collection( + Iterable collection) => + OutboundDataDocument._(collection.toList()); + + /// Creates an instance of a document containing a to-one relationship. + static OutboundDataDocument one(One one) => + OutboundDataDocument._(one.identifier) + ..meta.addAll(one.meta) + ..links.addAll(one.links); + + /// Creates an instance of a document containing a to-many relationship. + static OutboundDataDocument> many(Many many) => + OutboundDataDocument._(many.toList()) + ..meta.addAll(many.meta) + ..links.addAll(many.links); + + static OutboundErrorDocument error(Iterable errors) => + OutboundErrorDocument._()..errors.addAll(errors); + + /// The document "meta" object. + final meta = {}; + + Map toJson() => {'meta': meta}; +} + +/// An outbound error document. +class OutboundErrorDocument extends OutboundDocument { + OutboundErrorDocument._(); + + /// The list of errors. + final errors = []; + + @override + Map toJson() => { + 'errors': errors, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +/// An outbound data document. +class OutboundDataDocument extends OutboundDocument { + OutboundDataDocument._(this.data); + + final D data; + + /// Links related to the primary data. + final links = {}; + + /// A list of included resources. + final included = []; + + @override + Map toJson() => { + 'data': data, + if (links.isNotEmpty) 'links': links, + if (included.isNotEmpty) 'included': included, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/relationship/many.dart b/lib/src/document/relationship/many.dart new file mode 100644 index 00000000..dede68b5 --- /dev/null +++ b/lib/src/document/relationship/many.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/relationship/relationship.dart'; + +class Many extends Relationship { + Many(Iterable identifiers) { + identifiers.forEach((_) => _map[_.key] = _); + } + + final _map = {}; + + @override + Map toJson() => + {'data': _map.values.toList(), ...super.toJson()}; + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/document/relationship/one.dart b/lib/src/document/relationship/one.dart new file mode 100644 index 00000000..7d46fc5b --- /dev/null +++ b/lib/src/document/relationship/one.dart @@ -0,0 +1,18 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/relationship/relationship.dart'; + +class One extends Relationship { + One(Identifier /*!*/ identifier) : identifier = identifier; + + One.empty() : identifier = null; + + @override + Map toJson() => {'data': identifier, ...super.toJson()}; + + /// Nullable + final Identifier /*?*/ identifier; + + @override + Iterator get iterator => + identifier == null ? [].iterator : [identifier].iterator; +} diff --git a/lib/src/document/relationship/relationship.dart b/lib/src/document/relationship/relationship.dart new file mode 100644 index 00000000..5e61f73d --- /dev/null +++ b/lib/src/document/relationship/relationship.dart @@ -0,0 +1,17 @@ +import 'dart:collection'; + +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/link.dart'; + +class Relationship with IterableMixin { + final links = {}; + final meta = {}; + + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; + + @override + Iterator get iterator => const [].iterator; +} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart new file mode 100644 index 00000000..edef7345 --- /dev/null +++ b/lib/src/document/resource.dart @@ -0,0 +1,30 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_properties.dart'; + +class Resource with ResourceProperties, Identity { + Resource(this.type, this.id) { + ArgumentError.checkNotNull(type); + ArgumentError.checkNotNull(id); + } + + @override + final String type; + + @override + final String id; + + final links = {}; + + Identifier get identifier => Identifier(type, id); + + Map toJson() => { + 'type': type, + 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart new file mode 100644 index 00000000..f3df6da9 --- /dev/null +++ b/lib/src/document/resource_properties.dart @@ -0,0 +1,16 @@ +import 'package:json_api/src/document/relationship/relationship.dart'; + +mixin ResourceProperties { + /// Resource meta data. + final meta = {}; + + /// Resource attributes. + /// + /// See https://jsonapi.org/format/#document-resource-object-attributes + final attributes = {}; + + /// Resource relationships. + /// + /// See https://jsonapi.org/format/#document-resource-object-relationships + final relationships = {}; +} diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart new file mode 100644 index 00000000..be75c4f9 --- /dev/null +++ b/lib/src/extensions.dart @@ -0,0 +1,23 @@ +extension TypedGetter on Map { + T get(String key, {T Function() /*?*/ orGet}) { + if (containsKey(key)) { + final val = this[key]; + if (val is T) return val; + throw FormatException( + 'Key "$key": expected $T, found ${val.runtimeType}'); + } + if (orGet != null) return orGet(); + throw FormatException('Key "$key" does not exist'); + } + + T /*?*/ getNullable(String key, {T /*?*/ Function() /*?*/ orGet}) { + if (containsKey(key)) { + final val = this[key]; + if (val is T || val == null) return val; + throw FormatException( + 'Key "$key": expected $T, found ${val.runtimeType}'); + } + if (orGet != null) return orGet(); + throw FormatException('Key "$key" does not exist'); + } +} diff --git a/lib/src/http/headers.dart b/lib/src/http/headers.dart new file mode 100644 index 00000000..c9cab96d --- /dev/null +++ b/lib/src/http/headers.dart @@ -0,0 +1,29 @@ +import 'dart:collection'; + +/// HTTP headers. All keys are converted to lowercase on the fly. +class Headers with MapMixin { + Headers([Map headers = const {}]) { + addAll(headers); + } + + final _ = {}; + + @override + String /*?*/ operator [](Object /*?*/ key) => + key is String ? _[_convert(key)] : null; + + @override + void operator []=(String key, String value) => _[_convert(key)] = value; + + @override + void clear() => _.clear(); + + @override + Iterable get keys => _.keys; + + @override + String /*?*/ remove(Object /*?*/ key) => + _.remove(key is String ? _convert(key) : key); + + String _convert(String s) => s.toLowerCase(); +} diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart new file mode 100644 index 00000000..9f941129 --- /dev/null +++ b/lib/src/http/http_handler.dart @@ -0,0 +1,23 @@ +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A callable class which converts requests to responses +abstract class HttpHandler { + /// Sends the request over the network and returns the received response + Future call(HttpRequest request); + + /// Creates an instance of [HttpHandler] from a function + static HttpHandler fromFunction(HttpHandlerFunc f) => _HandlerFromFunction(f); +} + +/// This typedef is compatible with [HttpHandler] +typedef HttpHandlerFunc = Future Function(HttpRequest request); + +class _HandlerFromFunction implements HttpHandler { + const _HandlerFromFunction(this._f); + + @override + Future call(HttpRequest request) => _f(request); + + final HttpHandlerFunc _f; +} diff --git a/lib/src/http/http_logger.dart b/lib/src/http/http_logger.dart new file mode 100644 index 00000000..70fd03fa --- /dev/null +++ b/lib/src/http/http_logger.dart @@ -0,0 +1,7 @@ +import 'package:json_api/http.dart'; + +abstract class HttpLogger { + void onRequest(HttpRequest /*!*/ request); + + void onResponse(HttpResponse /*!*/ response); +} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart new file mode 100644 index 00000000..321f33b2 --- /dev/null +++ b/lib/src/http/http_request.dart @@ -0,0 +1,32 @@ +import 'package:json_api/src/http/headers.dart'; + +/// The request which is sent by the client and received by the server +class HttpRequest { + HttpRequest(String method, this.uri, + {this.body = '', Map headers = const {}}) + : method = method.toLowerCase() { + this.headers.addAll(headers); + } + + /// Requested URI + final Uri uri; + + /// Request method, lowercase + final String method; + + /// Request body + final String body; + + /// Request headers. Lowercase keys + final headers = Headers(); + + bool get isGet => method == 'get'; + + bool get isPost => method == 'post'; + + bool get isDelete => method == 'delete'; + + bool get isPatch => method == 'patch'; + + bool get isOptions => method == 'options'; +} diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart new file mode 100644 index 00000000..d2b972f9 --- /dev/null +++ b/lib/src/http/http_response.dart @@ -0,0 +1,38 @@ +import 'package:json_api/src/http/headers.dart'; +import 'package:json_api/src/http/media_type.dart'; + +/// The response sent by the server and received by the client +class HttpResponse { + HttpResponse(this.statusCode, + {this.body = '', Map headers = const {}}) { + this.headers.addAll(headers); + } + + /// Response status code + final int statusCode; + + /// Response body + final String body; + + /// Response headers. Lowercase keys + final headers = Headers(); + + /// True for the requests processed asynchronously. + /// @see https://jsonapi.org/recommendations/#asynchronous-processing). + bool get isPending => statusCode == 202; + + /// True for successfully processed requests + bool get isSuccessful => statusCode >= 200 && statusCode < 300 && !isPending; + + /// True for failed requests (i.e. neither successful nor pending) + bool get isFailed => !isSuccessful && !isPending; + + /// True for 204 No Content responses + bool get isNoContent => statusCode == 204; + + bool get hasDocument => + body.isNotEmpty && + (headers['content-type'] ?? '') + .toLowerCase() + .startsWith(MediaType.jsonApi); +} diff --git a/lib/src/http/last_value_logger.dart b/lib/src/http/last_value_logger.dart new file mode 100644 index 00000000..8ce77e38 --- /dev/null +++ b/lib/src/http/last_value_logger.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/http/http_logger.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +class LastValueLogger implements HttpLogger { + @override + void onRequest(HttpRequest request) => this.request = request; + + @override + void onResponse(HttpResponse response) => this.response = response; + + /// Last received response or null. + HttpResponse /*?*/ response; + + /// Last sent request or null. + HttpRequest /*?*/ request; +} diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart new file mode 100644 index 00000000..0df78328 --- /dev/null +++ b/lib/src/http/logging_http_handler.dart @@ -0,0 +1,20 @@ +import 'package:json_api/src/http/http_handler.dart'; +import 'package:json_api/src/http/http_logger.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A wrapper over [HttpHandler] which allows logging +class LoggingHttpHandler implements HttpHandler { + LoggingHttpHandler(this._handler, this._logger); + + final HttpHandler _handler; + final HttpLogger _logger; + + @override + Future call(HttpRequest request) async { + _logger.onRequest(request); + final response = await _handler(request); + _logger.onResponse(response); + return response; + } +} diff --git a/lib/src/content_type.dart b/lib/src/http/media_type.dart similarity index 73% rename from lib/src/content_type.dart rename to lib/src/http/media_type.dart index 1e03a7d9..facf0de6 100644 --- a/lib/src/content_type.dart +++ b/lib/src/http/media_type.dart @@ -1,3 +1,3 @@ -class ContentType { +class MediaType { static const jsonApi = 'application/vnd.api+json'; } diff --git a/lib/src/identity_collection.dart b/lib/src/identity_collection.dart deleted file mode 100644 index fcd90a13..00000000 --- a/lib/src/identity_collection.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api_common/document.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class IdentityCollection with IterableMixin { - IdentityCollection(Iterable resources) - : _map = Map.fromIterable(resources, key: (_) => _.key); - - final Map _map; - - Maybe get(String key) => Maybe(_map[key]); - - @override - Iterator get iterator => _map.values.iterator; -} diff --git a/lib/src/json_api_client.dart b/lib/src/json_api_client.dart deleted file mode 100644 index 92d7bc86..00000000 --- a/lib/src/json_api_client.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'package:json_api/json_api.dart'; -import 'package:json_api/src/response/create_resource.dart'; -import 'package:json_api/src/response/delete_resource.dart'; -import 'package:json_api/src/response/fetch_collection.dart'; -import 'package:json_api/src/response/fetch_primary_resource.dart'; -import 'package:json_api/src/response/fetch_related_resource.dart'; -import 'package:json_api/src/response/fetch_relationship.dart'; -import 'package:json_api/src/response/request_failure.dart'; -import 'package:json_api/src/response/update_relationship.dart'; -import 'package:json_api/src/response/update_resource.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; -import 'package:json_api_common/url_design.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// The JSON:API client -class JsonApiClient { - JsonApiClient(this._http, this._url); - - final HttpHandler _http; - final UrlDesign _url; - - /// Fetches a primary resource collection by [type]. - Future fetchCollection(String type, - {Map headers, - Iterable include, - Map> fields, - Iterable sort, - Map page, - Map query}) async => - FetchCollection.decode(await call( - JsonApiRequest('get', - headers: headers, - include: include, - fields: fields, - sort: sort, - page: page, - query: query), - _url.collection(type))); - - /// Fetches a related resource collection by [type], [id], [relationship]. - Future fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, - Iterable include, - Map> fields, - Iterable sort, - Map page, - Map query}) async => - FetchCollection.decode(await call( - JsonApiRequest('get', - headers: headers, - include: include, - fields: fields, - sort: sort, - page: page, - query: query), - _url.related(type, id, relationship))); - - /// Fetches a primary resource by [type] and [id]. - Future fetchResource(String type, String id, - {Map headers, - Iterable include, - Map> fields, - Map query}) async => - FetchPrimaryResource.decode(await call( - JsonApiRequest('get', - headers: headers, include: include, fields: fields, query: query), - _url.resource(type, id))); - - /// Fetches a related resource by [type], [id], [relationship]. - Future fetchRelatedResource( - String type, String id, String relationship, - {Map headers, - Iterable include, - Map> fields, - Map query}) async => - FetchRelatedResource.decode(await call( - JsonApiRequest('get', - headers: headers, include: include, fields: fields, query: query), - _url.related(type, id, relationship))); - - /// Fetches a relationship by [type], [id], [relationship]. - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers, Map query}) async => - FetchRelationship.decode(await call( - JsonApiRequest('get', headers: headers, query: query), - _url.relationship(type, id, relationship))); - - /// Creates a new resource on the server. - /// The server is expected to assign the resource id. - Future createNewResource(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async => - CreateResource.decode(await call( - JsonApiRequest('POST', headers: headers, document: { - 'data': NewResource(type, - attributes: attributes, - relationships: _relationships(one, many)) - .toJson() - }), - _url.collection(type))); - - /// Creates a resource on the server. - /// The server is expected to accept the provided resource id. - Future createResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async => - UpdateResource.decode(await call( - JsonApiRequest('post', - headers: headers, - document: _resource(type, id, attributes, one, many)), - _url.collection(type))); - - /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, - {Map headers}) async => - DeleteResource.decode(await call( - JsonApiRequest('DELETE', headers: headers), _url.resource(type, id))); - - /// Updates the resource by [type] and [id]. - Future updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map headers}) async => - UpdateResource.decode(await call( - JsonApiRequest('patch', - headers: headers, - document: _resource(type, id, attributes, one, many)), - _url.resource(type, id))); - - /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceOne( - String type, String id, String relationship, String identifier, - {Map headers}) async => - UpdateRelationship.decode(await call( - JsonApiRequest('patch', - headers: headers, document: One(Identifier.fromKey(identifier))), - _url.relationship(type, id, relationship))); - - /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteOne( - String type, String id, String relationship, - {Map headers}) async => - UpdateRelationship.decode(await call( - JsonApiRequest('patch', headers: headers, document: One.empty()), - _url.relationship(type, id, relationship))); - - /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) async => - UpdateRelationship.decode(await call( - JsonApiRequest('delete', - headers: headers, - document: Many(identifiers.map(Identifier.fromKey))), - _url.relationship(type, id, relationship))); - - /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) async => - UpdateRelationship.decode(await call( - JsonApiRequest('patch', - headers: headers, - document: Many(identifiers.map(Identifier.fromKey))), - _url.relationship(type, id, relationship))); - - /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers = const {}}) async => - UpdateRelationship.decode(await call( - JsonApiRequest('post', - headers: headers, - document: Many(identifiers.map(Identifier.fromKey))), - _url.relationship(type, id, relationship))); - - /// Sends the [request] to [uri]. - /// If the response is successful, returns the [HttpResponse]. - /// Otherwise, throws a [RequestFailure]. - Future call(JsonApiRequest request, Uri uri) async { - final response = await _http.call(HttpRequest( - request.method, - request.query.isEmpty - ? uri - : uri.replace(queryParameters: request.query), - body: request.body, - headers: request.headers)); - if (StatusCode(response.statusCode).isFailed) { - throw RequestFailure.decode(response); - } - return response; - } - - Object _resource(String type, String id, Map attributes, - Map one, Map> many) => - { - 'data': Resource(type, id, - attributes: attributes, - relationships: _relationships(one, many)) - .toJson() - }; - - Map _relationships( - Map one, Map> many) => - { - ...one.map((key, value) => MapEntry( - key, - Maybe(value) - .filter((_) => _.isNotEmpty) - .map(Identifier.fromKey) - .map((_) => One(_)) - .orGet(() => One.empty()))), - ...many.map( - (key, value) => MapEntry(key, Many(value.map(Identifier.fromKey)))) - }; -} diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart new file mode 100644 index 00000000..1476f3ab --- /dev/null +++ b/lib/src/nullable.dart @@ -0,0 +1,2 @@ +U /*?*/ Function(V /*?*/ v) nullable(U Function(V v) f) => + (v) => v == null ? null : f(v); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart new file mode 100644 index 00000000..02621afd --- /dev/null +++ b/lib/src/query/fields.dart @@ -0,0 +1,45 @@ +import 'dart:collection'; + +/// Query parameters defining Sparse Fieldsets +/// @see https://jsonapi.org/format/#fetching-sparse-fieldsets +class Fields with MapMixin> { + /// The [fields] argument maps the resource type to a list of fields. + /// + /// Example: + /// ```dart + /// Fields({'articles': ['title', 'body'], 'people': ['name']}); + /// ``` + Fields([Map> fields = const {}]) { + addAll(fields); + } + + /// Extracts the requested fields from the [uri]. + static Fields fromUri(Uri uri) => + Fields(uri.queryParametersAll.map((k, v) => MapEntry( + _regex.firstMatch(k)?.group(1) ?? '', + v.expand((_) => _.split(',')).toList())) + ..removeWhere((k, v) => k.isEmpty)); + + static final _regex = RegExp(r'^fields\[(.+)\]$'); + + final _map = >{}; + + /// Converts to a map of query parameters + Map get asQueryParameters => + _map.map((k, v) => MapEntry('fields[$k]', v.join(','))); + + @override + void operator []=(String key, List value) => _map[key] = value; + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + List /*?*/ remove(Object /*?*/ key) => _map.remove(key); + + @override + List /*?*/ operator [](Object /*?*/ key) => _map[key]; +} diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart new file mode 100644 index 00000000..4870a3db --- /dev/null +++ b/lib/src/query/filter.dart @@ -0,0 +1,42 @@ +import 'dart:collection'; + +class Filter with MapMixin { + /// Example: + /// ```dart + /// Filter({'post': '1,2', 'author': '12'}).addTo(url); + /// ``` + /// encodes into + /// ``` + /// ?filter[post]=1,2&filter[author]=12 + /// ``` + Filter([Map parameters = const {}]) { + addAll(parameters); + } + + static Filter fromUri(Uri uri) => Filter(uri.queryParametersAll + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty)); + + static final _regex = RegExp(r'^filter\[(.+)\]$'); + + final _ = {}; + + /// Converts to a map of query parameters + Map get asQueryParameters => + _.map((k, v) => MapEntry('filter[${k}]', v)); + + @override + String /*?*/ operator [](Object /*?*/ key) => _[key]; + + @override + void operator []=(String key, String value) => _[key] = value; + + @override + void clear() => _.clear(); + + @override + Iterable get keys => _.keys; + + @override + String /*?*/ remove(Object /*?*/ key) => _.remove(key); +} diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart new file mode 100644 index 00000000..a2fb7d63 --- /dev/null +++ b/lib/src/query/include.dart @@ -0,0 +1,27 @@ +import 'dart:collection'; + +/// Query parameter defining inclusion of related resources. +/// @see https://jsonapi.org/format/#fetching-includes +class Include with IterableMixin { + /// Example: + /// ```dart + /// Include(['comments', 'comments.author']); + /// ``` + Include([Iterable resources = const []]) { + _.addAll(resources); + } + + static Include fromUri(Uri uri) => Include( + uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? []); + + final _ = []; + + /// Converts to a map of query parameters + Map get asQueryParameters => {'include': join(',')}; + + @override + Iterator get iterator => _.iterator; + + @override + int get length => _.length; +} diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart new file mode 100644 index 00000000..1a730401 --- /dev/null +++ b/lib/src/query/page.dart @@ -0,0 +1,44 @@ +import 'dart:collection'; + +/// Query parameters defining the pagination data. +/// @see https://jsonapi.org/format/#fetching-pagination +class Page with MapMixin { + /// Example: + /// ```dart + /// Page({'limit': '10', 'offset': '20'}).addTo(url); + /// ``` + /// encodes into + /// ``` + /// ?page[limit]=10&page[offset]=20 + /// ``` + /// + Page([Map parameters = const {}]) { + addAll(parameters); + } + + static Page fromUri(Uri uri) => Page(uri.queryParametersAll + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty)); + static final _regex = RegExp(r'^page\[(.+)\]$'); + + final _ = {}; + + /// Converts to a map of query parameters + Map get asQueryParameters => + _.map((k, v) => MapEntry('page[${k}]', v)); + + @override + String /*?*/ operator [](Object /*?*/ key) => _[key]; + + @override + void operator []=(String key, String value) => _[key] = value; + + @override + void clear() => _.clear(); + + @override + Iterable get keys => _.keys; + + @override + String /*?*/ remove(Object /*?*/ key) => _.remove(key); +} diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart new file mode 100644 index 00000000..bae7877d --- /dev/null +++ b/lib/src/query/sort.dart @@ -0,0 +1,66 @@ +import 'dart:collection'; + +/// Query parameters defining the sorting. +/// @see https://jsonapi.org/format/#fetching-sorting +class Sort with IterableMixin { + /// The [fields] arguments is the list of sorting criteria. + /// + /// Example: + /// ```dart + /// Sort(['-created', 'title']); + /// ``` + Sort([Iterable fields = const []]) { + _.addAll(fields.map((SortField.parse))); + } + + static Sort fromUri(Uri uri) => + Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? [])); + + final _ = []; + + /// Converts to a map of query parameters + Map get asQueryParameters => {'sort': join(',')}; + + @override + int get length => _.length; + + @override + Iterator get iterator => _.iterator; +} + +abstract class SortField { + static SortField parse(String queryParam) => queryParam.startsWith('-') + ? Desc(queryParam.substring(1)) + : Asc(queryParam); + + String get name; + + /// Returns 1 for Ascending fields, -1 for Descending + int get factor; +} + +class Asc implements SortField { + const Asc(this.name); + + @override + final String name; + + @override + final int factor = 1; + + @override + String toString() => name; +} + +class Desc implements SortField { + const Desc(this.name); + + @override + final String name; + + @override + final int factor = -1; + + @override + String toString() => '-$name'; +} diff --git a/lib/src/request.dart b/lib/src/request.dart deleted file mode 100644 index bbac5c03..00000000 --- a/lib/src/request.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/content_type.dart'; -import 'package:json_api_common/query.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -/// A JSON:API HTTP request. -class JsonApiRequest { - /// Created an instance of JSON:API HTTP request. - /// - /// - [method] - the HTTP method - /// - [document] - if passed, will be JSON-encoded and sent in the HTTP body - /// - [headers] - any arbitrary HTTP headers - /// - [include] - related resources to include (for GET requests) - /// - [fields] - sparse fieldsets (for GET requests) - /// - [sort] - sorting options (for GET collection requests) - /// - [page] - pagination options (for GET collection requests) - /// - [query] - any arbitrary query parameters (for GET requests) - JsonApiRequest(String method, - {Object document, - Map headers, - Iterable include, - Map> fields, - Iterable sort, - Map page, - Map query}) - : method = method.toLowerCase(), - body = Maybe(document).map(jsonEncode).or(''), - query = Map.unmodifiable({ - if (include != null) ...Include(include).asQueryParameters, - if (fields != null) ...Fields(fields).asQueryParameters, - if (sort != null) ...Sort(sort).asQueryParameters, - if (page != null) ...Page(page).asQueryParameters, - ...?query, - }), - headers = Map.unmodifiable({ - 'accept': ContentType.jsonApi, - if (document != null) 'content-type': ContentType.jsonApi, - ...?headers, - }); - - /// HTTP method, lowercase. - final String method; - - /// HTTP body. - final String body; - - /// HTTP headers. - final Map headers; - - /// Map of query parameters. - final Map query; -} diff --git a/lib/src/response/create_resource.dart b/lib/src/response/create_resource.dart deleted file mode 100644 index 5663e83f..00000000 --- a/lib/src/response/create_resource.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/json_api.dart'; -import 'package:json_api/src/document.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; - -class CreateResource { - CreateResource(this.resource, {Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}); - - static CreateResource decode(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return CreateResource( - document - .get('data') - .map(Resource.fromJson) - .orThrow(() => FormatException('Invalid response')), - links: document.links().or(const {})); - } - - final Map links; - final Resource resource; -} diff --git a/lib/src/response/delete_resource.dart b/lib/src/response/delete_resource.dart deleted file mode 100644 index 5dd93951..00000000 --- a/lib/src/response/delete_resource.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/document.dart'; -import 'package:json_api_common/http.dart'; - -class DeleteResource { - DeleteResource({Map meta = const {}}) - : meta = Map.unmodifiable(meta ?? const {}); - - static DeleteResource decode(HttpResponse http) => DeleteResource( - meta: http.body.isEmpty - ? const {} - : Document(jsonDecode(http.body)).meta().or(const {})); - - final Map meta; -} diff --git a/lib/src/response/fetch_collection.dart b/lib/src/response/fetch_collection.dart deleted file mode 100644 index 8efe295d..00000000 --- a/lib/src/response/fetch_collection.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:json_api/src/document.dart'; -import 'package:json_api/src/identity_collection.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; - -class FetchCollection with IterableMixin { - FetchCollection( - {Iterable resources = const [], - Iterable included = const [], - Map links = const {}}) - : resources = resources ?? IdentityCollection(const []), - links = Map.unmodifiable(links ?? const {}), - included = included ?? IdentityCollection(const []); - - static FetchCollection decode(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchCollection( - resources: IdentityCollection(document - .get('data') - .cast() - .map((_) => _.map(Resource.fromJson)) - .or(const [])), - included: IdentityCollection(document.included().or([])), - links: document.links().or(const {})); - } - - final IdentityCollection resources; - final IdentityCollection included; - final Map links; - - @override - Iterator get iterator => resources.iterator; -} diff --git a/lib/src/response/fetch_primary_resource.dart b/lib/src/response/fetch_primary_resource.dart deleted file mode 100644 index 70044849..00000000 --- a/lib/src/response/fetch_primary_resource.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/document.dart'; -import 'package:json_api/src/identity_collection.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; - -class FetchPrimaryResource { - FetchPrimaryResource(this.resource, - {Iterable included = const [], - Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}), - included = IdentityCollection(included ?? const []); - - static FetchPrimaryResource decode(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchPrimaryResource( - document - .get('data') - .map(Resource.fromJson) - .orThrow(() => ArgumentError('Invalid response')), - included: IdentityCollection(document.included().or([])), - links: document.links().or(const {})); - } - - final Resource resource; - final IdentityCollection included; - final Map links; -} diff --git a/lib/src/response/fetch_related_resource.dart b/lib/src/response/fetch_related_resource.dart deleted file mode 100644 index cb092342..00000000 --- a/lib/src/response/fetch_related_resource.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/document.dart'; -import 'package:json_api/src/identity_collection.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class FetchRelatedResource { - FetchRelatedResource(this.resource, - {IdentityCollection included, Map links = const {}}) - : links = Map.unmodifiable(links ?? const {}), - included = included ?? IdentityCollection(const []); - - static FetchRelatedResource decode(HttpResponse http) { - final document = Document(jsonDecode(http.body)); - return FetchRelatedResource(document.get('data').map(Resource.fromJson), - included: IdentityCollection(document.included().or([])), - links: document.links().or(const {})); - } - - final Maybe resource; - final IdentityCollection included; - final Map links; -} diff --git a/lib/src/response/fetch_relationship.dart b/lib/src/response/fetch_relationship.dart deleted file mode 100644 index baf8b994..00000000 --- a/lib/src/response/fetch_relationship.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; - -class FetchRelationship { - FetchRelationship(this.relationship); - - static FetchRelationship decode( - HttpResponse http) => - FetchRelationship(Relationship.fromJson(jsonDecode(http.body)).as()); - - final R relationship; -} diff --git a/lib/src/response/request_failure.dart b/lib/src/response/request_failure.dart deleted file mode 100644 index 405aa544..00000000 --- a/lib/src/response/request_failure.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/json_api.dart'; -import 'package:json_api_common/http.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class RequestFailure { - RequestFailure(this.http, {Iterable errors = const []}) - : errors = List.unmodifiable(errors ?? const []); - - static RequestFailure decode(HttpResponse http) { - if (http.body.isEmpty || - http.headers['content-type'] != ContentType.jsonApi) { - return RequestFailure(http); - } - - return RequestFailure(http, - errors: Just(http.body) - .filter((_) => _.isNotEmpty) - .map(jsonDecode) - .cast() - .flatMap((_) => Maybe(_['errors'])) - .cast() - .map((_) => _.map(ErrorObject.fromJson)) - .or([])); - } - - final List errors; - - final HttpResponse http; -} diff --git a/lib/src/response/update_relationship.dart b/lib/src/response/update_relationship.dart deleted file mode 100644 index 7def346a..00000000 --- a/lib/src/response/update_relationship.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class UpdateRelationship { - UpdateRelationship({R relationship}) : relationship = Maybe(relationship); - - static UpdateRelationship decode( - HttpResponse http) => - Maybe(http.body) - .filter((_) => _.isNotEmpty) - .map(jsonDecode) - .map(Relationship.fromJson) - .cast() - .map((_) => UpdateRelationship(relationship: _)) - .orGet(() => UpdateRelationship()); - - final Maybe relationship; -} diff --git a/lib/src/response/update_resource.dart b/lib/src/response/update_resource.dart deleted file mode 100644 index 84ed1f29..00000000 --- a/lib/src/response/update_resource.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/document.dart'; -import 'package:json_api_common/document.dart'; -import 'package:json_api_common/http.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; - -class UpdateResource { - UpdateResource(Resource resource, {Map links = const {}}) - : resource = Just(resource), - links = Map.unmodifiable(links ?? const {}); - - UpdateResource.empty() - : resource = Nothing(), - links = const {}; - - static UpdateResource decode(HttpResponse http) { - if (http.body.isEmpty) { - return UpdateResource.empty(); - } - final document = Document(jsonDecode(http.body)); - return UpdateResource( - document - .get('data') - .map(Resource.fromJson) - .orThrow(() => ArgumentError('Invalid response')), - links: document.links().or(const {})); - } - - final Map links; - final Maybe resource; -} diff --git a/lib/src/routing/recommended_url_design.dart b/lib/src/routing/recommended_url_design.dart new file mode 100644 index 00000000..4d1fceed --- /dev/null +++ b/lib/src/routing/recommended_url_design.dart @@ -0,0 +1,65 @@ +import 'package:json_api/routing.dart'; +import 'package:json_api/src/routing/target_matcher.dart'; + +/// URL Design recommended by the standard. +/// See https://jsonapi.org/recommendations/#urls +class RecommendedUrlDesign implements UriFactory, TargetMatcher { + /// Creates an instance of RecommendedUrlDesign. + /// The [base] URI will be used as a prefix for the generated URIs. + /// + /// To generate URIs without a hostname, pass `Uri(path: '/')` as [base]. + const RecommendedUrlDesign(this.base); + + /// A "path only" version of the recommended URL design, e.g. + /// `/books`, `/books/42`, `/books/42/authors` + static final pathOnly = RecommendedUrlDesign(Uri(path: '/')); + + final Uri base; + + /// Returns a URL for the primary resource collection of type [type]. + /// E.g. `/books`. + @override + Uri collection(CollectionTarget target) => _resolve([target.type]); + + /// Returns a URL for the primary resource of type [type] with id [id]. + /// E.g. `/books/123`. + @override + Uri resource(ResourceTarget target) => _resolve([target.type, target.id]); + + /// Returns a URL for the relationship itself. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + /// E.g. `/books/123/relationships/authors`. + @override + Uri relationship(RelationshipTarget target) => + _resolve([target.type, target.id, 'relationships', target.relationship]); + + /// Returns a URL for the related resource or collection. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + /// E.g. `/books/123/authors`. + @override + Uri related(RelatedTarget target) => + _resolve([target.type, target.id, target.relationship]); + + @override + Target /*?*/ match(Uri uri) { + final s = uri.pathSegments; + if (s.length == 1) { + return CollectionTarget(s.first); + } + if (s.length == 2) { + return ResourceTarget(s.first, s.last); + } + if (s.length == 3) { + return RelatedTarget(s.first, s[1], s.last); + } + if (s.length == 4 && s[2] == 'relationships') { + return RelationshipTarget(s.first, s[1], s.last); + } + return null; + } + + Uri _resolve(List pathSegments) => + base.resolveUri(Uri(pathSegments: pathSegments)); +} diff --git a/lib/src/routing/reference.dart b/lib/src/routing/reference.dart new file mode 100644 index 00000000..396f5e2c --- /dev/null +++ b/lib/src/routing/reference.dart @@ -0,0 +1,32 @@ +/// A reference to a resource collection +class CollectionReference { + const CollectionReference(this.type); + + /// Resource type + final String type; +} + +/// A reference to a resource +class ResourceReference implements CollectionReference { + const ResourceReference(this.type, this.id); + + @override + final String type; + + /// Resource id + final String id; +} + +/// A reference to a resource relationship +class RelationshipReference implements ResourceReference { + const RelationshipReference(this.type, this.id, this.relationship); + + @override + final String type; + + @override + final String id; + + /// Relationship name + final String relationship; +} diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart new file mode 100644 index 00000000..88ee3b0b --- /dev/null +++ b/lib/src/routing/target.dart @@ -0,0 +1,50 @@ +import 'package:json_api/routing.dart'; +import 'package:json_api/src/routing/reference.dart'; + +/// A request target +abstract class Target { + /// Targeted resource type + String get type; + + T map(TargetMapper mapper) => mapper.collection(this); +} + +abstract class TargetMapper { + T collection(CollectionTarget target); + + T resource(ResourceTarget target); + + T related(RelatedTarget target); + + T relationship(RelationshipTarget target); +} + +class CollectionTarget extends CollectionReference implements Target { + const CollectionTarget(String type) : super(type); + + @override + T map(TargetMapper mapper) => mapper.collection(this); +} + +class ResourceTarget extends ResourceReference implements Target { + const ResourceTarget(String type, String id) : super(type, id); + + @override + T map(TargetMapper mapper) => mapper.resource(this); +} + +class RelatedTarget extends RelationshipReference implements Target { + const RelatedTarget(String type, String id, String relationship) + : super(type, id, relationship); + + @override + T map(TargetMapper mapper) => mapper.related(this); +} + +class RelationshipTarget extends RelationshipReference implements Target { + const RelationshipTarget(String type, String id, String relationship) + : super(type, id, relationship); + + @override + T map(TargetMapper mapper) => mapper.relationship(this); +} diff --git a/lib/src/routing/target_matcher.dart b/lib/src/routing/target_matcher.dart new file mode 100644 index 00000000..d5822c57 --- /dev/null +++ b/lib/src/routing/target_matcher.dart @@ -0,0 +1,6 @@ +import 'package:json_api/src/routing/target.dart'; + +abstract class TargetMatcher { + /// Nullable. Returns the URI target. + Target /*?*/ match(Uri uri); +} diff --git a/lib/src/routing/uri_factory.dart b/lib/src/routing/uri_factory.dart new file mode 100644 index 00000000..1040f73b --- /dev/null +++ b/lib/src/routing/uri_factory.dart @@ -0,0 +1,3 @@ +import 'package:json_api/src/routing/target.dart'; + +abstract class UriFactory extends TargetMapper {} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart new file mode 100644 index 00000000..4a85d596 --- /dev/null +++ b/lib/src/server/controller.dart @@ -0,0 +1,10 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; + +abstract class JsonApiController { + T fetchCollection(HttpRequest request, CollectionTarget target); + + T createResource(HttpRequest request, CollectionTarget target); + + T fetchResource(HttpRequest request, ResourceTarget target); +} diff --git a/lib/src/server/cors_handler.dart b/lib/src/server/cors_handler.dart new file mode 100644 index 00000000..3a61a490 --- /dev/null +++ b/lib/src/server/cors_handler.dart @@ -0,0 +1,26 @@ +import 'package:json_api/http.dart'; + +class CorsHandler implements HttpHandler { + CorsHandler(this.wrapped, {this.origin = '*'}); + + final String origin; + + final HttpHandler wrapped; + + @override + Future call(HttpRequest request) async { + if (request.method == 'options') { + return HttpResponse(204, headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': + request.headers['Access-Control-Request-Method'] ?? + 'POST, GET, OPTIONS, DELETE, PATCH', + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? '*' + }); + } + final response = await wrapped(request); + response.headers['Access-Control-Allow-Origin'] = origin; + return response; + } +} diff --git a/lib/src/server/entity.dart b/lib/src/server/entity.dart new file mode 100644 index 00000000..8c606013 --- /dev/null +++ b/lib/src/server/entity.dart @@ -0,0 +1,7 @@ +class Entity { + Entity(this.id, this.model); + + final String id; + + final M model; +} diff --git a/lib/src/server/json_api_handler.dart b/lib/src/server/json_api_handler.dart new file mode 100644 index 00000000..ca921fc2 --- /dev/null +++ b/lib/src/server/json_api_handler.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/method_not_allowed.dart'; +import 'package:json_api/src/server/router.dart'; + +class JsonApiHandler implements HttpHandler { + JsonApiHandler(this.controller, + {TargetMatcher matcher, + UriFactory urlDesign, + this.exposeInternalErrors = false}) + : router = Router(matcher ?? RecommendedUrlDesign.pathOnly); + + final JsonApiController> controller; + final Router router; + final bool exposeInternalErrors; + + /// Handles the request by calling the appropriate method of the controller + @override + Future call(HttpRequest request) async { + try { + return await router.route(request, controller); + } on MethodNotAllowed { + return HttpResponse(405); + } catch (e) { + var body = ''; + if (exposeInternalErrors) { + final error = ErrorObject( + title: 'Uncaught exception', detail: e.toString(), status: '500'); + error.meta['runtimeType'] = e.runtimeType.toString(); + if (e is Error) { + error.meta['stackTrace'] = e.stackTrace.toString().trim().split('\n'); + } + body = jsonEncode(OutboundDocument.error([error])); + } + return HttpResponse(500, body: body); + } + } +} diff --git a/lib/src/server/method_not_allowed.dart b/lib/src/server/method_not_allowed.dart new file mode 100644 index 00000000..f5f27c98 --- /dev/null +++ b/lib/src/server/method_not_allowed.dart @@ -0,0 +1,5 @@ +class MethodNotAllowed implements Exception { + MethodNotAllowed(this.method); + + final String method; +} diff --git a/lib/src/server/model.dart b/lib/src/server/model.dart new file mode 100644 index 00000000..2751f947 --- /dev/null +++ b/lib/src/server/model.dart @@ -0,0 +1,11 @@ +import 'package:json_api/document.dart'; + +class Model { + Model(this.type); + + final String type; + final attributes = {}; + final one = {}; + final many = >{}; + final meta = {}; +} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart new file mode 100644 index 00000000..c4be8421 --- /dev/null +++ b/lib/src/server/router.dart @@ -0,0 +1,24 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/method_not_allowed.dart'; + +class Router { + const Router(this.matcher); + + final TargetMatcher matcher; + + T route(HttpRequest request, JsonApiController controller) { + final target = matcher.match(request.uri); + if (target is CollectionTarget) { + if (request.isGet) return controller.fetchCollection(request, target); + if (request.isPost) return controller.createResource(request, target); + throw MethodNotAllowed(request.method); + } + if (target is ResourceTarget) { + if (request.isGet) return controller.fetchResource(request, target); + throw MethodNotAllowed(request.method); + } + throw 'UnmatchedTarget'; + } +} diff --git a/lib/src/status_code.dart b/lib/src/status_code.dart deleted file mode 100644 index 50c37df1..00000000 --- a/lib/src/status_code.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// The status code in the HTTP response -class StatusCode { - const StatusCode(this.code); - - /// The code - final int code; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => code == 202; - - /// True for successfully processed requests - bool get isSuccessful => code >= 200 && code < 300 && !isPending; - - /// True for failed requests - bool get isFailed => !isSuccessful && !isPending; - - bool get isNoContent => code == 204; -} diff --git a/pubspec.yaml b/pubspec.yaml index 1c348d7c..e48f6f67 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,13 @@ name: json_api -version: 4.0.0 +version: 5.0.0-dev.1 homepage: https://github.com/f3ath/json-api-dart -description: Framework-agnostic implementations of JSON:API Client. Supports JSON:API v1.0 (http://jsonapi.org) +description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: - sdk: '>=2.8.0 <3.0.0' -dependencies: - json_api_common: - path: ../json-api-common - maybe_just_nothing: ^0.1.0 + sdk: '>=2.10.0 <3.0.0' dev_dependencies: - json_api_server: - path: ../json-api-server - html: ^0.14.0 - pedantic: ^1.9.0 - test: ^1.9.2 - json_matcher: ^0.2.3 + uuid: ^2.2.2 + pedantic: ^1.9.2 + sqlite3: ^0.1.7 + test: ^1.15.4 + test_coverage: ^0.5.0 stream_channel: ^2.0.0 - uuid: ^2.0.1 - test_coverage: ^0.4.0 diff --git a/test/client/client_test.dart b/test/client/client_test.dart new file mode 100644 index 00000000..769161de --- /dev/null +++ b/test/client/client_test.dart @@ -0,0 +1,786 @@ +import 'dart:convert'; + +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import 'response.dart' as mock; + +void main() { + final http = MockHandler(); + final client = JsonApiClient(http, RecommendedUrlDesign(Uri(path: '/'))); + + group('Failure', () { + test('RequestFailure', () async { + http.response = mock.error422; + try { + await client(FetchCollection('articles')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + expect(e.document.errors.first.title, 'Invalid Attribute'); + } + }); + test('ServerError', () async { + http.response = mock.error500; + try { + await client(FetchCollection('articles')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 500); + } + }); + }); + + group('Fetch Collection', () { + test('Min', () async { + http.response = mock.collectionMin; + final response = await client.call(FetchCollection('articles')); + expect(response.collection.single.key, 'articles:1'); + expect(response.included, isEmpty); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + }); + + test('Full', () async { + http.response = mock.collectionFull; + final response = await client.call(FetchCollection('articles') + ..headers['foo'] = 'bar' + ..query['foo'] = 'bar' + ..include(['author']) + ..fields({ + 'author': ['name'] + }) + ..page({'limit': '10'}) + ..sort(['title', '-date'])); + + expect(response.collection.length, 1); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles'); + expect(http.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'sort': 'title,-date', + 'page[limit]': '10', + 'foo': 'bar' + }); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + group('Fetch Related Collection', () { + test('Min', () async { + http.response = mock.collectionFull; + final response = + await client(FetchRelatedCollection('people', '1', 'articles')); + expect(response.collection.length, 1); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/people/1/articles'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.collectionFull; + final response = + await client.call(FetchRelatedCollection('people', '1', 'articles') + ..headers['foo'] = 'bar' + ..query['foo'] = 'bar' + ..include(['author']) + ..fields({ + 'author': ['name'] + }) + ..page({'limit': '10'}) + ..sort(['title', '-date'])); + + expect(response.collection.length, 1); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/people/1/articles'); + expect(http.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'sort': 'title,-date', + 'page[limit]': '10', + 'foo': 'bar' + }); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Fetch Primary Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client(FetchResource.build('articles', '1')); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = await client(FetchResource.build('articles', '1') + ..headers['foo'] = 'bar' + ..include(['author']) + ..fields({ + 'author': ['name'] + }) + ..query['foo'] = 'bar'); + expect(response.resource.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1'); + expect(http.request.uri.queryParameters, + {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Fetch Related Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = + await client(FetchRelatedResource('articles', '1', 'author')); + expect(response.resource?.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/author'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = + await client(FetchRelatedResource('articles', '1', 'author') + ..headers['foo'] = 'bar' + ..include(['author']) + ..fields({ + 'author': ['name'] + }) + ..query['foo'] = 'bar'); + expect(response.resource?.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1/author'); + expect(http.request.uri.queryParameters, + {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Fetch Relationship', () { + test('Min', () async { + http.response = mock.one; + final response = + await client(FetchRelationship('articles', '1', 'author')); + expect(response.relationship, isA()); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.one; + final response = await client(FetchRelationship('articles', '1', 'author') + ..headers['foo'] = 'bar' + ..query['foo'] = 'bar'); + expect(response.relationship, isA()); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1/relationships/author'); + expect(http.request.uri.queryParameters, {'foo': 'bar'}); + expect(http.request.headers, + {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Create New Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client(CreateNewResource.build('articles')); + expect(response.resource.type, 'articles'); + expect( + response.links['self'].toString(), 'http://example.com/articles/1'); + expect(response.included.length, 3); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = + await client(CreateNewResource.build('articles', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }) + ..headers['foo'] = 'bar'); + expect(response.resource.type, 'articles'); + expect( + response.links['self'].toString(), 'http://example.com/articles/1'); + expect(response.included.length, 3); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Create Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client(CreateResource.build('articles', '1')); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Min with 204 No Content', () async { + http.response = mock.noContent; + final response = await client(CreateResource.build('articles', '1')); + expect(response.resource, isNull); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = + await client(CreateResource.build('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }) + ..headers['foo'] = 'bar'); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Update Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client(UpdateResource('articles', '1')); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Min with 204 No Content', () async { + http.response = mock.noContent; + final response = await client(UpdateResource('articles', '1')); + expect(response.resource, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = + await client(UpdateResource('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }) + ..headers['foo'] = 'bar'); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Replace One', () { + test('Min', () async { + http.response = mock.one; + final response = await client(ReplaceOne.build( + 'articles', '1', 'author', Identifier('people', '42'))); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Full', () async { + http.response = mock.one; + final response = await client( + ReplaceOne.build('articles', '1', 'author', Identifier('people', '42')) + ..headers['foo'] = 'bar', + ); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client(ReplaceOne.build( + 'articles', '1', 'author', Identifier('people', '42'))); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.many; + expect( + () async => await client(ReplaceOne.build( + 'articles', '1', 'author', Identifier('people', '42'))), + throwsFormatException); + }); + }); + + group('Delete One', () { + test('Min', () async { + http.response = mock.oneEmpty; + final response = await client(DeleteOne.build('articles', '1', 'author')); + expect(response.relationship, isA()); + expect(response.relationship.identifier, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Full', () async { + http.response = mock.oneEmpty; + final response = await client( + DeleteOne.build('articles', '1', 'author')..headers['foo'] = 'bar'); + expect(response.relationship, isA()); + expect(response.relationship.identifier, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client(DeleteOne.build('articles', '1', 'author')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.many; + expect( + () async => await client(DeleteOne.build('articles', '1', 'author')), + throwsFormatException); + }); + }); + + group('Delete Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client( + DeleteMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + expect(response.relationship, isA()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client( + DeleteMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) + ..headers['foo'] = 'bar'); + expect(response.relationship, isA()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client(DeleteMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () async => await client(DeleteMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])), + throwsFormatException); + }); + }); + + group('Replace Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client(ReplaceMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client( + ReplaceMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) + ..headers['foo'] = 'bar'); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client(ReplaceMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () async => await client(ReplaceMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])), + throwsFormatException); + }); + }); + + group('Add Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client( + AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + expect(response.relationship, isA()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client( + AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) + ..headers['foo'] = 'bar'); + expect(response.relationship, isA()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'accept': 'application/vnd.api+json', + 'content-type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client( + AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.document.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () async => await client(AddMany.build( + 'articles', '1', 'tags', [Identifier('tags', '1')])), + throwsFormatException); + }); + }); + +// group('Call', () { +// test('Sends correct request when given minimum arguments', () async { +// http.response = HttpResponse(204); +// final response = +// await client.call(Request('get'), Uri.parse('/foo')); +// expect(response, http.response); +// expect(http.request.method, 'get'); +// expect(http.request.uri.toString(), '/foo'); +// expect(http.request.headers, { +// 'accept': 'application/vnd.api+json', +// }); +// expect(http.request.body, isEmpty); +// }); +// +// test('Sends correct request when given all possible arguments', () async { +// http.response = HttpResponse(204); +// final response = await client.call( +// Request('get', document: { +// 'data': null +// }, headers: { +// 'foo': 'bar' +// }, include: [ +// 'author' +// ], fields: { +// 'author': ['name'] +// }, sort: [ +// 'title', +// '-date' +// ], page: { +// 'limit': '10' +// }, query: { +// 'foo': 'bar' +// }), +// Uri.parse('/foo')); +// expect(response, http.response); +// expect(http.request.method, 'get'); +// expect(http.request.uri.toString(), +// r'/foo?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); +// expect(http.request.headers, { +// 'accept': 'application/vnd.api+json', +// 'content-type': 'application/vnd.api+json', +// 'foo': 'bar' +// }); +// expect(jsonDecode(http.request.body), {'data': null}); +// }); +// +// test('Throws RequestFailure', () async { +// http.response = mock.error422; +// try { +// await client.call(Request('get'), Uri.parse('/foo')); +// fail('Exception expected'); +// } on RequestFailure catch (e) { +// expect(e.response.statusCode, 422); +// expect(e.errors.first.status, '422'); +// } +// }); +// }); +} + +class MockHandler implements HttpHandler { + HttpResponse /*?*/ response; + HttpRequest /*?*/ request; + + @override + Future call(HttpRequest request) async { + this.request = request; + return response; + } +} diff --git a/test/client/response.dart b/test/client/response.dart new file mode 100644 index 00000000..eb5c957e --- /dev/null +++ b/test/client/response.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; + +import 'package:json_api/src/http/media_type.dart'; +import 'package:json_api/http.dart'; + +final collectionMin = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'data': [ + {'type': 'articles', 'id': '1'} + ] + })); + +final collectionFull = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10' + }, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })); + +final primaryResource = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1'}, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'} + } + } + }, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })); +final relatedResourceNull = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1/author'}, + 'data': null + })); +final one = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': {'type': 'people', 'id': '12'}, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })); + +final oneEmpty = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': null, + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })); + +final many = HttpResponse(200, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [ + {'type': 'tags', 'id': '12'} + ] + })); + +final noContent = HttpResponse(204); + +final error422 = HttpResponse(422, + headers: {'Content-Type': MediaType.jsonApi}, + body: jsonEncode({ + 'errors': [ + { + 'status': '422', + 'source': {'pointer': '/data/attributes/firstName'}, + 'title': 'Invalid Attribute', + 'detail': 'First name must contain at least three characters.' + } + ] + })); + +final error500 = HttpResponse(500); diff --git a/test/client_test.dart b/test/client_test.dart deleted file mode 100644 index fa18d434..00000000 --- a/test/client_test.dart +++ /dev/null @@ -1,780 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/json_api.dart'; -import 'package:maybe_just_nothing/maybe_just_nothing.dart'; -import 'package:test/test.dart'; - -import 'responses.dart' as mock; - -void main() { - final http = MockHandler(); - final client = JsonApiClient(http, UrlDesign()); - setUp(() { - http.request = null; - http.response = null; - }); - - group('Fetch Collection', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.collection; - final response = await client.fetchCollection('articles'); - expect(response.length, 1); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.collection; - final response = await client.fetchCollection('articles', headers: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, sort: [ - 'title', - '-date' - ], page: { - 'limit': '10' - }, query: { - 'foo': 'bar' - }); - expect(response.length, 1); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - r'/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); - expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.fetchCollection('articles'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Fetch Related Collection', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.collection; - final response = - await client.fetchRelatedCollection('people', '1', 'articles'); - expect(response.length, 1); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/people/1/articles'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.collection; - final response = await client - .fetchRelatedCollection('people', '1', 'articles', headers: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, sort: [ - 'title', - '-date' - ], page: { - 'limit': '10' - }, query: { - 'foo': 'bar' - }); - expect(response.length, 1); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - r'/people/1/articles?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); - expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.fetchRelatedCollection('people', '1', 'articles'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Fetch Primary Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.primaryResource; - final response = await client.fetchResource('articles', '1'); - expect(response.resource.type, 'articles'); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.primaryResource; - final response = await client.fetchResource('articles', '1', headers: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, query: { - 'foo': 'bar' - }); - expect(response.resource.type, 'articles'); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - r'/articles/1?include=author&fields%5Bauthor%5D=name&foo=bar'); - expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.fetchResource('articles', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Fetch Related Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.relatedResourceNull; - final response = - await client.fetchRelatedResource('articles', '1', 'author'); - expect(response.resource, isA>()); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.relatedResourceNull; - final response = await client - .fetchRelatedResource('articles', '1', 'author', headers: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, query: { - 'foo': 'bar' - }); - expect(response.resource, isA>()); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - r'/articles/1/author?include=author&fields%5Bauthor%5D=name&foo=bar'); - expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.fetchRelatedResource('articles', '1', 'author'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Fetch Relationship', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.one; - final response = - await client.fetchRelationship('articles', '1', 'author'); - expect(response.relationship, isA()); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.one; - final response = await client.fetchRelationship( - 'articles', '1', 'author', - headers: {'foo': 'bar'}, query: {'foo': 'bar'}); - expect(response.relationship, isA()); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - '/articles/1/relationships/author?foo=bar'); - expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.fetchRelationship('articles', '1', 'author'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Create New Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.primaryResource; - final response = await client.createNewResource('articles'); - expect(response.resource.type, 'articles'); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': {'type': 'articles'} - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.primaryResource; - final response = await client.createNewResource('articles', attributes: { - 'cool': true - }, one: { - 'author': 'people:42' - }, many: { - 'tags': ['tags:1', 'tags:2'] - }, headers: { - 'foo': 'bar' - }); - expect(response.resource.type, 'articles'); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': { - 'type': 'articles', - 'attributes': {'cool': true}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '42'} - }, - 'tags': { - 'data': [ - {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } - } - } - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.createNewResource('articles'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Create Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.primaryResource; - final response = await client.createResource('articles', '1'); - expect(response.resource, isA>()); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': {'type': 'articles', 'id': '1'} - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.primaryResource; - final response = - await client.createResource('articles', '1', attributes: { - 'cool': true - }, one: { - 'author': 'people:42' - }, many: { - 'tags': ['tags:1', 'tags:2'] - }, headers: { - 'foo': 'bar' - }); - expect(response.resource, isA>()); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': { - 'type': 'articles', - 'id': '1', - 'attributes': {'cool': true}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '42'} - }, - 'tags': { - 'data': [ - {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } - } - } - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.createResource('articles', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Create Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = HttpResponse(204); - final response = await client.deleteResource('articles', '1'); - expect(response.meta, isEmpty); - expect(http.request.method, 'delete'); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - }); - expect(http.request.body, isEmpty); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = HttpResponse(204); - final response = - await client.deleteResource('articles', '1', headers: {'foo': 'bar'}); - expect(response.meta, isEmpty); - expect(http.request.method, 'delete'); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'foo': 'bar', - }); - expect(http.request.body, isEmpty); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.deleteResource('articles', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Update Resource', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.primaryResource; - final response = await client.updateResource('articles', '1'); - expect(response.resource, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': {'type': 'articles', 'id': '1'} - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.primaryResource; - final response = - await client.updateResource('articles', '1', attributes: { - 'cool': true - }, one: { - 'author': 'people:42' - }, many: { - 'tags': ['tags:1', 'tags:2'] - }, headers: { - 'foo': 'bar' - }); - expect(response.resource, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': { - 'type': 'articles', - 'id': '1', - 'attributes': {'cool': true}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '42'} - }, - 'tags': { - 'data': [ - {'type': 'tags', 'id': '1'}, - {'type': 'tags', 'id': '2'} - ] - } - } - } - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.updateResource('articles', '1'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Replace One', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.one; - final response = - await client.replaceOne('articles', '1', 'author', 'people:42'); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': {'type': 'people', 'id': '42'} - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.one; - final response = await client.replaceOne( - 'articles', '1', 'author', 'people:42', - headers: {'foo': 'bar'}); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': {'type': 'people', 'id': '42'} - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.replaceOne('articles', '1', 'author', 'people:42'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Delete One', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.one; - final response = await client.deleteOne('articles', '1', 'author'); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), {'data': null}); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.one; - final response = await client - .deleteOne('articles', '1', 'author', headers: {'foo': 'bar'}); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), {'data': null}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.deleteOne('articles', '1', 'author'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Delete Many', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.many; - final response = - await client.deleteMany('articles', '1', 'tags', ['tags:1']); - expect(response.relationship, isA>()); - expect(http.request.method, 'delete'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.many; - final response = await client.deleteMany( - 'articles', '1', 'tags', ['tags:1'], - headers: {'foo': 'bar'}); - expect(response.relationship, isA>()); - expect(http.request.method, 'delete'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.deleteMany('articles', '1', 'tags', ['tags:1']); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Replace Many', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.many; - final response = - await client.replaceMany('articles', '1', 'tags', ['tags:1']); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.many; - final response = await client.replaceMany( - 'articles', '1', 'tags', ['tags:1'], - headers: {'foo': 'bar'}); - expect(response.relationship, isA>()); - expect(http.request.method, 'patch'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.replaceMany('articles', '1', 'tags', ['tags:1']); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Add Many', () { - test('Sends correct request when given minimum arguments', () async { - http.response = mock.many; - final response = - await client.addMany('articles', '1', 'tags', ['tags:1']); - expect(response.relationship, isA>()); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = mock.many; - final response = await client.addMany('articles', '1', 'tags', ['tags:1'], - headers: {'foo': 'bar'}); - expect(response.relationship, isA>()); - expect(http.request.method, 'post'); - expect(http.request.uri.toString(), '/articles/1/relationships/tags'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), { - 'data': [ - {'type': 'tags', 'id': '1'} - ] - }); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.addMany('articles', '1', 'tags', ['tags:1']); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); - - group('Call', () { - test('Sends correct request when given minimum arguments', () async { - http.response = HttpResponse(204); - final response = - await client.call(JsonApiRequest('get'), Uri.parse('/foo')); - expect(response, http.response); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), '/foo'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - }); - expect(http.request.body, isEmpty); - }); - - test('Sends correct request when given all possible arguments', () async { - http.response = HttpResponse(204); - final response = await client.call( - JsonApiRequest('get', document: { - 'data': null - }, headers: { - 'foo': 'bar' - }, include: [ - 'author' - ], fields: { - 'author': ['name'] - }, sort: [ - 'title', - '-date' - ], page: { - 'limit': '10' - }, query: { - 'foo': 'bar' - }), - Uri.parse('/foo')); - expect(response, http.response); - expect(http.request.method, 'get'); - expect(http.request.uri.toString(), - r'/foo?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); - expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', - 'foo': 'bar' - }); - expect(jsonDecode(http.request.body), {'data': null}); - }); - - test('Throws RequestFailure', () async { - http.response = mock.error422; - try { - await client.call(JsonApiRequest('get'), Uri.parse('/foo')); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 422); - expect(e.errors.first.status, '422'); - } - }); - }); -} - -class MockHandler implements HttpHandler { - HttpResponse response; - HttpRequest request; - - @override - Future call(HttpRequest request) async { - this.request = request; - return response; - } -} diff --git a/test/document/error_object_test.dart b/test/document/error_object_test.dart new file mode 100644 index 00000000..f42b2d2b --- /dev/null +++ b/test/document/error_object_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/error_source.dart'; +import 'package:test/test.dart'; + +void main() { + group('ErrorObject', () { + test('Minimal', () { + expect(jsonEncode(ErrorObject()), '{}'); + }); + test('Full', () { + expect( + jsonEncode(ErrorObject( + id: 'test_id', + status: 'test_status', + code: 'test_code', + title: 'test_title', + detail: 'test_detail', + source: ErrorSource( + parameter: 'test_parameter', pointer: 'test_pointer')) + ..links['foo'] = Link(Uri.parse('/bar')) + ..meta['foo'] = 42), + jsonEncode({ + 'id': 'test_id', + 'status': 'test_status', + 'code': 'test_code', + 'title': 'test_title', + 'detail': 'test_detail', + 'source': { + 'parameter': 'test_parameter', + 'pointer': 'test_pointer' + }, + 'links': {'foo': '/bar'}, + 'meta': {'foo': 42}, + })); + }); + }); +} diff --git a/test/document/inbound_document_test.dart b/test/document/inbound_document_test.dart new file mode 100644 index 00000000..19f13f64 --- /dev/null +++ b/test/document/inbound_document_test.dart @@ -0,0 +1,175 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +import 'payload.dart' as payload; + +void main() { + group('InboundDocument', () { + group('Errors', () { + test('Minimal', () { + final e = InboundDocument({ + 'errors': [{}] + }).errors.first; + expect(e.id, ''); + expect(e.status, ''); + expect(e.code, ''); + expect(e.title, ''); + expect(e.detail, ''); + expect(e.source.parameter, ''); + expect(e.source.pointer, ''); + expect(e.source.isEmpty, true); + expect(e.source.isNotEmpty, false); + expect(e.links, isEmpty); + expect(e.meta, isEmpty); + }); + test('Full', () { + final error = { + 'id': 'test_id', + 'status': 'test_status', + 'code': 'test_code', + 'title': 'test_title', + 'detail': 'test_detail', + 'source': {'parameter': 'test_parameter', 'pointer': 'test_pointer'}, + 'links': {'foo': '/bar'}, + 'meta': {'foo': 42}, + }; + final e = InboundDocument({ + 'errors': [error] + }).errors.first; + + expect(e.id, 'test_id'); + expect(e.status, 'test_status'); + expect(e.code, 'test_code'); + expect(e.title, 'test_title'); + expect(e.detail, 'test_detail'); + expect(e.source.parameter, 'test_parameter'); + expect(e.source.pointer, 'test_pointer'); + expect(e.source.isEmpty, false); + expect(e.source.isNotEmpty, true); + expect(e.links['foo'].toString(), '/bar'); + expect(e.meta['foo'], 42); + }); + + test('Invalid', () { + expect( + () => InboundDocument({ + 'errors': [ + {'id': []} + ] + }).errors.first, + throwsFormatException); + }); + }); + + group('Parsing', () { + test('can parse the standard example', () { + final doc = InboundDocument(payload.example); + expect( + doc + .resourceCollection() + .first + .relationships['author'] + .links['self'] + .uri + .toString(), + 'http://example.com/articles/1/relationships/author'); + expect(doc.included.first.attributes['firstName'], 'Dan'); + expect(doc.links['self'].toString(), 'http://example.com/articles'); + expect(doc.meta, isEmpty); + }); + + test('can parse primary resource', () { + final doc = InboundDocument(payload.resource); + final article = doc.resource(); + expect(article.id, '1'); + expect(article.attributes['title'], 'JSON:API paints my bikeshed!'); + expect(article.relationships['author'], isA()); + expect(doc.included, isEmpty); + expect(doc.links['self'].toString(), 'http://example.com/articles/1'); + expect(doc.meta, isEmpty); + }); + + test('can parse a new resource', () { + final doc = InboundDocument(payload.newResource); + final article = doc.newResource(); + expect(article.attributes['title'], 'A new article'); + expect(doc.included, isEmpty); + expect(doc.links, isEmpty); + expect(doc.meta, isEmpty); + }); + + test('newResource() has id if data is sufficient', () { + final doc = InboundDocument(payload.resource); + final article = doc.newResource(); + expect(article.id, isNotEmpty); + }); + + test('can parse related resource', () { + final doc = InboundDocument(payload.relatedEmpty); + expect(doc.nullableResource(), isNull); + expect(doc.included, isEmpty); + expect(doc.links['self'].toString(), + 'http://example.com/articles/1/author'); + expect(doc.meta, isEmpty); + }); + + test('can parse to-one', () { + final doc = InboundDocument(payload.one); + expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isNotEmpty); + expect(doc.dataAsRelationship().first.type, 'people'); + expect(doc.included, isEmpty); + expect( + doc.links['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta, isEmpty); + }); + + test('can parse empty to-one', () { + final doc = InboundDocument(payload.oneEmpty); + expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isEmpty); + expect(doc.included, isEmpty); + expect( + doc.links['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta, isEmpty); + }); + + test('can parse to-many', () { + final doc = InboundDocument(payload.many); + expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isNotEmpty); + expect(doc.dataAsRelationship().first.type, 'tags'); + expect(doc.included, isEmpty); + expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta, isEmpty); + }); + + test('can parse empty to-many', () { + final doc = InboundDocument(payload.manyEmpty); + expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isEmpty); + expect(doc.included, isEmpty); + expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta, isEmpty); + }); + + test('throws on invalid doc', () { + expect(() => InboundDocument(payload.manyEmpty).nullableResource(), + throwsFormatException); + expect(() => InboundDocument(payload.newResource).resource(), + throwsFormatException); + expect(() => InboundDocument(payload.newResource).nullableResource(), + throwsFormatException); + expect(() => InboundDocument({}).nullableResource(), + throwsFormatException); + expect(() => InboundDocument({'data': 42}).dataAsRelationship(), + throwsFormatException); + expect( + () => InboundDocument({ + 'links': {'self': 42} + }).dataAsRelationship(), + throwsFormatException); + }); + }); + }); +} diff --git a/test/document/link_test.dart b/test/document/link_test.dart new file mode 100644 index 00000000..2d7489c3 --- /dev/null +++ b/test/document/link_test.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('Link', () { + final href = 'http://example.com'; + test('String', () { + expect(jsonEncode(Link(Uri.parse(href))), jsonEncode(href)); + }); + test('Object', () { + expect( + jsonEncode(Link(Uri.parse(href))..meta['foo'] = []), + jsonEncode({ + 'href': href, + 'meta': {'foo': []} + })); + }); + }); +} diff --git a/test/document/new_resource_test.dart b/test/document/new_resource_test.dart new file mode 100644 index 00000000..cb8d93b4 --- /dev/null +++ b/test/document/new_resource_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('NewResource', () { + test('json encoding', () { + expect(jsonEncode(NewResource('test_type')), + jsonEncode({'type': 'test_type'})); + + expect( + jsonEncode(NewResource('test_type') + ..meta['foo'] = [42] + ..attributes['color'] = 'green' + ..relationships['one'] = + (One(Identifier('rel', '1')..meta['rel'] = 1)..meta['one'] = 1) + ..relationships['many'] = + (Many([Identifier('rel', '1')..meta['rel'] = 1]) + ..meta['many'] = 1)), + jsonEncode({ + 'type': 'test_type', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + 'meta': {'one': 1} + }, + 'many': { + 'data': [ + { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + ], + 'meta': {'many': 1} + } + }, + 'meta': { + 'foo': [42] + } + })); + }); + }); +} diff --git a/test/document/outbound_document_test.dart b/test/document/outbound_document_test.dart new file mode 100644 index 00000000..2c562afa --- /dev/null +++ b/test/document/outbound_document_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('OutboundDocument', () { + group('Meta', () { + test('The "meta" member must be always present', () { + expect(toObject(OutboundDocument()), {'meta': {}}); + }); + test('full', () { + expect(toObject(OutboundDocument()..meta['foo'] = true), { + 'meta': {'foo': true} + }); + }); + }); + + group('Error', () { + test('minimal', () { + expect(toObject(OutboundDocument.error([])), {'errors': []}); + }); + test('full', () { + expect( + toObject(OutboundDocument.error([ErrorObject(detail: 'Some issue')]) + ..meta['foo'] = 42), + { + 'errors': [ + {'detail': 'Some issue'} + ], + 'meta': {'foo': 42} + }); + }); + }); + }); + + group('Data', () { + final book = Resource('books', '1'); + final author = Resource('people', '2'); + group('Resource', () { + test('minimal', () { + expect(toObject(OutboundDocument.resource(book)), { + 'data': {'type': 'books', 'id': '1'} + }); + }); + test('full', () { + expect( + toObject(OutboundDocument.resource(book) + ..meta['foo'] = 42 + ..included.add(author) + ..links['self'] = Link(Uri.parse('/books/1'))), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('Collection', () { + test('minimal', () { + expect(toObject(OutboundDocument.collection([])), {'data': []}); + }); + test('full', () { + expect( + toObject(OutboundDocument.collection([book]) + ..meta['foo'] = 42 + ..included.add(author) + ..links['self'] = Link(Uri.parse('/books/1'))), + { + 'data': [ + {'type': 'books', 'id': '1'} + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('One', () { + test('minimal', () { + expect(toObject(OutboundDocument.one(One.empty())), {'data': null}); + }); + test('full', () { + expect( + toObject(OutboundDocument.one(One(book.identifier) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1'))) + ..included.add(author)), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('Many', () { + test('minimal', () { + expect(toObject(OutboundDocument.many(Many([]))), {'data': []}); + }); + test('full', () { + expect( + toObject(OutboundDocument.many(Many([book.identifier]) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1'))) + ..included.add(author)), + { + 'data': [ + {'type': 'books', 'id': '1'} + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + }); +} + +Map toObject(v) => jsonDecode(jsonEncode(v)); diff --git a/test/document/payload.dart b/test/document/payload.dart new file mode 100644 index 00000000..fc1758c2 --- /dev/null +++ b/test/document/payload.dart @@ -0,0 +1,140 @@ +final example = { + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10' + }, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] +}; + +final newResource = { + 'data': { + 'type': 'articles', + 'attributes': {'title': 'A new article'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '42'} + } + } + } +}; + +final many = { + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [ + {'type': 'tags', 'id': '2'}, + {'type': 'tags', 'id': '3'} + ] +}; + +final manyEmpty = { + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [] +}; + +final one = { + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': {'type': 'people', 'id': '12'} +}; + +final oneEmpty = { + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': null +}; + +final relatedEmpty = { + 'links': {'self': 'http://example.com/articles/1/author'}, + 'data': null +}; + +final resource = { + 'links': { + 'self': { + 'href': 'http://example.com/articles/1', + 'meta': {'answer': 42} + } + }, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'} + }, + 'reviewer': {'data': null} + } + } +}; diff --git a/test/document/relationship_test.dart b/test/document/relationship_test.dart new file mode 100644 index 00000000..601bf76d --- /dev/null +++ b/test/document/relationship_test.dart @@ -0,0 +1,45 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + final a = Identifier('apples', 'a'); + final b = Identifier('apples', 'b'); + group('Relationship', () { + test('one', () { + expect(One(a).identifier, a); + expect([...One(a)].first, a); + + expect(One.empty().identifier, isNull); + expect([...One.empty()], isEmpty); + }); + + test('many', () { + expect(Many([]), isEmpty); + expect([...Many([])], isEmpty); + + expect(Many([a]), isNotEmpty); + expect( + [ + ...Many([a]) + ].first, + a); + + expect(Many([a, b]), isNotEmpty); + expect( + [ + ...Many([a, b]) + ].first, + a); + expect( + [ + ...Many([a, b]) + ].last, + b); + }); + }); + + test('incomplete', () { + expect(Relationship(), isEmpty); + expect([...Relationship()], isEmpty); + }); +} diff --git a/test/document/resource_test.dart b/test/document/resource_test.dart new file mode 100644 index 00000000..beeb25cc --- /dev/null +++ b/test/document/resource_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('Resource', () { + test('json encoding', () { + expect(jsonEncode(Resource('test_type', 'test_id')), + jsonEncode({'type': 'test_type', 'id': 'test_id'})); + + expect( + jsonEncode(Resource('test_type', 'test_id') + ..meta['foo'] = [42] + ..attributes['color'] = 'green' + ..relationships['one'] = + (One(Identifier('rel', '1')..meta['rel'] = 1)..meta['one'] = 1) + ..relationships['many'] = + (Many([Identifier('rel', '1')..meta['rel'] = 1]) + ..meta['many'] = 1) + ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1)), + jsonEncode({ + 'type': 'test_type', + 'id': 'test_id', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + 'meta': {'one': 1} + }, + 'many': { + 'data': [ + { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + ], + 'meta': {'many': 1} + } + }, + 'links': { + 'self': { + 'href': '/apples/42', + 'meta': {'a': 1} + } + }, + 'meta': { + 'foo': [42] + } + })); + }); + }); +} diff --git a/test/e2e/dart_http_handler.dart b/test/e2e/dart_http_handler.dart new file mode 100644 index 00000000..526437bf --- /dev/null +++ b/test/e2e/dart_http_handler.dart @@ -0,0 +1,38 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:json_api/http.dart'; + +class DartHttpHandler { + DartHttpHandler(this._handler); + + final HttpHandler _handler; + + Future call(io.HttpRequest ioRequest) async { + final request = await _convertRequest(ioRequest); + final response = await _handler(request); + await _sendResponse(response, ioRequest.response); + } + + Future _sendResponse( + HttpResponse response, io.HttpResponse ioResponse) async { + response.headers.forEach(ioResponse.headers.add); + ioResponse.statusCode = response.statusCode; + ioResponse.write(response.body); + await ioResponse.close(); + } + + Future _convertRequest(io.HttpRequest ioRequest) async => + HttpRequest(ioRequest.method, ioRequest.requestedUri, + body: await _readBody(ioRequest), + headers: _convertHeaders(ioRequest.headers)); + + Future _readBody(io.HttpRequest ioRequest) => + ioRequest.cast>().transform(utf8.decoder).join(); + + Map _convertHeaders(io.HttpHeaders ioHeaders) { + final headers = {}; + ioHeaders.forEach((k, v) => headers[k] = v.join(',')); + return headers; + } +} diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart new file mode 100644 index 00000000..d2a142d7 --- /dev/null +++ b/test/e2e/e2e_test.dart @@ -0,0 +1,80 @@ +import 'package:http/http.dart' as http; +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../../example/demo/printing_logger.dart'; + +void main() { + final sql = ''' + CREATE TABLE books ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT + ); + '''; + + final logger = PrintingLogger(); + + StreamChannel channel; + http.Client httpClient; + JsonApiClient client; + + setUp(() async { + channel = spawnHybridUri('hybrid_server.dart', message: sql); + final serverUrl = await channel.stream.first; + // final serverUrl = 'http://localhost:8080'; + httpClient = http.Client(); + + client = JsonApiClient(LoggingHttpHandler(DartHttp(httpClient), logger), + RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); + }); + + tearDown(() async { + httpClient.close(); + }); + + group('Basic Client-Server interaction over HTTP', () { + test('Create new resource, read collection', () async { + final r0 = await client(CreateNewResource.build('books', + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 201); + expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); + expect(r0.resource.type, 'books'); + expect(r0.resource.id, isNotEmpty); + expect(r0.resource.attributes['title'], 'Hello world'); + expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); + + final r1 = await client(FetchCollection('books')); + expect(r1.http.statusCode, 200); + expect(r1.collection.first.type, 'books'); + expect(r1.collection.first.attributes['title'], 'Hello world'); + }); + + test('Create new resource sets Location header', () async { + // TODO: Why does this not work in browsers? + final r0 = await client(CreateNewResource.build('books', + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 201); + expect(r0.http.headers['location'], '/books/${r0.resource.id}'); + }, testOn: 'vm'); + + test('Create resource with id, read resource by id', () async { + final id = Uuid().v4(); + final r0 = await client(CreateResource.build('books', id, + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 204); + expect(r0.resource, isNull); + expect(r0.http.headers['location'], isNull); + + final r1 = await client(FetchResource.build('books', id)); + expect(r1.http.statusCode, 200); + expect(r1.http.headers['content-type'], 'application/vnd.api+json'); + expect(r1.resource.type, 'books'); + expect(r1.resource.id, id); + expect(r1.resource.attributes['title'], 'Hello world'); + }); + }); +} diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart new file mode 100644 index 00000000..7bacc28a --- /dev/null +++ b/test/e2e/hybrid_server.dart @@ -0,0 +1,9 @@ +import 'package:stream_channel/stream_channel.dart'; + +import '../../example/demo/demo_server.dart'; + +void hybridMain(StreamChannel channel, Object initSql) async { + final demo = DemoServer(initSql); + await demo.start(); + channel.sink.add(demo.uri); +} diff --git a/test/http/headers_test.dart b/test/http/headers_test.dart new file mode 100644 index 00000000..5c74be26 --- /dev/null +++ b/test/http/headers_test.dart @@ -0,0 +1,28 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + group('Headers', () { + test('add, read, clear', () { + final h = Headers({'Foo': 'Bar'}); + expect(h['Foo'], 'Bar'); + expect(h['foo'], 'Bar'); + expect(h['fOO'], 'Bar'); + expect(h.length, 1); + h['FOO'] = 'Baz'; + expect(h['Foo'], 'Baz'); + expect(h['foo'], 'Baz'); + expect(h['fOO'], 'Baz'); + expect(h.length, 1); + h['hello'] = 'world'; + expect(h.length, 2); + h.remove('foo'); + expect(h['foo'], isNull); + expect(h.length, 1); + h.clear(); + expect(h.length, 0); + expect(h.isEmpty, true); + expect(h.isNotEmpty, false); + }); + }); +} diff --git a/test/http/logging_http_handler_test.dart b/test/http/logging_http_handler_test.dart new file mode 100644 index 00000000..84e602b5 --- /dev/null +++ b/test/http/logging_http_handler_test.dart @@ -0,0 +1,16 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/http/last_value_logger.dart'; +import 'package:test/test.dart'; + +void main() { + test('Logging handler can log', () async { + final rq = HttpRequest('get', Uri.parse('http://localhost')); + final rs = HttpResponse(200, body: 'Hello'); + final log = LastValueLogger(); + final handler = + LoggingHttpHandler(HttpHandler.fromFunction((_) async => rs), log); + await handler(rq); + expect(log.request, same(rq)); + expect(log.response, same(rs)); + }); +} diff --git a/test/http/request_test.dart b/test/http/request_test.dart new file mode 100644 index 00000000..561e8947 --- /dev/null +++ b/test/http/request_test.dart @@ -0,0 +1,13 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + test('HttpRequest converts method to lowercase', () { + expect(HttpRequest('pAtCh', Uri()).method, 'patch'); + }); + + test('HttpRequest converts headers keys to lowercase', () { + expect(HttpRequest('post', Uri(), headers: {'FoO': 'Bar'}).headers, + {'foo': 'Bar'}); + }); +} diff --git a/test/integration/integration_test.dart b/test/integration/integration_test.dart new file mode 100644 index 00000000..9f3621b6 --- /dev/null +++ b/test/integration/integration_test.dart @@ -0,0 +1,75 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import '../../example/demo/printing_logger.dart'; +import '../../example/demo/sqlite_controller.dart'; + +void main() { + final sql = ''' + CREATE TABLE books ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT + ); + '''; + + JsonApiClient client; + + setUp(() async { + final db = sqlite3.openInMemory(); + db.execute(sql); + final controller = SqliteController(db); + final jsonApiServer = + JsonApiHandler(controller, exposeInternalErrors: true); + + client = JsonApiClient( + LoggingHttpHandler(jsonApiServer, const PrintingLogger()), + RecommendedUrlDesign.pathOnly); + }); + + group('Basic Client-Server interaction over HTTP', () { + test('Create new resource, read collection', () async { + final r0 = await client(CreateNewResource.build('books', + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 201); + expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); + expect(r0.resource.type, 'books'); + expect(r0.resource.id, isNotEmpty); + expect(r0.resource.attributes['title'], 'Hello world'); + expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); + + final r1 = await client(FetchCollection('books')); + expect(r1.http.statusCode, 200); + expect(r1.collection.first.type, 'books'); + expect(r1.collection.first.attributes['title'], 'Hello world'); + }); + + test('Create new resource sets Location header', () async { + // TODO: Why does this not work in browsers? + final r0 = await client(CreateNewResource.build('books', + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 201); + expect(r0.http.headers['location'], '/books/${r0.resource.id}'); + }, testOn: 'vm'); + + test('Create resource with id, read resource by id', () async { + final id = Uuid().v4(); + final r0 = await client(CreateResource.build('books', id, + attributes: {'title': 'Hello world'})); + expect(r0.http.statusCode, 204); + expect(r0.resource, isNull); + expect(r0.http.headers['location'], isNull); + + final r1 = await client(FetchResource.build('books', id)); + expect(r1.http.statusCode, 200); + expect(r1.http.headers['content-type'], 'application/vnd.api+json'); + expect(r1.resource.type, 'books'); + expect(r1.resource.id, id); + expect(r1.resource.attributes['title'], 'Hello world'); + }); + }); +} diff --git a/test/query/fields_test.dart b/test/query/fields_test.dart new file mode 100644 index 00000000..2708c983 --- /dev/null +++ b/test/query/fields_test.dart @@ -0,0 +1,59 @@ +import 'package:json_api/src/query/fields.dart'; +import 'package:test/test.dart'; + +void main() { + group('Fields', () { + test('emptiness', () { + expect(Fields().isEmpty, isTrue); + expect(Fields().isNotEmpty, isFalse); + + expect( + Fields({ + 'foo': ['bar'] + }).isEmpty, + isFalse); + expect( + Fields({ + 'foo': ['bar'] + }).isNotEmpty, + isTrue); + }); + + test('add, remove, clear', () { + final f = Fields(); + f['foo'] = ['bar']; + f['bar'] = ['foo']; + expect(f['foo'], ['bar']); + expect(f['bar'], ['foo']); + f.remove('foo'); + expect(f['foo'], isNull); + f.clear(); + expect(f.isEmpty, isTrue); + }); + + test('can decode url without duplicate keys', () { + final uri = Uri.parse( + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); + final fields = Fields.fromUri(uri); + expect(fields['articles'], ['title', 'body']); + expect(fields['people'], ['name']); + }); + + test('can decode url with duplicate keys', () { + final uri = Uri.parse( + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); + final fields = Fields.fromUri(uri); + expect(fields['articles'], ['title', 'body']); + expect(fields['people'], ['name', 'age']); + }); + + test('can convert to query parameters', () { + expect( + Fields({ + 'articles': ['title', 'body'], + 'people': ['name'] + }).asQueryParameters, + {'fields[articles]': 'title,body', 'fields[people]': 'name'}); + }); + }); +} diff --git a/test/query/filter_test.dart b/test/query/filter_test.dart new file mode 100644 index 00000000..5c8f0286 --- /dev/null +++ b/test/query/filter_test.dart @@ -0,0 +1,37 @@ +import 'package:json_api/query.dart'; +import 'package:test/test.dart'; + +void main() { + group('Filter', () { + test('emptiness', () { + expect(Filter().isEmpty, isTrue); + expect(Filter().isNotEmpty, isFalse); + expect(Filter({'foo': 'bar'}).isEmpty, isFalse); + expect(Filter({'foo': 'bar'}).isNotEmpty, isTrue); + }); + + test('add, remove, clear', () { + final f = Filter(); + f['foo'] = 'bar'; + f['bar'] = 'foo'; + expect(f['foo'], 'bar'); + expect(f['bar'], 'foo'); + f.remove('foo'); + expect(f['foo'], isNull); + f.clear(); + expect(f.isEmpty, isTrue); + }); + + test('Can decode url', () { + final uri = Uri.parse('/articles?filter[post]=1,2&filter[author]=12'); + final filter = Filter.fromUri(uri); + expect(filter['post'], '1,2'); + expect(filter['author'], '12'); + }); + + test('Can convert to query parameters', () { + expect(Filter({'post': '1,2', 'author': '12'}).asQueryParameters, + {'filter[post]': '1,2', 'filter[author]': '12'}); + }); + }); +} diff --git a/test/query/include_test.dart b/test/query/include_test.dart new file mode 100644 index 00000000..ef91bcc9 --- /dev/null +++ b/test/query/include_test.dart @@ -0,0 +1,31 @@ +import 'package:json_api/src/query/include.dart'; +import 'package:test/test.dart'; + +void main() { + test('emptiness', () { + expect(Include().isEmpty, isTrue); + expect(Include().isNotEmpty, isFalse); + expect(Include().length, 0); + expect(Include(['foo']).isEmpty, isFalse); + expect(Include(['foo']).isNotEmpty, isTrue); + expect(Include(['foo']).length, 1); + }); + + test('Can decode url without duplicate keys', () { + final uri = Uri.parse('/articles/1?include=author,comments.author'); + final include = Include.fromUri(uri); + expect(include, equals(['author', 'comments.author'])); + }); + + test('Can decode url with duplicate keys', () { + final uri = + Uri.parse('/articles/1?include=author,comments.author&include=tags'); + final include = Include.fromUri(uri); + expect(include, equals(['author', 'comments.author', 'tags'])); + }); + + test('Can convert to query parameters', () { + expect(Include(['author', 'comments.author']).asQueryParameters, + {'include': 'author,comments.author'}); + }); +} diff --git a/test/query/page_test.dart b/test/query/page_test.dart new file mode 100644 index 00000000..5b878e82 --- /dev/null +++ b/test/query/page_test.dart @@ -0,0 +1,37 @@ +import 'package:json_api/query.dart'; +import 'package:test/test.dart'; + +void main() { + group('Page', () { + test('emptiness', () { + expect(Page().isEmpty, isTrue); + expect(Page().isNotEmpty, isFalse); + expect(Page({'foo': 'bar'}).isEmpty, isFalse); + expect(Page({'foo': 'bar'}).isNotEmpty, isTrue); + }); + + test('add, remove, clear', () { + final p = Page(); + p['foo'] = 'bar'; + p['bar'] = 'foo'; + expect(p['foo'], 'bar'); + expect(p['bar'], 'foo'); + p.remove('foo'); + expect(p['foo'], isNull); + p.clear(); + expect(p.isEmpty, isTrue); + }); + + test('can decode url', () { + final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); + final page = Page.fromUri(uri); + expect(page['limit'], '10'); + expect(page['offset'], '20'); + }); + + test('can convert to query parameters', () { + expect(Page({'limit': '10', 'offset': '20'}).asQueryParameters, + {'page[limit]': '10', 'page[offset]': '20'}); + }); + }); +} diff --git a/test/query/sort_test.dart b/test/query/sort_test.dart new file mode 100644 index 00000000..effa6e72 --- /dev/null +++ b/test/query/sort_test.dart @@ -0,0 +1,32 @@ +import 'package:json_api/src/query/sort.dart'; +import 'package:test/test.dart'; + +void main() { + test('emptiness', () { + expect(Sort().isEmpty, isTrue); + expect(Sort().isNotEmpty, isFalse); + expect(Sort(['-created']).isEmpty, isFalse); + expect(Sort(['-created']).isNotEmpty, isTrue); + }); + + test('Can decode url without duplicate keys', () { + final uri = Uri.parse('/articles?sort=-created,title'); + final sort = Sort.fromUri(uri); + expect(sort.length, 2); + expect(sort.first.name, 'created'); + expect(sort.last.name, 'title'); + }); + + test('Can decode url with duplicate keys', () { + final uri = Uri.parse('/articles?sort=-created&sort=title'); + final sort = Sort.fromUri(uri); + expect(sort.length, 2); + expect(sort.first.name, 'created'); + expect(sort.last.name, 'title'); + }); + + test('Can convert to query parameters', () { + expect(Sort(['-created', 'title']).asQueryParameters, + {'sort': '-created,title'}); + }); +} diff --git a/test/responses.dart b/test/responses.dart deleted file mode 100644 index 2918e2db..00000000 --- a/test/responses.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/json_api.dart'; -import 'package:json_api_common/http.dart'; - -final collection = HttpResponse(200, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page[offset]=2', - 'last': 'http://example.com/articles?page[offset]=10' - }, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'relationships': { - 'author': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/author', - 'related': 'http://example.com/articles/1/author' - }, - 'data': {'type': 'people', 'id': '9'} - }, - 'comments': { - 'links': { - 'self': 'http://example.com/articles/1/relationships/comments', - 'related': 'http://example.com/articles/1/comments' - }, - 'data': [ - {'type': 'comments', 'id': '5'}, - {'type': 'comments', 'id': '12'} - ] - } - }, - 'links': {'self': 'http://example.com/articles/1'} - } - ], - 'included': [ - { - 'type': 'people', - 'id': '9', - 'attributes': { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' - }, - 'links': {'self': 'http://example.com/people/9'} - }, - { - 'type': 'comments', - 'id': '5', - 'attributes': {'body': 'First!'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '2'} - } - }, - 'links': {'self': 'http://example.com/comments/5'} - }, - { - 'type': 'comments', - 'id': '12', - 'attributes': {'body': 'I like XML better'}, - 'relationships': { - 'author': { - 'data': {'type': 'people', 'id': '9'} - } - }, - 'links': {'self': 'http://example.com/comments/12'} - } - ] - })); - -final primaryResource = HttpResponse(200, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'links': {'self': 'http://example.com/articles/1'}, - 'data': { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'relationships': { - 'author': { - 'links': {'related': 'http://example.com/articles/1/author'} - } - } - } - })); -final relatedResourceNull = HttpResponse(200, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'links': {'self': 'http://example.com/articles/1/author'}, - 'data': null - })); -final one = HttpResponse(200, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'links': { - 'self': '/articles/1/relationships/author', - 'related': '/articles/1/author' - }, - 'data': {'type': 'people', 'id': '12'} - })); - -final many = HttpResponse(200, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'links': { - 'self': '/articles/1/relationships/tags', - 'related': '/articles/1/tags' - }, - 'data': [ - {'type': 'tags', 'id': '12'} - ] - })); -final error422 = HttpResponse(422, - headers: {'Content-Type': ContentType.jsonApi}, - body: jsonEncode({ - 'errors': [ - { - 'status': '422', - 'source': {'pointer': '/data/attributes/firstName'}, - 'title': 'Invalid Attribute', - 'detail': 'First name must contain at least three characters.' - } - ] - })); diff --git a/test/routing/url_test.dart b/test/routing/url_test.dart new file mode 100644 index 00000000..87272a5e --- /dev/null +++ b/test/routing/url_test.dart @@ -0,0 +1,36 @@ +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +void main() { + final collection = CollectionTarget('books'); + final resource = ResourceTarget('books', '42'); + final related = RelatedTarget('books', '42', 'author'); + final relationship = RelationshipTarget('books', '42', 'author'); + + test('uri generation', () { + final url = RecommendedUrlDesign.pathOnly; + expect(collection.map(url).toString(), '/books'); + expect(resource.map(url).toString(), '/books/42'); + expect(related.map(url).toString(), '/books/42/author'); + expect(relationship.map(url).toString(), '/books/42/relationships/author'); + }); + + test('Authority is retained if exists in base', () { + final url = RecommendedUrlDesign(Uri.parse('https://example.com')); + expect(collection.map(url).toString(), 'https://example.com/books'); + expect(resource.map(url).toString(), 'https://example.com/books/42'); + expect(related.map(url).toString(), 'https://example.com/books/42/author'); + expect(relationship.map(url).toString(), + 'https://example.com/books/42/relationships/author'); + }); + + test('Authority and path is retained if exists in base (directory path)', () { + final url = RecommendedUrlDesign(Uri.parse('https://example.com/foo/')); + expect(collection.map(url).toString(), 'https://example.com/foo/books'); + expect(resource.map(url).toString(), 'https://example.com/foo/books/42'); + expect( + related.map(url).toString(), 'https://example.com/foo/books/42/author'); + expect(relationship.map(url).toString(), + 'https://example.com/foo/books/42/relationships/author'); + }); +} From dc15c8691f503fcfa756692bea0909775ea58218 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 15 Nov 2020 21:33:35 -0800 Subject: [PATCH 78/99] Unified request --- README.md | 2 +- doc/schema.json | 60 ++++-- example/demo/sqlite_controller.dart | 6 +- lib/client.dart | 17 +- lib/src/client/client_request.dart | 20 ++ lib/src/client/json_api_client.dart | 14 +- lib/src/client/json_api_request.dart | 14 -- lib/src/client/request.dart | 190 ++++++++++++++++++ lib/src/client/request/add_many.dart | 20 -- .../client/request/create_new_resource.dart | 32 --- lib/src/client/request/create_resource.dart | 34 ---- lib/src/client/request/delete_many.dart | 20 -- lib/src/client/request/delete_one.dart | 10 - lib/src/client/request/fetch_collection.dart | 17 -- .../request/fetch_related_collection.dart | 18 -- .../request/fetch_related_resource.dart | 18 -- .../client/request/fetch_relationship.dart | 18 -- lib/src/client/request/fetch_resource.dart | 17 -- .../request/internal/payload_request.dart | 15 -- .../request/internal/simple_request.dart | 55 ----- lib/src/client/request/replace.dart | 20 -- lib/src/client/request/replace_many.dart | 11 - lib/src/client/request/replace_one.dart | 11 - lib/src/client/request/update_resource.dart | 35 ---- .../response/relationship_response.dart | 6 + lib/src/document/identifier.dart | 1 + lib/src/document/identity.dart | 4 +- lib/src/document/outbound_document.dart | 56 +++--- lib/src/server/json_api_handler.dart | 2 +- pubspec.yaml | 4 +- test/client/client_test.dart | 103 +++++----- test/document/outbound_document_test.dart | 20 +- test/e2e/e2e_test.dart | 16 +- test/integration/integration_test.dart | 16 +- tmp/e2e/browser_test.dart | 39 ---- tmp/e2e/client_server_interaction_test.dart | 51 ----- tmp/e2e/hybrid_server.dart | 14 -- 37 files changed, 389 insertions(+), 617 deletions(-) create mode 100644 lib/src/client/client_request.dart delete mode 100644 lib/src/client/json_api_request.dart create mode 100644 lib/src/client/request.dart delete mode 100644 lib/src/client/request/add_many.dart delete mode 100644 lib/src/client/request/create_new_resource.dart delete mode 100644 lib/src/client/request/create_resource.dart delete mode 100644 lib/src/client/request/delete_many.dart delete mode 100644 lib/src/client/request/delete_one.dart delete mode 100644 lib/src/client/request/fetch_collection.dart delete mode 100644 lib/src/client/request/fetch_related_collection.dart delete mode 100644 lib/src/client/request/fetch_related_resource.dart delete mode 100644 lib/src/client/request/fetch_relationship.dart delete mode 100644 lib/src/client/request/fetch_resource.dart delete mode 100644 lib/src/client/request/internal/payload_request.dart delete mode 100644 lib/src/client/request/internal/simple_request.dart delete mode 100644 lib/src/client/request/replace.dart delete mode 100644 lib/src/client/request/replace_many.dart delete mode 100644 lib/src/client/request/replace_one.dart delete mode 100644 lib/src/client/request/update_resource.dart delete mode 100644 tmp/e2e/browser_test.dart delete mode 100644 tmp/e2e/client_server_interaction_test.dart delete mode 100644 tmp/e2e/hybrid_server.dart diff --git a/README.md b/README.md index 8977d566..521ae758 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This package consists of several libraries: - direct modification of relationships (both to-one and to-many) - [async processing](https://jsonapi.org/recommendations/#asynchronous-processing) -The client returns back a [Response] which contains the HTTP status code, headers and the JSON:API [Document]. +The client returns a [Response] which contains the HTTP status code, headers and the JSON:API [Document]. Sometimes the request URIs can be inferred from the context. For such cases you may use the [RoutingClient] which is a wrapper over the [JsonApiClient] capable of inferring the URIs. diff --git a/doc/schema.json b/doc/schema.json index a06f524b..615a1a11 100644 --- a/doc/schema.json +++ b/doc/schema.json @@ -13,7 +13,6 @@ "$ref": "#/definitions/info" } ], - "definitions": { "success": { "type": "object", @@ -95,7 +94,6 @@ }, "additionalProperties": false }, - "meta": { "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", "type": "object", @@ -196,7 +194,6 @@ } ] }, - "attributes": { "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", "type": "object", @@ -207,7 +204,6 @@ }, "additionalProperties": false }, - "relationships": { "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", "type": "object", @@ -233,9 +229,21 @@ } }, "anyOf": [ - {"required": ["data"]}, - {"required": ["meta"]}, - {"required": ["links"]} + { + "required": [ + "data" + ] + }, + { + "required": [ + "meta" + ] + }, + { + "required": [ + "links" + ] + } ], "additionalProperties": false } @@ -291,34 +299,53 @@ "first": { "description": "The first page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "last": { "description": "The last page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "prev": { "description": "The previous page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "next": { "description": "The next page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] } } }, - "jsonapi": { "description": "An object describing the server's implementation", "type": "object", @@ -332,7 +359,6 @@ }, "additionalProperties": false }, - "error": { "type": "object", "properties": { diff --git a/example/demo/sqlite_controller.dart b/example/demo/sqlite_controller.dart index ca976fbc..d34cd7e7 100644 --- a/example/demo/sqlite_controller.dart +++ b/example/demo/sqlite_controller.dart @@ -28,7 +28,7 @@ class SqliteController implements JsonApiController> { final collection = db .select('SELECT * FROM ${_sanitize(target.type)}') .map(_resourceFromRow(target.type)); - final doc = OutboundDocument.collection(collection) + final doc = OutboundDataDocument.collection(collection) ..links['self'] = Link(target.map(urlDesign)); return HttpResponse(200, body: jsonEncode(doc), headers: { 'content-type': MediaType.jsonApi, @@ -40,7 +40,7 @@ class SqliteController implements JsonApiController> { HttpRequest request, ResourceTarget target) async { final resource = _fetchResource(target.type, target.id); final self = Link(target.map(urlDesign)); - final doc = OutboundDocument.resource(resource)..links['self'] = self; + final doc = OutboundDataDocument.resource(resource)..links['self'] = self; return HttpResponse(200, body: jsonEncode(doc), headers: {'content-type': MediaType.jsonApi}); } @@ -59,7 +59,7 @@ class SqliteController implements JsonApiController> { final self = Link(ResourceTarget(resource.type, resource.id).map(urlDesign)); resource.links['self'] = self; - final doc = OutboundDocument.resource(resource)..links['self'] = self; + final doc = OutboundDataDocument.resource(resource)..links['self'] = self; return HttpResponse(201, body: jsonEncode(doc), headers: { 'content-type': MediaType.jsonApi, 'location': self.uri.toString() diff --git a/lib/client.dart b/lib/client.dart index 42ba48ff..b5d25fbe 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,21 +1,10 @@ library json_api; +export 'package:json_api/src/client/client_request.dart'; +export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/request_failure.dart'; -export 'package:json_api/src/client/dart_http.dart'; -export 'package:json_api/src/client/request/add_many.dart'; -export 'package:json_api/src/client/request/create_new_resource.dart'; -export 'package:json_api/src/client/request/create_resource.dart'; -export 'package:json_api/src/client/request/delete_many.dart'; -export 'package:json_api/src/client/request/delete_one.dart'; -export 'package:json_api/src/client/request/fetch_collection.dart'; -export 'package:json_api/src/client/request/fetch_related_collection.dart'; -export 'package:json_api/src/client/request/fetch_related_resource.dart'; -export 'package:json_api/src/client/request/fetch_relationship.dart'; -export 'package:json_api/src/client/request/fetch_resource.dart'; -export 'package:json_api/src/client/request/replace_many.dart'; -export 'package:json_api/src/client/request/replace_one.dart'; -export 'package:json_api/src/client/request/update_resource.dart'; export 'package:json_api/src/client/response/collection_response.dart'; export 'package:json_api/src/client/response/relationship_response.dart'; export 'package:json_api/src/client/response/resource_response.dart'; diff --git a/lib/src/client/client_request.dart b/lib/src/client/client_request.dart new file mode 100644 index 00000000..1c14c491 --- /dev/null +++ b/lib/src/client/client_request.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; + +abstract class ClientRequest { + /// HTTP method + String get method; + + /// The outbound document. Nullable. + OutboundDocument /*?*/ get document; + + /// Any extra headers. + Map get headers; + + /// Returns the request URI + Uri uri(TargetMapper urls); + + /// Converts the HTTP response to the response object + T response(HttpResponse response); +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 7fc4da12..e874dea9 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,7 +1,9 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/json_api_request.dart'; +import 'package:json_api/src/client/client_request.dart'; import 'package:json_api/src/client/request_failure.dart'; import 'package:json_api/src/http/media_type.dart'; @@ -16,7 +18,7 @@ class JsonApiClient { /// Returns the response when the server responds with a JSON:API document. /// Throws a [RequestFailure] if the server responds with a JSON:API error. /// Throws a [ServerError] if the server responds with a non-JSON:API error. - Future call(JsonApiRequest request) async { + Future call(ClientRequest request) async { final response = await _http.call(_toHttp(request)); if (!response.isSuccessful && !response.isPending) { throw RequestFailure(response, @@ -27,13 +29,15 @@ class JsonApiClient { return request.response(response); } - HttpRequest _toHttp(JsonApiRequest request) { + HttpRequest _toHttp(ClientRequest request) { final headers = {'accept': MediaType.jsonApi}; - if (request.body.isNotEmpty) { + var body = ''; + if (request.document != null) { headers['content-type'] = MediaType.jsonApi; + body = jsonEncode(request.document); } headers.addAll(request.headers); return HttpRequest(request.method, request.uri(_uriFactory), - body: request.body, headers: headers); + body: body, headers: headers); } } diff --git a/lib/src/client/json_api_request.dart b/lib/src/client/json_api_request.dart deleted file mode 100644 index 755b1da4..00000000 --- a/lib/src/client/json_api_request.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; - -abstract class JsonApiRequest { - String get method; - - String get body; - - Map get headers; - - Uri uri(TargetMapper urls); - - T response(HttpResponse response); -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart new file mode 100644 index 00000000..9d2e4a50 --- /dev/null +++ b/lib/src/client/request.dart @@ -0,0 +1,190 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/client_request.dart'; +import 'package:json_api/src/client/response/collection_response.dart'; +import 'package:json_api/src/client/response/new_resource_response.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; + +class Request implements ClientRequest { + Request(this.method, this.target, this.convert, [this.document]); + + /// Adds identifiers to a to-many relationship + static Request> addMany(String type, String id, + String relationship, List identifiers) => + Request( + 'post', + RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeMany, + OutboundDataDocument.many(Many(identifiers))); + + /// Creates a new resource on the server. The server is responsible for assigning the resource id. + static Request createNew(String type, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}}) => + Request( + 'post', + CollectionTarget(type), + NewResourceResponse.decode, + OutboundDataDocument.newResource(NewResource(type) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta))); + + static Request> deleteMany(String type, String id, + String relationship, List identifiers) => + Request( + 'delete', + RelationshipTarget(type, id, relationship), + RelationshipResponse.decode, + OutboundDataDocument.many(Many(identifiers))); + + static Request fetchCollection(String type) => + Request('get', CollectionTarget(type), CollectionResponse.decode); + + static Request fetchRelatedCollection( + String type, String id, String relationship) => + Request('get', RelatedTarget(type, id, relationship), + CollectionResponse.decode); + + static Request fetchRelationship( + String type, String id, String relationship) => + Request('get', RelationshipTarget(type, id, relationship), + RelationshipResponse.decode); + + static Request fetchRelatedResource( + String type, String id, String relationship) => + Request('get', RelatedTarget(type, id, relationship), + ResourceResponse.decode); + + static Request fetchResource(String type, String id) => + Request('get', ResourceTarget(type, id), ResourceResponse.decode); + + static Request updateResource( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + }) => + Request( + 'patch', + ResourceTarget(type, id), + ResourceResponse.decode, + OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta))); + + /// Creates a new resource with the given id on the server. + static Request create( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + }) => + Request( + 'post', + CollectionTarget(type), + ResourceResponse.decode, + OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((k, v) => MapEntry(k, One(v))), + ...many.map((k, v) => MapEntry(k, Many(v))), + }) + ..meta.addAll(meta))); + + static Request> replaceOne( + String type, String id, String relationship, Identifier identifier) => + Request( + 'patch', + RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeOne, + OutboundDataDocument.one(One(identifier))); + + static Request> replaceMany(String type, String id, + String relationship, Iterable identifiers) => + Request( + 'patch', + RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeMany, + OutboundDataDocument.many(Many(identifiers))); + + static Request> deleteOne( + String type, String id, String relationship) => + Request( + 'patch', + RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeOne, + OutboundDataDocument.one(One.empty())); + + /// Request target + final Target target; + + @override + final String method; + + @override + final OutboundDocument document; + + final ResponseConverter convert; + + @override + final headers = {}; + + @override + Uri uri(TargetMapper urls) { + final path = target.map(urls); + return query.isEmpty + ? path + : path.replace(queryParameters: {...path.queryParameters, ...query}); + } + + /// URL Query String parameters + final query = {}; + + /// Adds the request to include the [related] resources to the [query]. + void include(Iterable related) { + query.addAll(Include(related).asQueryParameters); + } + + /// Adds the request for the sparse [fields] to the [query]. + void fields(Map> fields) { + query.addAll(Fields(fields).asQueryParameters); + } + + /// Adds the request for pagination to the [query]. + void page(Map page) { + query.addAll(Page(page).asQueryParameters); + } + + /// Adds the filter parameters to the [query]. + void filter(Map page) { + query.addAll(Filter(page).asQueryParameters); + } + + /// Adds the request for page sorting to the [query]. + void sort(Iterable fields) { + query.addAll(Sort(fields).asQueryParameters); + } + + @override + T response(HttpResponse response) => convert(response); +} + +typedef ResponseConverter = T Function(HttpResponse response); diff --git a/lib/src/client/request/add_many.dart b/lib/src/client/request/add_many.dart deleted file mode 100644 index 1470d9a1..00000000 --- a/lib/src/client/request/add_many.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; - -class AddMany extends PayloadRequest> { - AddMany(this.target, Many many) : super('post', many); - - AddMany.build( - String type, String id, String relationship, List identifiers) - : this(RelationshipTarget(type, id, relationship), Many(identifiers)); - - @override - final RelationshipTarget target; - - @override - RelationshipResponse response(HttpResponse response) => - RelationshipResponse.decode(response); -} diff --git a/lib/src/client/request/create_new_resource.dart b/lib/src/client/request/create_new_resource.dart deleted file mode 100644 index f7dc399f..00000000 --- a/lib/src/client/request/create_new_resource.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/new_resource_response.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class CreateNewResource extends PayloadRequest { - CreateNewResource(this.target, NewResource properties) - : super('post', {'data': properties}); - - CreateNewResource.build(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}}) - : this( - CollectionTarget(type), - NewResource(type) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), - }) - ..meta.addAll(meta)); - - @override - final CollectionTarget target; - - @override - NewResourceResponse response(HttpResponse response) => - NewResourceResponse.decode(response); -} diff --git a/lib/src/client/request/create_resource.dart b/lib/src/client/request/create_resource.dart deleted file mode 100644 index 8e247f1b..00000000 --- a/lib/src/client/request/create_resource.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class CreateResource extends PayloadRequest { - CreateResource(this.target, Resource resource) - : super('post', {'data': resource}); - - CreateResource.build( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - }) : this( - CollectionTarget(type), - Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((k, v) => MapEntry(k, One(v))), - ...many.map((k, v) => MapEntry(k, Many(v))), - }) - ..meta.addAll(meta)); - - @override - final CollectionTarget target; - - @override - ResourceResponse response(HttpResponse response) => - ResourceResponse.decode(response); -} diff --git a/lib/src/client/request/delete_many.dart b/lib/src/client/request/delete_many.dart deleted file mode 100644 index c17b2688..00000000 --- a/lib/src/client/request/delete_many.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class DeleteMany extends PayloadRequest> { - DeleteMany(this.target, Many many) : super('delete', many); - - DeleteMany.build( - String type, String id, String relationship, List identifiers) - : this(RelationshipTarget(type, id, relationship), Many(identifiers)); - - @override - final RelationshipTarget target; - - @override - RelationshipResponse response(HttpResponse response) => - RelationshipResponse.decode(response); -} diff --git a/lib/src/client/request/delete_one.dart b/lib/src/client/request/delete_one.dart deleted file mode 100644 index e9d25e7a..00000000 --- a/lib/src/client/request/delete_one.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class DeleteOne extends ReplaceOne { - DeleteOne(RelationshipTarget target) : super(target, One.empty()); - - DeleteOne.build(String type, String id, String relationship) - : this(RelationshipTarget(type, id, relationship)); -} diff --git a/lib/src/client/request/fetch_collection.dart b/lib/src/client/request/fetch_collection.dart deleted file mode 100644 index a1313762..00000000 --- a/lib/src/client/request/fetch_collection.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/client/response/collection_response.dart'; -import 'package:json_api/routing.dart'; - -class FetchCollection extends SimpleRequest { - FetchCollection(String type) : this.build(CollectionTarget(type)); - - FetchCollection.build(this.target) : super('get'); - - @override - final CollectionTarget target; - - @override - CollectionResponse response(HttpResponse response) => - CollectionResponse.decode(response); -} diff --git a/lib/src/client/request/fetch_related_collection.dart b/lib/src/client/request/fetch_related_collection.dart deleted file mode 100644 index cd4a3ffc..00000000 --- a/lib/src/client/request/fetch_related_collection.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/client/response/collection_response.dart'; -import 'package:json_api/routing.dart'; - -class FetchRelatedCollection extends SimpleRequest { - FetchRelatedCollection(String type, String id, String relationship) - : this.build(RelatedTarget(type, id, relationship)); - - FetchRelatedCollection.build(this.target) : super('get'); - - @override - final RelatedTarget target; - - @override - CollectionResponse response(HttpResponse response) => - CollectionResponse.decode(response); -} diff --git a/lib/src/client/request/fetch_related_resource.dart b/lib/src/client/request/fetch_related_resource.dart deleted file mode 100644 index e86cd08c..00000000 --- a/lib/src/client/request/fetch_related_resource.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; -import 'package:json_api/routing.dart'; - -class FetchRelatedResource extends SimpleRequest { - FetchRelatedResource(String type, String id, String relationship) - : this.build(RelatedTarget(type, id, relationship)); - - FetchRelatedResource.build(this.target) : super('get'); - - @override - final RelatedTarget target; - - @override - ResourceResponse response(HttpResponse response) => - ResourceResponse.decode(response); -} diff --git a/lib/src/client/request/fetch_relationship.dart b/lib/src/client/request/fetch_relationship.dart deleted file mode 100644 index 93174234..00000000 --- a/lib/src/client/request/fetch_relationship.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; -import 'package:json_api/routing.dart'; - -class FetchRelationship extends SimpleRequest { - FetchRelationship(String type, String id, String relationship) - : this.build(RelationshipTarget(type, id, relationship)); - - FetchRelationship.build(this.target) : super('get'); - - @override - final RelationshipTarget target; - - @override - RelationshipResponse response(HttpResponse response) => - RelationshipResponse.decode(response); -} diff --git a/lib/src/client/request/fetch_resource.dart b/lib/src/client/request/fetch_resource.dart deleted file mode 100644 index 09dcd35d..00000000 --- a/lib/src/client/request/fetch_resource.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; -import 'package:json_api/routing.dart'; - -class FetchResource extends SimpleRequest { - FetchResource(this.target) : super('get'); - - FetchResource.build(String type, String id) : this(ResourceTarget(type, id)); - - @override - final ResourceTarget target; - - @override - ResourceResponse response(HttpResponse response) => - ResourceResponse.decode(response); -} diff --git a/lib/src/client/request/internal/payload_request.dart b/lib/src/client/request/internal/payload_request.dart deleted file mode 100644 index ce915770..00000000 --- a/lib/src/client/request/internal/payload_request.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/src/client/request/internal/simple_request.dart'; -import 'package:json_api/src/http/media_type.dart'; - -abstract class PayloadRequest extends SimpleRequest { - PayloadRequest(String method, Object payload) - : body = jsonEncode(payload), - super(method) { - headers['content-type'] = MediaType.jsonApi; - } - - @override - final String body; -} diff --git a/lib/src/client/request/internal/simple_request.dart b/lib/src/client/request/internal/simple_request.dart deleted file mode 100644 index c5cd7efb..00000000 --- a/lib/src/client/request/internal/simple_request.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/json_api_request.dart'; -import 'package:json_api/src/http/media_type.dart'; - -abstract class SimpleRequest implements JsonApiRequest { - SimpleRequest(this.method); - - Target get target; - - @override - final String method; - - @override - final body = ''; - - @override - final headers = {'accept': MediaType.jsonApi}; - - @override - Uri uri(TargetMapper urls) { - final path = target.map(urls); - return query.isEmpty - ? path - : path.replace(queryParameters: {...path.queryParameters, ...query}); - } - - /// URL Query String parameters - final query = {}; - - /// Adds the request to include the [related] resources to the [query]. - void include(Iterable related) { - query.addAll(Include(related).asQueryParameters); - } - - /// Adds the request for the sparse [fields] to the [query]. - void fields(Map> fields) { - query.addAll(Fields(fields).asQueryParameters); - } - - /// Adds the request for pagination to the [query]. - void page(Map page) { - query.addAll(Page(page).asQueryParameters); - } - - /// Adds the filter parameters to the [query]. - void filter(Map page) { - query.addAll(Filter(page).asQueryParameters); - } - - /// Adds the request for page sorting to the [query]. - void sort(Iterable fields) { - query.addAll(Sort(fields).asQueryParameters); - } -} diff --git a/lib/src/client/request/replace.dart b/lib/src/client/request/replace.dart deleted file mode 100644 index 558f0f35..00000000 --- a/lib/src/client/request/replace.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; - -class Replace - extends PayloadRequest> { - Replace(this.target, R data) : super('patch', data); - - Replace.build(String type, String id, String relationship, R data) - : this(RelationshipTarget(type, id, relationship), data); - - @override - final RelationshipTarget target; - - @override - RelationshipResponse response(HttpResponse response) => - RelationshipResponse.decode(response); -} diff --git a/lib/src/client/request/replace_many.dart b/lib/src/client/request/replace_many.dart deleted file mode 100644 index b097f0dc..00000000 --- a/lib/src/client/request/replace_many.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/src/client/request/replace.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class ReplaceMany extends Replace { - ReplaceMany(RelationshipTarget target, Many many) : super(target, many); - - ReplaceMany.build(String type, String id, String relationship, - Iterable identifiers) - : super.build(type, id, relationship, Many(identifiers)); -} diff --git a/lib/src/client/request/replace_one.dart b/lib/src/client/request/replace_one.dart deleted file mode 100644 index f3e501af..00000000 --- a/lib/src/client/request/replace_one.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/src/client/request/replace.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class ReplaceOne extends Replace { - ReplaceOne(RelationshipTarget target, One one) : super(target, one); - - ReplaceOne.build( - String type, String id, String relationship, Identifier identifier) - : super.build(type, id, relationship, One(identifier)); -} diff --git a/lib/src/client/request/update_resource.dart b/lib/src/client/request/update_resource.dart deleted file mode 100644 index 9e612dd9..00000000 --- a/lib/src/client/request/update_resource.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/request/internal/payload_request.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; - -class UpdateResource extends PayloadRequest { - UpdateResource( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - }) : this.build( - ResourceTarget(type, id), - Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), - }) - ..meta.addAll(meta)); - - UpdateResource.build(this.target, Resource resource) - : super('patch', {'data': resource}); - - @override - final ResourceTarget target; - - /// Returns [ResourceResponse] - @override - ResourceResponse response(HttpResponse response) => - ResourceResponse.decode(response); -} diff --git a/lib/src/client/response/relationship_response.dart b/lib/src/client/response/relationship_response.dart index 8c02a8ff..907ec686 100644 --- a/lib/src/client/response/relationship_response.dart +++ b/lib/src/client/response/relationship_response.dart @@ -7,6 +7,12 @@ class RelationshipResponse { {Iterable included = const []}) : included = IdentityCollection(included); + static RelationshipResponse decodeMany(HttpResponse response) => + decode(response); + + static RelationshipResponse decodeOne(HttpResponse response) => + decode(response); + static RelationshipResponse decode( HttpResponse response) { final doc = InboundDocument.decode(response.body); diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index f12a4476..d10e9460 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -10,6 +10,7 @@ class Identifier with Identity { @override final String id; + /// Identifier meta-data. final meta = {}; Map toJson() => diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart index 1a6fc514..3e5ecbe1 100644 --- a/lib/src/document/identity.dart +++ b/lib/src/document/identity.dart @@ -1,5 +1,7 @@ /// Resource identity. mixin Identity { + static const separator = ':'; + /// Resource type String get type; @@ -7,5 +9,5 @@ mixin Identity { String get id; /// Compound key, uniquely identifying the resource - String get key => '$type:$id'; + String get key => '$type$separator$id'; } diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index bc1f6eda..f08c3777 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -4,30 +4,6 @@ import 'package:json_api/src/document/resource.dart'; /// An empty outbound document. class OutboundDocument { - /// Creates an instance of a document containing a single resource as the primary data. - static OutboundDataDocument resource(Resource resource) => - OutboundDataDocument._(resource); - - /// Creates an instance of a document containing a collection of resources as the primary data. - static OutboundDataDocument> collection( - Iterable collection) => - OutboundDataDocument._(collection.toList()); - - /// Creates an instance of a document containing a to-one relationship. - static OutboundDataDocument one(One one) => - OutboundDataDocument._(one.identifier) - ..meta.addAll(one.meta) - ..links.addAll(one.links); - - /// Creates an instance of a document containing a to-many relationship. - static OutboundDataDocument> many(Many many) => - OutboundDataDocument._(many.toList()) - ..meta.addAll(many.meta) - ..links.addAll(many.links); - - static OutboundErrorDocument error(Iterable errors) => - OutboundErrorDocument._()..errors.addAll(errors); - /// The document "meta" object. final meta = {}; @@ -36,7 +12,9 @@ class OutboundDocument { /// An outbound error document. class OutboundErrorDocument extends OutboundDocument { - OutboundErrorDocument._(); + OutboundErrorDocument(Iterable errors) { + this.errors.addAll(errors); + } /// The list of errors. final errors = []; @@ -49,10 +27,30 @@ class OutboundErrorDocument extends OutboundDocument { } /// An outbound data document. -class OutboundDataDocument extends OutboundDocument { - OutboundDataDocument._(this.data); +class OutboundDataDocument extends OutboundDocument { + /// Creates an instance of a document containing a single resource as the primary data. + OutboundDataDocument.resource(Resource resource) : _data = resource; + + /// Creates an instance of a document containing a single to-be-created resource as the primary data. Used only in client-to-server requests. + OutboundDataDocument.newResource(NewResource resource) : _data = resource; + + /// Creates an instance of a document containing a collection of resources as the primary data. + OutboundDataDocument.collection(Iterable collection) + : _data = collection.toList(); + + /// Creates an instance of a document containing a to-one relationship. + OutboundDataDocument.one(One one) : _data = one.identifier { + meta.addAll(one.meta); + links.addAll(one.links); + } + + /// Creates an instance of a document containing a to-many relationship. + OutboundDataDocument.many(Many many) : _data = many.toList() { + meta.addAll(many.meta); + links.addAll(many.links); + } - final D data; + final Object _data; /// Links related to the primary data. final links = {}; @@ -62,7 +60,7 @@ class OutboundDataDocument extends OutboundDocument { @override Map toJson() => { - 'data': data, + 'data': _data, if (links.isNotEmpty) 'links': links, if (included.isNotEmpty) 'included': included, if (meta.isNotEmpty) 'meta': meta, diff --git a/lib/src/server/json_api_handler.dart b/lib/src/server/json_api_handler.dart index ca921fc2..423f0595 100644 --- a/lib/src/server/json_api_handler.dart +++ b/lib/src/server/json_api_handler.dart @@ -34,7 +34,7 @@ class JsonApiHandler implements HttpHandler { if (e is Error) { error.meta['stackTrace'] = e.stackTrace.toString().trim().split('\n'); } - body = jsonEncode(OutboundDocument.error([error])); + body = jsonEncode(OutboundErrorDocument([error])); } return HttpResponse(500, body: body); } diff --git a/pubspec.yaml b/pubspec.yaml index e48f6f67..a8cb3eea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,11 @@ name: json_api -version: 5.0.0-dev.1 +version: 5.0.0-dev.2 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: sdk: '>=2.10.0 <3.0.0' +dependencies: + http: ^0.12.2 dev_dependencies: uuid: ^2.2.2 pedantic: ^1.9.2 diff --git a/test/client/client_test.dart b/test/client/client_test.dart index 769161de..ec65861c 100644 --- a/test/client/client_test.dart +++ b/test/client/client_test.dart @@ -16,7 +16,7 @@ void main() { test('RequestFailure', () async { http.response = mock.error422; try { - await client(FetchCollection('articles')); + await client(Request.fetchCollection('articles')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -27,7 +27,7 @@ void main() { test('ServerError', () async { http.response = mock.error500; try { - await client(FetchCollection('articles')); + await client(Request.fetchCollection('articles')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 500); @@ -38,7 +38,7 @@ void main() { group('Fetch Collection', () { test('Min', () async { http.response = mock.collectionMin; - final response = await client.call(FetchCollection('articles')); + final response = await client.call(Request.fetchCollection('articles')); expect(response.collection.single.key, 'articles:1'); expect(response.included, isEmpty); expect(http.request.method, 'get'); @@ -49,7 +49,7 @@ void main() { test('Full', () async { http.response = mock.collectionFull; - final response = await client.call(FetchCollection('articles') + final response = await client.call(Request.fetchCollection('articles') ..headers['foo'] = 'bar' ..query['foo'] = 'bar' ..include(['author']) @@ -77,8 +77,8 @@ void main() { group('Fetch Related Collection', () { test('Min', () async { http.response = mock.collectionFull; - final response = - await client(FetchRelatedCollection('people', '1', 'articles')); + final response = await client( + Request.fetchRelatedCollection('people', '1', 'articles')); expect(response.collection.length, 1); expect(http.request.method, 'get'); expect(http.request.uri.path, '/people/1/articles'); @@ -87,8 +87,8 @@ void main() { test('Full', () async { http.response = mock.collectionFull; - final response = - await client.call(FetchRelatedCollection('people', '1', 'articles') + final response = await client + .call(Request.fetchRelatedCollection('people', '1', 'articles') ..headers['foo'] = 'bar' ..query['foo'] = 'bar' ..include(['author']) @@ -117,7 +117,7 @@ void main() { group('Fetch Primary Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(FetchResource.build('articles', '1')); + final response = await client(Request.fetchResource('articles', '1')); expect(response.resource.type, 'articles'); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1'); @@ -126,7 +126,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = await client(FetchResource.build('articles', '1') + final response = await client(Request.fetchResource('articles', '1') ..headers['foo'] = 'bar' ..include(['author']) ..fields({ @@ -148,7 +148,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = - await client(FetchRelatedResource('articles', '1', 'author')); + await client(Request.fetchRelatedResource('articles', '1', 'author')); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -159,7 +159,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; final response = - await client(FetchRelatedResource('articles', '1', 'author') + await client(Request.fetchRelatedResource('articles', '1', 'author') ..headers['foo'] = 'bar' ..include(['author']) ..fields({ @@ -181,7 +181,7 @@ void main() { test('Min', () async { http.response = mock.one; final response = - await client(FetchRelationship('articles', '1', 'author')); + await client(Request.fetchRelationship('articles', '1', 'author')); expect(response.relationship, isA()); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -191,9 +191,10 @@ void main() { test('Full', () async { http.response = mock.one; - final response = await client(FetchRelationship('articles', '1', 'author') - ..headers['foo'] = 'bar' - ..query['foo'] = 'bar'); + final response = + await client(Request.fetchRelationship('articles', '1', 'author') + ..headers['foo'] = 'bar' + ..query['foo'] = 'bar'); expect(response.relationship, isA()); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -207,7 +208,7 @@ void main() { group('Create New Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(CreateNewResource.build('articles')); + final response = await client(Request.createNew('articles')); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); @@ -225,8 +226,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = - await client(CreateNewResource.build('articles', attributes: { + final response = await client(Request.createNew('articles', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -235,7 +235,7 @@ void main() { }, meta: { 'answer': 42 }) - ..headers['foo'] = 'bar'); + ..headers['foo'] = 'bar'); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); @@ -275,7 +275,7 @@ void main() { group('Create Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(CreateResource.build('articles', '1')); + final response = await client(Request.create('articles', '1')); expect(response.resource.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); @@ -290,7 +290,7 @@ void main() { test('Min with 204 No Content', () async { http.response = mock.noContent; - final response = await client(CreateResource.build('articles', '1')); + final response = await client(Request.create('articles', '1')); expect(response.resource, isNull); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); @@ -306,7 +306,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; final response = - await client(CreateResource.build('articles', '1', attributes: { + await client(Request.create('articles', '1', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -353,7 +353,7 @@ void main() { group('Update Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(UpdateResource('articles', '1')); + final response = await client(Request.updateResource('articles', '1')); expect(response.resource?.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); @@ -368,7 +368,7 @@ void main() { test('Min with 204 No Content', () async { http.response = mock.noContent; - final response = await client(UpdateResource('articles', '1')); + final response = await client(Request.updateResource('articles', '1')); expect(response.resource, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); @@ -384,7 +384,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; final response = - await client(UpdateResource('articles', '1', attributes: { + await client(Request.updateResource('articles', '1', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -431,7 +431,7 @@ void main() { group('Replace One', () { test('Min', () async { http.response = mock.one; - final response = await client(ReplaceOne.build( + final response = await client(Request.replaceOne( 'articles', '1', 'author', Identifier('people', '42'))); expect(response.relationship, isA()); expect(http.request.method, 'patch'); @@ -448,7 +448,8 @@ void main() { test('Full', () async { http.response = mock.one; final response = await client( - ReplaceOne.build('articles', '1', 'author', Identifier('people', '42')) + Request.replaceOne( + 'articles', '1', 'author', Identifier('people', '42')) ..headers['foo'] = 'bar', ); expect(response.relationship, isA()); @@ -467,7 +468,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(ReplaceOne.build( + await client(Request.replaceOne( 'articles', '1', 'author', Identifier('people', '42'))); fail('Exception expected'); } on RequestFailure catch (e) { @@ -479,7 +480,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; expect( - () async => await client(ReplaceOne.build( + () async => await client(Request.replaceOne( 'articles', '1', 'author', Identifier('people', '42'))), throwsFormatException); }); @@ -488,7 +489,8 @@ void main() { group('Delete One', () { test('Min', () async { http.response = mock.oneEmpty; - final response = await client(DeleteOne.build('articles', '1', 'author')); + final response = + await client(Request.deleteOne('articles', '1', 'author')); expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); @@ -503,7 +505,7 @@ void main() { test('Full', () async { http.response = mock.oneEmpty; final response = await client( - DeleteOne.build('articles', '1', 'author')..headers['foo'] = 'bar'); + Request.deleteOne('articles', '1', 'author')..headers['foo'] = 'bar'); expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); @@ -519,7 +521,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(DeleteOne.build('articles', '1', 'author')); + await client(Request.deleteOne('articles', '1', 'author')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -530,7 +532,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; expect( - () async => await client(DeleteOne.build('articles', '1', 'author')), + () async => + await client(Request.deleteOne('articles', '1', 'author')), throwsFormatException); }); }); @@ -538,8 +541,8 @@ void main() { group('Delete Many', () { test('Min', () async { http.response = mock.many; - final response = await client( - DeleteMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + final response = await client(Request.deleteMany( + 'articles', '1', 'tags', [Identifier('tags', '1')])); expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -557,7 +560,7 @@ void main() { test('Full', () async { http.response = mock.many; final response = await client( - DeleteMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) + Request.deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]) ..headers['foo'] = 'bar'); expect(response.relationship, isA()); expect(http.request.method, 'delete'); @@ -577,7 +580,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(DeleteMany.build( + await client(Request.deleteMany( 'articles', '1', 'tags', [Identifier('tags', '1')])); fail('Exception expected'); } on RequestFailure catch (e) { @@ -589,7 +592,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(DeleteMany.build( + () async => await client(Request.deleteMany( 'articles', '1', 'tags', [Identifier('tags', '1')])), throwsFormatException); }); @@ -598,7 +601,7 @@ void main() { group('Replace Many', () { test('Min', () async { http.response = mock.many; - final response = await client(ReplaceMany.build( + final response = await client(Request.replaceMany( 'articles', '1', 'tags', [Identifier('tags', '1')])); expect(response.relationship, isA()); expect(http.request.method, 'patch'); @@ -616,9 +619,9 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client( - ReplaceMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) - ..headers['foo'] = 'bar'); + final response = await client(Request.replaceMany( + 'articles', '1', 'tags', [Identifier('tags', '1')]) + ..headers['foo'] = 'bar'); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -637,7 +640,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(ReplaceMany.build( + await client(Request.replaceMany( 'articles', '1', 'tags', [Identifier('tags', '1')])); fail('Exception expected'); } on RequestFailure catch (e) { @@ -649,7 +652,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(ReplaceMany.build( + () async => await client(Request.replaceMany( 'articles', '1', 'tags', [Identifier('tags', '1')])), throwsFormatException); }); @@ -659,7 +662,7 @@ void main() { test('Min', () async { http.response = mock.many; final response = await client( - AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + Request.addMany('articles', '1', 'tags', [Identifier('tags', '1')])); expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -677,7 +680,7 @@ void main() { test('Full', () async { http.response = mock.many; final response = await client( - AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')]) + Request.addMany('articles', '1', 'tags', [Identifier('tags', '1')]) ..headers['foo'] = 'bar'); expect(response.relationship, isA()); expect(http.request.method, 'post'); @@ -697,8 +700,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client( - AddMany.build('articles', '1', 'tags', [Identifier('tags', '1')])); + await client(Request.addMany( + 'articles', '1', 'tags', [Identifier('tags', '1')])); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -709,7 +712,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(AddMany.build( + () async => await client(Request.addMany( 'articles', '1', 'tags', [Identifier('tags', '1')])), throwsFormatException); }); diff --git a/test/document/outbound_document_test.dart b/test/document/outbound_document_test.dart index 2c562afa..1809bc83 100644 --- a/test/document/outbound_document_test.dart +++ b/test/document/outbound_document_test.dart @@ -18,11 +18,11 @@ void main() { group('Error', () { test('minimal', () { - expect(toObject(OutboundDocument.error([])), {'errors': []}); + expect(toObject(OutboundErrorDocument([])), {'errors': []}); }); test('full', () { expect( - toObject(OutboundDocument.error([ErrorObject(detail: 'Some issue')]) + toObject(OutboundErrorDocument([ErrorObject(detail: 'Some issue')]) ..meta['foo'] = 42), { 'errors': [ @@ -39,13 +39,13 @@ void main() { final author = Resource('people', '2'); group('Resource', () { test('minimal', () { - expect(toObject(OutboundDocument.resource(book)), { + expect(toObject(OutboundDataDocument.resource(book)), { 'data': {'type': 'books', 'id': '1'} }); }); test('full', () { expect( - toObject(OutboundDocument.resource(book) + toObject(OutboundDataDocument.resource(book) ..meta['foo'] = 42 ..included.add(author) ..links['self'] = Link(Uri.parse('/books/1'))), @@ -62,11 +62,11 @@ void main() { group('Collection', () { test('minimal', () { - expect(toObject(OutboundDocument.collection([])), {'data': []}); + expect(toObject(OutboundDataDocument.collection([])), {'data': []}); }); test('full', () { expect( - toObject(OutboundDocument.collection([book]) + toObject(OutboundDataDocument.collection([book]) ..meta['foo'] = 42 ..included.add(author) ..links['self'] = Link(Uri.parse('/books/1'))), @@ -85,11 +85,11 @@ void main() { group('One', () { test('minimal', () { - expect(toObject(OutboundDocument.one(One.empty())), {'data': null}); + expect(toObject(OutboundDataDocument.one(One.empty())), {'data': null}); }); test('full', () { expect( - toObject(OutboundDocument.one(One(book.identifier) + toObject(OutboundDataDocument.one(One(book.identifier) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -106,11 +106,11 @@ void main() { group('Many', () { test('minimal', () { - expect(toObject(OutboundDocument.many(Many([]))), {'data': []}); + expect(toObject(OutboundDataDocument.many(Many([]))), {'data': []}); }); test('full', () { expect( - toObject(OutboundDocument.many(Many([book.identifier]) + toObject(OutboundDataDocument.many(Many([book.identifier]) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart index d2a142d7..06e4371d 100644 --- a/test/e2e/e2e_test.dart +++ b/test/e2e/e2e_test.dart @@ -38,8 +38,8 @@ void main() { group('Basic Client-Server interaction over HTTP', () { test('Create new resource, read collection', () async { - final r0 = await client(CreateNewResource.build('books', - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.createNew('books', attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 201); expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); expect(r0.resource.type, 'books'); @@ -47,7 +47,7 @@ void main() { expect(r0.resource.attributes['title'], 'Hello world'); expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); - final r1 = await client(FetchCollection('books')); + final r1 = await client(Request.fetchCollection('books')); expect(r1.http.statusCode, 200); expect(r1.collection.first.type, 'books'); expect(r1.collection.first.attributes['title'], 'Hello world'); @@ -55,21 +55,21 @@ void main() { test('Create new resource sets Location header', () async { // TODO: Why does this not work in browsers? - final r0 = await client(CreateNewResource.build('books', - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.createNew('books', attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 201); expect(r0.http.headers['location'], '/books/${r0.resource.id}'); }, testOn: 'vm'); test('Create resource with id, read resource by id', () async { final id = Uuid().v4(); - final r0 = await client(CreateResource.build('books', id, - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.create('books', id, attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 204); expect(r0.resource, isNull); expect(r0.http.headers['location'], isNull); - final r1 = await client(FetchResource.build('books', id)); + final r1 = await client(Request.fetchResource('books', id)); expect(r1.http.statusCode, 200); expect(r1.http.headers['content-type'], 'application/vnd.api+json'); expect(r1.resource.type, 'books'); diff --git a/test/integration/integration_test.dart b/test/integration/integration_test.dart index 9f3621b6..64eb6d93 100644 --- a/test/integration/integration_test.dart +++ b/test/integration/integration_test.dart @@ -33,8 +33,8 @@ void main() { group('Basic Client-Server interaction over HTTP', () { test('Create new resource, read collection', () async { - final r0 = await client(CreateNewResource.build('books', - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.createNew('books', attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 201); expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); expect(r0.resource.type, 'books'); @@ -42,7 +42,7 @@ void main() { expect(r0.resource.attributes['title'], 'Hello world'); expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); - final r1 = await client(FetchCollection('books')); + final r1 = await client(Request.fetchCollection('books')); expect(r1.http.statusCode, 200); expect(r1.collection.first.type, 'books'); expect(r1.collection.first.attributes['title'], 'Hello world'); @@ -50,21 +50,21 @@ void main() { test('Create new resource sets Location header', () async { // TODO: Why does this not work in browsers? - final r0 = await client(CreateNewResource.build('books', - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.createNew('books', attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 201); expect(r0.http.headers['location'], '/books/${r0.resource.id}'); }, testOn: 'vm'); test('Create resource with id, read resource by id', () async { final id = Uuid().v4(); - final r0 = await client(CreateResource.build('books', id, - attributes: {'title': 'Hello world'})); + final r0 = await client( + Request.create('books', id, attributes: {'title': 'Hello world'})); expect(r0.http.statusCode, 204); expect(r0.resource, isNull); expect(r0.http.headers['location'], isNull); - final r1 = await client(FetchResource.build('books', id)); + final r1 = await client(Request.fetchResource('books', id)); expect(r1.http.statusCode, 200); expect(r1.http.headers['content-type'], 'application/vnd.api+json'); expect(r1.resource.type, 'books'); diff --git a/tmp/e2e/browser_test.dart b/tmp/e2e/browser_test.dart deleted file mode 100644 index ddc9a50b..00000000 --- a/tmp/e2e/browser_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:http/http.dart'; -import 'package:json_api/json_api.dart'; -import 'package:json_api_common/routing.dart'; -import 'package:test/test.dart'; - -void main() async { - final port = 8081; - final host = 'localhost'; - final routing = Routing(Uri(host: host, port: port, scheme: 'http')); - Client httpClient; - - setUp(() { - httpClient = Client(); - }); - - tearDown(() { - httpClient.close(); - }); - - test('can create and fetch', () async { - final channel = spawnHybridUri('hybrid_server.dart', message: port); - await channel.stream.first; - - final client = JsonApiClient(DartHttp(httpClient), routing); - - await client - .createResource('writers', '1', attributes: {'name': 'Martin Fowler'}); - await client - .createResource('books', '2', attributes: {'title': 'Refactoring'}); - await client.updateResource('books', '2', many: {'authors': []}); - await client.addMany('books', '2', 'authors', [Identifier('writers', '1')]); - - final response = - await client.fetchResource('books', '2', include: ['authors']); - - expect(response.resource.attributes['title'], 'Refactoring'); - expect(response.included.first.attributes['name'], 'Martin Fowler'); - }, testOn: 'browser'); -} diff --git a/tmp/e2e/client_server_interaction_test.dart b/tmp/e2e/client_server_interaction_test.dart deleted file mode 100644 index b64e2a2a..00000000 --- a/tmp/e2e/client_server_interaction_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/json_api.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/dart_http.dart'; -import 'package:json_api_common/routing.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:test/test.dart'; - -void main() { - group('Client-Server interaction over HTTP', () { - final port = 8088; - final host = 'localhost'; - final routing = Routing(Uri(host: host, port: port, scheme: 'http')); - final repo = InMemoryRepository({'writers': {}, 'books': {}}); - final jsonApiServer = JsonApiServer(RepositoryController(repo)); - final serverHandler = DartServer(jsonApiServer); - Client httpClient; - JsonApiClient client; - HttpServer server; - - setUp(() async { - server = await HttpServer.bind(host, port); - httpClient = Client(); - client = JsonApiClient(DartHttp(httpClient), routing); - unawaited(server.forEach(serverHandler)); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - test('Happy Path', () async { - await client.createResource('writers', '1', - attributes: {'name': 'Martin Fowler'}); - await client - .createResource('books', '2', attributes: {'title': 'Refactoring'}); - await client.updateResource('books', '2', many: {'authors': []}); - await client - .addMany('books', '2', 'authors', [Identifier('writers', '1')]); - - final response = - await client.fetchResource('books', '2', include: ['authors']); - - expect(response.resource.attributes['title'], 'Refactoring'); - expect(response.included.first.attributes['name'], 'Martin Fowler'); - }); - }, testOn: 'vm'); -} diff --git a/tmp/e2e/hybrid_server.dart b/tmp/e2e/hybrid_server.dart deleted file mode 100644 index 6b36df36..00000000 --- a/tmp/e2e/hybrid_server.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/server.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:stream_channel/stream_channel.dart'; - -void hybridMain(StreamChannel channel, Object port) async { - final repo = InMemoryRepository({'writers': {}, 'books': {}}); - final jsonApiServer = JsonApiServer(RepositoryController(repo)); - final serverHandler = DartServer(jsonApiServer); - final server = await HttpServer.bind('localhost', port); - unawaited(server.forEach(serverHandler)); - channel.sink.add('ready'); -} From 1014ee737c2521df420c0ca708ad8cb1c78a353b Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 15 Nov 2020 22:22:33 -0800 Subject: [PATCH 79/99] Cleaup --- README.md | 132 +----------------- example/README.md | 1 + lib/client.dart | 2 +- lib/document.dart | 6 +- lib/src/client/json_api_client.dart | 9 +- ...ent_request.dart => json_api_request.dart} | 2 +- lib/src/client/request.dart | 9 +- lib/src/client/uri_provider.dart | 3 - lib/src/document/inbound_document.dart | 4 +- lib/src/document/{relationship => }/many.dart | 2 +- lib/src/document/{relationship => }/one.dart | 2 +- .../{relationship => }/relationship.dart | 0 lib/src/document/resource_properties.dart | 2 +- pubspec.yaml | 2 +- 14 files changed, 26 insertions(+), 150 deletions(-) create mode 100644 example/README.md rename lib/src/client/{client_request.dart => json_api_request.dart} (92%) delete mode 100644 lib/src/client/uri_provider.dart rename lib/src/document/{relationship => }/many.dart (85%) rename lib/src/document/{relationship => }/one.dart (86%) rename lib/src/document/{relationship => }/relationship.dart (100%) diff --git a/README.md b/README.md index 521ae758..5ad3225a 100644 --- a/README.md +++ b/README.md @@ -3,97 +3,14 @@ [JSON:API] is a specification for building APIs in JSON. This package consists of several libraries: -- The [Client library] is a JSON:API Client for Flutter, Web and Server-side -- The [Server library] is a framework-agnostic JSON:API server implementation -- The [Document library] is the core of this package. It describes the JSON:API document structure -- The [HTTP library] is a thin abstraction of HTTP requests and responses -- The [Query library] builds and parses the query parameters (page, sorting, filtering, etc) -- The [Routing library] builds and matches URIs for resources, collections, and relationships +- The [Client library] is a JSON:API Client for Flutter, browsers and vm. +- The [Server library] is a framework-agnostic JSON:API server implementation. +- The [Document library] is the core of this package. It describes the JSON:API document structure. +- The [HTTP library] is a thin abstraction of HTTP requests and responses. +- The [Query library] builds and parses the query parameters (page, sorting, filtering, etc). +- The [Routing library] builds and matches URIs for resources, collections, and relationships. -## Client -[JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: -- fetching resources and collections (both primary and related) -- creating resources -- deleting resources -- updating resource attributes and relationships -- direct modification of relationships (both to-one and to-many) -- [async processing](https://jsonapi.org/recommendations/#asynchronous-processing) - -The client returns a [Response] which contains the HTTP status code, headers and the JSON:API [Document]. - -Sometimes the request URIs can be inferred from the context. -For such cases you may use the [RoutingClient] which is a wrapper over the [JsonApiClient] capable of inferring the URIs. -The [RoutingClient] requires an instance of [RouteFactory] to be provided. - -[JsonApiClient] itself does not make actual HTTP calls. -Instead, it calls the underlying [HttpHandler] which acts as an HTTP client (must be passed to the constructor). -The library comes with an implementation of [HttpHandler] called [DartHttp] which uses the Dart's native http client. - -## Server -This is a framework-agnostic library for implementing a JSON:API server. -It may be used on its own (a fully functional server implementation is included) or as a set of independent components. - -### Request lifecycle -#### HTTP request -The server receives an incoming [HttpRequest] containing the HTTP headers and the body represented as a String. -When this request is received, your server may decide to check for authentication or other non-JSON:API concerns -to prepare for the request processing, or it may decide to fail out with an error response. - -#### JSON:API request -The [RequestConverter] is then used to convert the HTTP request to a [JsonApiRequest]. -[JsonApiRequest] abstracts the JSON:API specific details, -such as the request target (a collection, a resource or a relationship) and the decoded body (e.g. [Resource] or [Identifier]). -At this point it is possible to determine whether the request is a valid JSON:API request and to read the decoded payload. -You may perform some application-specific logic, e.g. check for authentication. -Each implementation of [JsonApiRequest] has the `handleWith()` method to dispatch a call to the right method of the [Controller]. - -#### Controller -The [Controller] consolidates all methods to process JSON:API requests. -Every controller method must return an instance of [JsonApiResponse] (or another type, the controller is generic). -This library comes with a particular implementation of the [Controller] called [RepositoryController]. -The [RepositoryController] takes care of all JSON:API specific logic (e.g. validation, filtering, resource -inclusion) and translates the JSON:API requests to calls to a resource [Repository]. - -#### Repository (optional) -The [Repository] is an interface separating the data storage concerns from the specifics of the API. - -#### JSON:API response -When an instance of [JsonApiResponse] is returned from the controller, the [ResponseConverter] -converts it to an [HttpResponse]. -The converter takes care of JSON:API transport-layer concerns. -In particular, it: -- generates a proper [Document], including the HATEOAS links or meta-data -- encodes the document to JSON string -- sets the response headers - -#### HTTP response -The generated [HttpResponse] is sent to the underlying HTTP system. -This is the final step. - -## HTTP -This library is used by both the Client and the Server to abstract out the HTTP protocol specifics. -The [HttpHandler] interface turns an [HttpRequest] to an [HttpResponse]. -The Client consumes an implementation of [HttpHandler] as a low-level HTTP client. -The Server is itself an implementation of [HttpHandler]. - -## Query -This is a set of classes for building avd parsing some URL query parameters defined in the standard. -- [Fields] for [Sparse fieldsets] -- [Include] for [Inclusion of Related Resources] -- [Page] for [Collection Pagination] -- [Sort] for [Collection Sorting] - -## Routing -Defines the logic for constructing and matching URLs for resources, collections and relationships. -The URL construction is used by both the Client (See [RoutingClient] for instance) and the Server libraries. -The [StandardRouting] implements the [Recommended URL design]. - -[JSON:API]: http://jsonapi.org -[Sparse fieldsets]: https://jsonapi.org/format/#fetching-sparse-fieldsets -[Inclusion of Related Resources]: https://jsonapi.org/format/#fetching-includes -[Collection Pagination]: https://jsonapi.org/format/#fetching-pagination -[Collection Sorting]: https://jsonapi.org/format/#fetching-sorting -[Recommended URL design]: https://jsonapi.org/recommendations/#urls +[JSON:API]: https://jsonapi.org [Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html [Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html @@ -101,38 +18,3 @@ The [StandardRouting] implements the [Recommended URL design]. [Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html [Routing library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html [HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html - - -[Resource]: https://pub.dev/documentation/json_api/latest/document/Resource-class.html -[Identifier]: https://pub.dev/documentation/json_api/latest/document/Identifier-class.html -[Document]: https://pub.dev/documentation/json_api/latest/document/Document-class.html -[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html - - -[Response]: https://pub.dev/documentation/json_api/latest/client/Response-class.html -[RoutingClient]: https://pub.dev/documentation/json_api/latest/client/RoutingClient-class.html -[DartHttp]: https://pub.dev/documentation/json_api/latest/client/DartHttp-class.html - - -[RequestConverter]: https://pub.dev/documentation/json_api/latest/server/RequestConverter-class.html -[JsonApiResponse]: https://pub.dev/documentation/json_api/latest/server/JsonApiResponse-class.html -[ResponseConverter]: https://pub.dev/documentation/json_api/latest/server/ResponseConverter-class.html -[JsonApiRequest]: https://pub.dev/documentation/json_api/latest/server/JsonApiRequest-class.html -[Controller]: https://pub.dev/documentation/json_api/latest/server/Controller-class.html -[Repository]: https://pub.dev/documentation/json_api/latest/server/Repository-class.html -[RepositoryController]: https://pub.dev/documentation/json_api/latest/server/RepositoryController-class.html - - -[HttpHandler]: https://pub.dev/documentation/json_api/latest/http/HttpHandler-class.html -[HttpRequest]: https://pub.dev/documentation/json_api/latest/http/HttpRequest-class.html -[HttpResponse]: https://pub.dev/documentation/json_api/latest/http/HttpResponse-class.html - - -[Fields]: https://pub.dev/documentation/json_api/latest/query/Fields-class.html -[Include]: https://pub.dev/documentation/json_api/latest/query/Include-class.html -[Page]: https://pub.dev/documentation/json_api/latest/query/Page-class.html -[Sort]: https://pub.dev/documentation/json_api/latest/query/Sort-class.html - - -[RouteFactory]: https://pub.dev/documentation/json_api/latest/routing/RouteFactory-class.html -[StandardRouting]: https://pub.dev/documentation/json_api/latest/routing/StandardRouting-class.html \ No newline at end of file diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..c845d1a5 --- /dev/null +++ b/example/README.md @@ -0,0 +1 @@ +Work in progress. See the tests meanwhile. \ No newline at end of file diff --git a/lib/client.dart b/lib/client.dart index b5d25fbe..7523387f 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,6 +1,6 @@ library json_api; -export 'package:json_api/src/client/client_request.dart'; +export 'package:json_api/src/client/json_api_request.dart'; export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/request.dart'; diff --git a/lib/document.dart b/lib/document.dart index cd880752..fc7d72aa 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -7,7 +7,7 @@ export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/new_resource.dart'; export 'package:json_api/src/document/outbound_document.dart'; -export 'package:json_api/src/document/relationship/many.dart'; -export 'package:json_api/src/document/relationship/one.dart'; -export 'package:json_api/src/document/relationship/relationship.dart'; +export 'package:json_api/src/document/many.dart'; +export 'package:json_api/src/document/one.dart'; +export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index e874dea9..327c0e30 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/client_request.dart'; +import 'package:json_api/src/client/json_api_request.dart'; import 'package:json_api/src/client/request_failure.dart'; import 'package:json_api/src/http/media_type.dart'; @@ -16,9 +16,8 @@ class JsonApiClient { /// Sends the [request] to the server. /// Returns the response when the server responds with a JSON:API document. - /// Throws a [RequestFailure] if the server responds with a JSON:API error. - /// Throws a [ServerError] if the server responds with a non-JSON:API error. - Future call(ClientRequest request) async { + /// Throws a [RequestFailure] if the server responds with an error. + Future call(JsonApiRequest request) async { final response = await _http.call(_toHttp(request)); if (!response.isSuccessful && !response.isPending) { throw RequestFailure(response, @@ -29,7 +28,7 @@ class JsonApiClient { return request.response(response); } - HttpRequest _toHttp(ClientRequest request) { + HttpRequest _toHttp(JsonApiRequest request) { final headers = {'accept': MediaType.jsonApi}; var body = ''; if (request.document != null) { diff --git a/lib/src/client/client_request.dart b/lib/src/client/json_api_request.dart similarity index 92% rename from lib/src/client/client_request.dart rename to lib/src/client/json_api_request.dart index 1c14c491..b5c8b7f5 100644 --- a/lib/src/client/client_request.dart +++ b/lib/src/client/json_api_request.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -abstract class ClientRequest { +abstract class JsonApiRequest { /// HTTP method String get method; diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 9d2e4a50..56becdb1 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,15 +1,14 @@ -import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/client_request.dart'; +import 'package:json_api/src/client/json_api_request.dart'; import 'package:json_api/src/client/response/collection_response.dart'; import 'package:json_api/src/client/response/new_resource_response.dart'; import 'package:json_api/src/client/response/relationship_response.dart'; import 'package:json_api/src/client/response/resource_response.dart'; -class Request implements ClientRequest { +class Request implements JsonApiRequest { Request(this.method, this.target, this.convert, [this.document]); /// Adds identifiers to a to-many relationship @@ -142,7 +141,7 @@ class Request implements ClientRequest { @override final OutboundDocument document; - final ResponseConverter convert; + final T Function(HttpResponse response) convert; @override final headers = {}; @@ -186,5 +185,3 @@ class Request implements ClientRequest { @override T response(HttpResponse response) => convert(response); } - -typedef ResponseConverter = T Function(HttpResponse response); diff --git a/lib/src/client/uri_provider.dart b/lib/src/client/uri_provider.dart deleted file mode 100644 index 78599b4f..00000000 --- a/lib/src/client/uri_provider.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:json_api/routing.dart'; - -typedef UriProvider = Uri Function(UriFactory design); diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index fc509a61..e363f3fb 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -2,8 +2,8 @@ import 'dart:convert'; import 'package:json_api/document.dart'; import 'package:json_api/src/document/error_source.dart'; -import 'package:json_api/src/document/relationship/many.dart'; -import 'package:json_api/src/document/relationship/one.dart'; +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/extensions.dart'; import 'package:json_api/src/nullable.dart'; diff --git a/lib/src/document/relationship/many.dart b/lib/src/document/many.dart similarity index 85% rename from lib/src/document/relationship/many.dart rename to lib/src/document/many.dart index dede68b5..5e94be26 100644 --- a/lib/src/document/relationship/many.dart +++ b/lib/src/document/many.dart @@ -1,5 +1,5 @@ import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/relationship/relationship.dart'; +import 'package:json_api/src/document/relationship.dart'; class Many extends Relationship { Many(Iterable identifiers) { diff --git a/lib/src/document/relationship/one.dart b/lib/src/document/one.dart similarity index 86% rename from lib/src/document/relationship/one.dart rename to lib/src/document/one.dart index 7d46fc5b..bde0e295 100644 --- a/lib/src/document/relationship/one.dart +++ b/lib/src/document/one.dart @@ -1,5 +1,5 @@ import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/relationship/relationship.dart'; +import 'package:json_api/src/document/relationship.dart'; class One extends Relationship { One(Identifier /*!*/ identifier) : identifier = identifier; diff --git a/lib/src/document/relationship/relationship.dart b/lib/src/document/relationship.dart similarity index 100% rename from lib/src/document/relationship/relationship.dart rename to lib/src/document/relationship.dart diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart index f3df6da9..e9b4bae4 100644 --- a/lib/src/document/resource_properties.dart +++ b/lib/src/document/resource_properties.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/document/relationship/relationship.dart'; +import 'package:json_api/src/document/relationship.dart'; mixin ResourceProperties { /// Resource meta data. diff --git a/pubspec.yaml b/pubspec.yaml index a8cb3eea..bc806aca 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-dev.2 +version: 5.0.0-dev.3 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: From 34b61823aa0f798f9fa328e9085fa05ce165a77b Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 21 Nov 2020 12:26:13 -0800 Subject: [PATCH 80/99] WIP: before moving methods to client --- .github/workflows/dart.yml | 2 +- example/demo/demo_server.dart | 54 ----- example/demo/printing_logger.dart | 15 -- example/demo/sqlite_controller.dart | 104 --------- example/server.dart | 19 ++ lib/client.dart | 6 +- lib/document.dart | 5 +- lib/http.dart | 2 + lib/routing.dart | 1 - lib/server.dart | 2 +- lib/src/{server => _demo}/cors_handler.dart | 11 +- .../src/_demo/dart_io_http_handler.dart | 4 +- lib/src/_demo/demo_server.dart | 49 ++++ lib/src/{server => _demo}/entity.dart | 0 lib/src/_demo/in_memory_repo.dart | 72 ++++++ lib/src/_demo/repo.dart | 63 +++++ lib/src/_demo/repository_controller.dart | 217 ++++++++++++++++++ .../{response => }/collection_response.dart | 1 + lib/src/client/dart_http.dart | 24 +- lib/src/client/json_api_client.dart | 17 +- lib/src/client/json_api_request.dart | 6 +- .../{response => }/new_resource_response.dart | 8 +- .../{response => }/relationship_response.dart | 1 + lib/src/client/request.dart | 82 ++++--- lib/src/client/request_failure.dart | 4 + .../{response => }/resource_response.dart | 4 + lib/src/client/response.dart | 12 + lib/src/document/identifier.dart | 6 + lib/src/document/identity.dart | 8 +- lib/src/document/inbound_document.dart | 31 ++- lib/src/document/one.dart | 4 + lib/src/document/resource.dart | 25 +- lib/src/document/resource_properties.dart | 3 + lib/src/extensions.dart | 23 -- lib/src/http/callback_http_logger.dart | 24 ++ lib/src/http/last_value_logger.dart | 17 -- .../{reference.dart => _reference.dart} | 0 lib/src/routing/target.dart | 3 +- lib/src/server/controller.dart | 21 ++ lib/src/server/model.dart | 11 - lib/src/server/relationship_node.dart | 27 +++ lib/src/server/response.dart | 26 +++ lib/src/server/router.dart | 25 +- lib/src/test/mock_handler.dart | 12 + {test/document => lib/src/test}/payload.dart | 0 {test/client => lib/src/test}/response.dart | 0 pubspec.yaml | 1 - test/e2e/browser_test.dart | 46 ++++ test/e2e/dart_http_handler.dart | 38 --- test/e2e/e2e_test.dart | 80 ------- test/e2e/hybrid_server.dart | 10 +- test/e2e/usecase_test.dart | 144 ++++++++++++ test/integration/integration_test.dart | 75 ------ test/{ => unit}/client/client_test.dart | 93 ++------ .../document/error_object_test.dart | 0 .../document/inbound_document_test.dart | 3 +- test/{ => unit}/document/link_test.dart | 0 .../document/new_resource_test.dart | 0 .../document/outbound_document_test.dart | 4 +- .../document/relationship_test.dart | 0 test/{ => unit}/document/resource_test.dart | 0 test/{ => unit}/http/headers_test.dart | 0 .../http/logging_http_handler_test.dart | 10 +- test/{ => unit}/http/request_test.dart | 0 test/{ => unit}/query/fields_test.dart | 0 test/{ => unit}/query/filter_test.dart | 0 test/{ => unit}/query/include_test.dart | 1 + test/{ => unit}/query/page_test.dart | 0 test/{ => unit}/query/sort_test.dart | 0 test/{ => unit}/routing/url_test.dart | 0 70 files changed, 965 insertions(+), 591 deletions(-) delete mode 100644 example/demo/demo_server.dart delete mode 100644 example/demo/printing_logger.dart delete mode 100644 example/demo/sqlite_controller.dart create mode 100644 example/server.dart rename lib/src/{server => _demo}/cors_handler.dart (62%) rename example/demo/dart_http_handler.dart => lib/src/_demo/dart_io_http_handler.dart (94%) create mode 100644 lib/src/_demo/demo_server.dart rename lib/src/{server => _demo}/entity.dart (100%) create mode 100644 lib/src/_demo/in_memory_repo.dart create mode 100644 lib/src/_demo/repo.dart create mode 100644 lib/src/_demo/repository_controller.dart rename lib/src/client/{response => }/collection_response.dart (96%) rename lib/src/client/{response => }/new_resource_response.dart (82%) rename lib/src/client/{response => }/relationship_response.dart (96%) rename lib/src/client/{response => }/resource_response.dart (95%) create mode 100644 lib/src/client/response.dart delete mode 100644 lib/src/extensions.dart create mode 100644 lib/src/http/callback_http_logger.dart delete mode 100644 lib/src/http/last_value_logger.dart rename lib/src/routing/{reference.dart => _reference.dart} (100%) delete mode 100644 lib/src/server/model.dart create mode 100644 lib/src/server/relationship_node.dart create mode 100644 lib/src/server/response.dart create mode 100644 lib/src/test/mock_handler.dart rename {test/document => lib/src/test}/payload.dart (100%) rename {test/client => lib/src/test}/response.dart (100%) create mode 100644 test/e2e/browser_test.dart delete mode 100644 test/e2e/dart_http_handler.dart delete mode 100644 test/e2e/e2e_test.dart create mode 100644 test/e2e/usecase_test.dart delete mode 100644 test/integration/integration_test.dart rename test/{ => unit}/client/client_test.dart (90%) rename test/{ => unit}/document/error_object_test.dart (100%) rename test/{ => unit}/document/inbound_document_test.dart (99%) rename test/{ => unit}/document/link_test.dart (100%) rename test/{ => unit}/document/new_resource_test.dart (100%) rename test/{ => unit}/document/outbound_document_test.dart (96%) rename test/{ => unit}/document/relationship_test.dart (100%) rename test/{ => unit}/document/resource_test.dart (100%) rename test/{ => unit}/http/headers_test.dart (100%) rename test/{ => unit}/http/logging_http_handler_test.dart (64%) rename test/{ => unit}/http/request_test.dart (100%) rename test/{ => unit}/query/fields_test.dart (100%) rename test/{ => unit}/query/filter_test.dart (100%) rename test/{ => unit}/query/include_test.dart (99%) rename test/{ => unit}/query/page_test.dart (100%) rename test/{ => unit}/query/sort_test.dart (100%) rename test/{ => unit}/routing/url_test.dart (100%) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 25c7b75b..10b96471 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -23,4 +23,4 @@ jobs: - name: Analyzer run: dart analyze --fatal-infos --fatal-warnings - name: Tests - run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 \ No newline at end of file + run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* \ No newline at end of file diff --git a/example/demo/demo_server.dart b/example/demo/demo_server.dart deleted file mode 100644 index 97afcf3a..00000000 --- a/example/demo/demo_server.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:sqlite3/sqlite3.dart'; - -import 'dart_http_handler.dart'; -import 'printing_logger.dart'; -import 'sqlite_controller.dart'; - -class DemoServer { - DemoServer(this._initSql, {String address, int port = 8080}) - : _address = address ?? 'localhost', - _port = port; - - final String _address; - final int _port; - final String _initSql; - - Database _database; - HttpServer _server; - - bool get isStarted => _database != null || _server != null; - - String get uri => 'http://${_address}:$_port'; - - Future start() async { - if (isStarted) throw StateError('Server already started'); - try { - _database = sqlite3.openInMemory(); - _database.execute(_initSql); - _server = await HttpServer.bind(_address, _port); - final controller = SqliteController(_database); - final jsonApiServer = - JsonApiHandler(controller, exposeInternalErrors: true); - final _handler = - CorsHandler(LoggingHttpHandler(jsonApiServer, PrintingLogger())); - unawaited(_server.forEach(DartHttpHandler(_handler))); - } on Exception { - await stop(); - rethrow; - } - } - - Future stop({bool force = false}) async { - if (_database != null) { - _database.dispose(); - } - if (_server != null) { - await _server.close(force: force); - } - } -} diff --git a/example/demo/printing_logger.dart b/example/demo/printing_logger.dart deleted file mode 100644 index 39485891..00000000 --- a/example/demo/printing_logger.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_api/http.dart'; - -class PrintingLogger implements HttpLogger { - const PrintingLogger(); - - @override - void onRequest(HttpRequest request) { - // print('Rq: ${request.method} ${request.uri}\n${request.headers}'); - } - - @override - void onResponse(HttpResponse response) { - // print('Rs: ${response.statusCode}\n${response.headers}'); - } -} diff --git a/example/demo/sqlite_controller.dart b/example/demo/sqlite_controller.dart deleted file mode 100644 index d34cd7e7..00000000 --- a/example/demo/sqlite_controller.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/http/media_type.dart'; -import 'package:json_api/src/server/model.dart'; -import 'package:sqlite3/sqlite3.dart'; -import 'package:uuid/uuid.dart'; - -class SqliteController implements JsonApiController> { - SqliteController(this.db); - - static SqliteController inMemory(String init) { - final db = sqlite3.openInMemory(); - db.execute(init); - return SqliteController(db); - } - - final Database db; - - final urlDesign = RecommendedUrlDesign.pathOnly; - - @override - Future fetchCollection( - HttpRequest request, CollectionTarget target) async { - final collection = db - .select('SELECT * FROM ${_sanitize(target.type)}') - .map(_resourceFromRow(target.type)); - final doc = OutboundDataDocument.collection(collection) - ..links['self'] = Link(target.map(urlDesign)); - return HttpResponse(200, body: jsonEncode(doc), headers: { - 'content-type': MediaType.jsonApi, - }); - } - - @override - Future fetchResource( - HttpRequest request, ResourceTarget target) async { - final resource = _fetchResource(target.type, target.id); - final self = Link(target.map(urlDesign)); - final doc = OutboundDataDocument.resource(resource)..links['self'] = self; - return HttpResponse(200, - body: jsonEncode(doc), headers: {'content-type': MediaType.jsonApi}); - } - - @override - Future createResource( - HttpRequest request, CollectionTarget target) async { - final doc = InboundDocument(jsonDecode(request.body)); - final res = doc.newResource(); - final model = Model(res.type)..attributes.addAll(res.attributes); - final id = res.id ?? Uuid().v4(); - if (res.id == null) { - _createResource(target.type, id, model); - final resource = _fetchResource(target.type, id); - - final self = - Link(ResourceTarget(resource.type, resource.id).map(urlDesign)); - resource.links['self'] = self; - final doc = OutboundDataDocument.resource(resource)..links['self'] = self; - return HttpResponse(201, body: jsonEncode(doc), headers: { - 'content-type': MediaType.jsonApi, - 'location': self.uri.toString() - }); - } - _createResource(target.type, res.id, model); - return HttpResponse(204); - } - - void _createResource(String type, String id, Model model) { - final columns = ['id', ...model.attributes.keys].map(_sanitize); - final values = [id, ...model.attributes.values]; - final sql = ''' - INSERT INTO ${_sanitize(type)} - (${columns.join(', ')}) - VALUES (${values.map((_) => '?').join(', ')}) - '''; - final s = db.prepare(sql); - s.execute(values); - } - - Resource _fetchResource(String type, String id) { - final sql = 'SELECT * FROM ${_sanitize(type)} WHERE id = ?'; - final results = db.select(sql, [id]); - if (results.isEmpty) throw ResourceNotFound(type, id); - return _resourceFromRow(type)(results.first); - } - - Resource Function(Row row) _resourceFromRow(String type) => - (Row row) => Resource(type, row['id'].toString()) - ..attributes.addAll({ - for (var _ in row.keys.where((_) => _ != 'id')) _.toString(): row[_] - }); - - String _sanitize(String value) => value.replaceAll(_nonAlpha, ''); - - static final _nonAlpha = RegExp('[^a-z]'); -} - -class ResourceNotFound implements Exception { - ResourceNotFound(String type, String id); -} diff --git a/example/server.dart b/example/server.dart new file mode 100644 index 00000000..86115bf8 --- /dev/null +++ b/example/server.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:json_api/http.dart'; +import 'package:json_api/src/_demo/demo_server.dart'; +import 'package:json_api/src/_demo/in_memory_repo.dart'; + +Future main() async { + final demo = DemoServer(InMemoryRepo(['users', 'posts', 'comments']), + logger: CallbackHttpLogger(onRequest: (r) { + print('${r.method} ${r.uri}\n${r.headers}\n${r.body}\n\n'); + }, onResponse: (r) { + print('${r.statusCode}\n${r.headers}\n${r.body}\n\n'); + })); + await demo.start(); + ProcessSignal.sigint.watch().listen((event) async { + await demo.stop(); + exit(0); + }); +} diff --git a/lib/client.dart b/lib/client.dart index 7523387f..1cdd90e5 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -5,6 +5,6 @@ export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/request_failure.dart'; -export 'package:json_api/src/client/response/collection_response.dart'; -export 'package:json_api/src/client/response/relationship_response.dart'; -export 'package:json_api/src/client/response/resource_response.dart'; +export 'package:json_api/src/client/collection_response.dart'; +export 'package:json_api/src/client/relationship_response.dart'; +export 'package:json_api/src/client/resource_response.dart'; diff --git a/lib/document.dart b/lib/document.dart index fc7d72aa..02beab79 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -5,9 +5,10 @@ export 'package:json_api/src/document/identifier.dart'; export 'package:json_api/src/document/identity.dart'; export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/new_resource.dart'; -export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/many.dart'; +export 'package:json_api/src/document/new_resource.dart'; export 'package:json_api/src/document/one.dart'; +export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; +export 'package:json_api/src/document/resource_properties.dart'; diff --git a/lib/http.dart b/lib/http.dart index c2435423..7be2b9ba 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,9 +1,11 @@ /// This is a thin HTTP layer abstraction used by the client library http; +export 'package:json_api/src/http/callback_http_logger.dart'; export 'package:json_api/src/http/headers.dart'; export 'package:json_api/src/http/http_handler.dart'; export 'package:json_api/src/http/http_logger.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; export 'package:json_api/src/http/logging_http_handler.dart'; +export 'package:json_api/src/http/media_type.dart'; diff --git a/lib/routing.dart b/lib/routing.dart index d707e384..51803c72 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -3,7 +3,6 @@ library routing; export 'package:json_api/src/routing/recommended_url_design.dart'; -export 'package:json_api/src/routing/reference.dart'; export 'package:json_api/src/routing/target.dart'; export 'package:json_api/src/routing/target_matcher.dart'; export 'package:json_api/src/routing/uri_factory.dart'; diff --git a/lib/server.dart b/lib/server.dart index d0141648..559bcd75 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,3 +1,3 @@ export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/cors_handler.dart'; export 'package:json_api/src/server/json_api_handler.dart'; +export 'package:json_api/src/server/response.dart'; diff --git a/lib/src/server/cors_handler.dart b/lib/src/_demo/cors_handler.dart similarity index 62% rename from lib/src/server/cors_handler.dart rename to lib/src/_demo/cors_handler.dart index 3a61a490..ddc10748 100644 --- a/lib/src/server/cors_handler.dart +++ b/lib/src/_demo/cors_handler.dart @@ -11,16 +11,17 @@ class CorsHandler implements HttpHandler { Future call(HttpRequest request) async { if (request.method == 'options') { return HttpResponse(204, headers: { - 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Origin': request.headers['origin'] ?? origin, 'Access-Control-Allow-Methods': - request.headers['Access-Control-Request-Method'] ?? - 'POST, GET, OPTIONS, DELETE, PATCH', + // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? + request.headers['Access-Control-Request-Method'].toUpperCase(), 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? '*' + request.headers['Access-Control-Request-Headers'] ?? '*', }); } final response = await wrapped(request); - response.headers['Access-Control-Allow-Origin'] = origin; + response.headers['Access-Control-Allow-Origin'] = + request.headers['origin'] ?? origin; return response; } } diff --git a/example/demo/dart_http_handler.dart b/lib/src/_demo/dart_io_http_handler.dart similarity index 94% rename from example/demo/dart_http_handler.dart rename to lib/src/_demo/dart_io_http_handler.dart index 526437bf..a35d8237 100644 --- a/example/demo/dart_http_handler.dart +++ b/lib/src/_demo/dart_io_http_handler.dart @@ -3,8 +3,8 @@ import 'dart:io' as io; import 'package:json_api/http.dart'; -class DartHttpHandler { - DartHttpHandler(this._handler); +class DartIOHttpHandler { + DartIOHttpHandler(this._handler); final HttpHandler _handler; diff --git a/lib/src/_demo/demo_server.dart b/lib/src/_demo/demo_server.dart new file mode 100644 index 00000000..b1e3e57e --- /dev/null +++ b/lib/src/_demo/demo_server.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/_demo/cors_handler.dart'; +import 'package:json_api/src/_demo/dart_io_http_handler.dart'; +import 'package:json_api/src/_demo/repo.dart'; +import 'package:json_api/src/_demo/repository_controller.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:uuid/uuid.dart'; + +class DemoServer { + DemoServer(Repo repo, + {this.host = 'localhost', + this.port = 8080, + HttpLogger logger = const CallbackHttpLogger(), + String Function() idGenerator, + bool exposeInternalErrors = false}) + : _handler = LoggingHttpHandler( + CorsHandler(JsonApiHandler( + RepositoryController(repo, idGenerator ?? Uuid().v4), + exposeInternalErrors: exposeInternalErrors)), + logger); + + final String host; + final int port; + final HttpHandler _handler; + + HttpServer _server; + + Uri get uri => Uri(scheme: 'http', host: host, port: port); + + Future start() async { + if (_server != null) return; + try { + _server = await HttpServer.bind(host, port); + unawaited(_server.forEach(DartIOHttpHandler(_handler))); + } on Exception { + await stop(); + rethrow; + } + } + + Future stop({bool force = false}) async { + if (_server == null) return; + await _server.close(force: force); + _server = null; + } +} diff --git a/lib/src/server/entity.dart b/lib/src/_demo/entity.dart similarity index 100% rename from lib/src/server/entity.dart rename to lib/src/_demo/entity.dart diff --git a/lib/src/_demo/in_memory_repo.dart b/lib/src/_demo/in_memory_repo.dart new file mode 100644 index 00000000..34d3d021 --- /dev/null +++ b/lib/src/_demo/in_memory_repo.dart @@ -0,0 +1,72 @@ +import 'package:json_api/src/_demo/repo.dart'; + +class InMemoryRepo implements Repo { + InMemoryRepo(Iterable types) { + types.forEach((_) { + _storage[_] = {}; + }); + } + + final _storage = >{}; + + @override + Stream> fetchAll(String type) { + return Stream.fromIterable(_storage[type].entries) + .map((_) => Entity(_.key, _.value)); + } + + @override + Future fetch(String type, String id) async { + return _storage[type][id]; + } + + @override + Future persist(String type, String id, Model model) async { + _storage[type][id] = model; + } + + @override + Stream addMany( + String type, String id, String rel, Iterable refs) { + final model = _storage[type][id]; + model.addMany(rel, refs); + return Stream.fromIterable(model.many[rel]); + } + + @override + Future delete(String type, String id) async { + _storage[type].remove(id); + } + + @override + Future update(String type, String id, Model model) async { + _storage[type][id].setFrom(model); + } + + @override + Future replaceOne( + String type, String id, String relationship, String key) async { + _storage[type][id].one[relationship] = key; + } + + @override + Future deleteOne(String type, String id, String relationship) async { + _storage[type][id].one[relationship] = null; + } + + @override + Stream deleteMany( + String type, String id, String relationship, Iterable refs) { + _storage[type][id].many[relationship].removeAll(refs); + return Stream.fromIterable(_storage[type][id].many[relationship]); + } + + @override + Stream replaceMany( + String type, String id, String relationship, Iterable refs) { + _storage[type][id].many[relationship] + ..clear() + ..addAll(refs); + return Stream.fromIterable(_storage[type][id].many[relationship]); + } +} diff --git a/lib/src/_demo/repo.dart b/lib/src/_demo/repo.dart new file mode 100644 index 00000000..6a159af3 --- /dev/null +++ b/lib/src/_demo/repo.dart @@ -0,0 +1,63 @@ +abstract class Repo { + Stream> fetchAll(String type); + + Future fetch(String type, String id); + + Future persist(String type, String id, Model model); + + /// Add refs to a to-many relationship + Stream addMany( + String type, String id, String rel, Iterable refs); + + /// Delete the model + Future delete(String type, String id); + + /// Updates the model + Future update(String type, String id, Model model); + + Future replaceOne( + String type, String id, String relationship, String key); + + Future deleteOne(String type, String id, String relationship); + + /// Deletes refs from the to-many relationship. + /// Returns the new actual refs. + Stream deleteMany( + String type, String id, String relationship, Iterable refs); + + /// Replaces refs in the to-many relationship. + /// Returns the new actual refs. + Stream replaceMany( + String type, String id, String relationship, Iterable refs); +} + +class Entity { + const Entity(this.id, this.model); + + final String id; + + final M model; +} + +class Model { + final attributes = {}; + final one = {}; + final many = >{}; + + void addMany(String relationship, Iterable refs) { + many[relationship] ??= {}; + many[relationship].addAll(refs); + } + + void setFrom(Model other) { + other.attributes.forEach((key, value) { + attributes[key] = value; + }); + other.one.forEach((key, value) { + one[key] = value; + }); + other.many.forEach((key, value) { + many[key] = {...value}; + }); + } +} diff --git a/lib/src/_demo/repository_controller.dart b/lib/src/_demo/repository_controller.dart new file mode 100644 index 00000000..6b743606 --- /dev/null +++ b/lib/src/_demo/repository_controller.dart @@ -0,0 +1,217 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/_demo/repo.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/relationship_node.dart'; +import 'package:json_api/src/server/response.dart'; + +class RepositoryController implements JsonApiController> { + RepositoryController(this.repo, this.getId); + + final Repo repo; + + final IdGenerator getId; + + final urlDesign = RecommendedUrlDesign.pathOnly; + + @override + Future fetchCollection( + HttpRequest request, CollectionTarget target) async { + final resources = await _fetchAll(target.type).toList(); + final doc = OutboundDataDocument.collection(resources) + ..links['self'] = Link(target.map(urlDesign)); + final forest = RelationshipNode.forest(Include.fromUri(request.uri)); + for (final r in resources) { + await for (final r in _getAllRelated(r, forest)) { + doc.included.add(r); + } + } + return Response.ok(doc); + } + + @override + Future fetchResource( + HttpRequest request, ResourceTarget target) async { + final resource = await _fetchLinkedResource(target.type, target.id); + if (resource == null) return Response.notFound(); + final doc = OutboundDataDocument.resource(resource) + ..links['self'] = Link(target.map(urlDesign)); + final forest = RelationshipNode.forest(Include.fromUri(request.uri)); + await for (final r in _getAllRelated(resource, forest)) { + doc.included.add(r); + } + return Response.ok(doc); + } + + @override + Future createResource( + HttpRequest request, CollectionTarget target) async { + final res = _decode(request).newResource(); + final id = res.id ?? getId(); + await repo.persist(res.type, id, _toModel(res)); + if (res.id != null) { + return Response.noContent(); + } + final self = Link(ResourceTarget(target.type, id).map(urlDesign)); + final resource = await _fetchResource(target.type, id) + ..links['self'] = self; + return Response.created( + OutboundDataDocument.resource(resource)..links['self'] = self, + location: self.uri.toString()); + } + + @override + Future addMany( + HttpRequest request, RelationshipTarget target) async { + final many = _decode(request).dataAsRelationship(); + final refs = await repo + .addMany( + target.type, target.id, target.relationship, many.map((_) => _.key)) + .toList(); + return Response.ok( + OutboundDataDocument.many(Many(refs.map(Identifier.fromKey)))); + } + + @override + Future deleteResource( + HttpRequest request, ResourceTarget target) async { + await repo.delete(target.type, target.id); + return Response.noContent(); + } + + @override + Future updateResource( + HttpRequest request, ResourceTarget target) async { + await repo.update( + target.type, target.id, _toModel(_decode(request).resource())); + return Response.noContent(); + } + + @override + Future replaceRelationship( + HttpRequest request, RelationshipTarget target) async { + final rel = _decode(request).dataAsRelationship(); + if (rel is One) { + final id = rel.identifier; + if (id == null) { + await repo.deleteOne(target.type, target.id, target.relationship); + } else { + await repo.replaceOne( + target.type, target.id, target.relationship, id.key); + } + return Response.ok(OutboundDataDocument.one(One(id))); + } + if (rel is Many) { + final ids = await repo + .replaceMany(target.type, target.id, target.relationship, + rel.map((_) => _.key)) + .map(Identifier.fromKey) + .toList(); + return Response.ok(OutboundDataDocument.many(Many(ids))); + } + throw FormatException('Incomplete relationship'); + } + + @override + Future deleteMany( + HttpRequest request, RelationshipTarget target) async { + final rel = _decode(request).dataAsRelationship(); + final ids = await repo + .deleteMany( + target.type, target.id, target.relationship, rel.map((_) => _.key)) + .map(Identifier.fromKey) + .toList(); + return Response.ok(OutboundDataDocument.many(Many(ids))); + } + + @override + Future fetchRelationship( + HttpRequest rq, RelationshipTarget target) async { + final model = await repo.fetch(target.type, target.id); + if (model.one.containsKey(target.relationship)) { + final doc = OutboundDataDocument.one( + One(nullable(Identifier.fromKey)(model.one[target.relationship]))); + return Response.ok(doc); + } + if (model.many.containsKey(target.relationship)) { + final doc = OutboundDataDocument.many( + Many(model.many[target.relationship].map(Identifier.fromKey))); + return Response.ok(doc); + } + // TODO: implement fetchRelationship + throw UnimplementedError(); + } + + /// Returns a stream of related resources recursively + Stream _getAllRelated( + Resource resource, Iterable forest) async* { + for (final node in forest) { + await for (final r in _getRelated(resource, node.name)) { + yield r; + yield* _getAllRelated(r, node.children); + } + } + } + + /// Returns a stream of related resources + Stream _getRelated(Resource resource, String relationship) async* { + for (final _ in resource.relationships[relationship]) { + final r = await _fetchLinkedResource(_.type, _.id); + if (r != null) yield r; + } + } + + /// Fetches and builds a resource object with a "self" link + Future _fetchLinkedResource(String type, String id) async { + final r = await _fetchResource(type, id); + if (r == null) return null; + return r..links['self'] = Link(ResourceTarget(type, id).map(urlDesign)); + } + + Stream _fetchAll(String type) => + repo.fetchAll(type).map((e) => _toResource(e.id, type, e.model)); + + /// Fetches and builds a resource object + Future _fetchResource(String type, String id) async { + final model = await repo.fetch(type, id); + if (model == null) return null; + return _toResource(id, type, model); + } + + Resource _toResource(String id, String type, Model model) { + final res = Resource(type, id); + model.attributes.forEach((key, value) { + res.attributes[key] = value; + }); + model.one.forEach((key, value) { + res.relationships[key] = One(nullable(Identifier.fromKey)(value)); + }); + model.many.forEach((key, value) { + res.relationships[key] = Many(value.map((Identifier.fromKey))); + }); + return res; + } + + Model _toModel(ResourceProperties r) { + final model = Model(); + r.attributes.forEach((key, value) { + model.attributes[key] = value; + }); + r.relationships.forEach((key, value) { + if (value is One) { + model.one[key] = value?.identifier?.key; + } + if (value is Many) { + model.many[key] = Set.from(value.map((_) => _.key)); + } + }); + return model; + } + + InboundDocument _decode(HttpRequest r) => InboundDocument.decode(r.body); +} + +typedef IdGenerator = String Function(); diff --git a/lib/src/client/response/collection_response.dart b/lib/src/client/collection_response.dart similarity index 96% rename from lib/src/client/response/collection_response.dart rename to lib/src/client/collection_response.dart index 78bcaad3..947a855a 100644 --- a/lib/src/client/response/collection_response.dart +++ b/lib/src/client/collection_response.dart @@ -15,6 +15,7 @@ class CollectionResponse { this.links.addAll(links); } + /// Decodes the response from [HttpResponse]. static CollectionResponse decode(HttpResponse response) { final doc = InboundDocument.decode(response.body); return CollectionResponse(response, diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart index f583cba2..818a6be8 100644 --- a/lib/src/client/dart_http.dart +++ b/lib/src/client/dart_http.dart @@ -3,19 +3,33 @@ import 'package:json_api/http.dart'; /// A handler using the built-in http client class DartHttp implements HttpHandler { - DartHttp(this._client); + /// Creates an instance of [DartHttp]. + /// If [client] is passed, it will be used to keep a persistent connection. + /// In this case it is your responsibility to call [Client.close]. + /// If [client] is omitted, a new connection will be established for each call. + DartHttp({this.client}); + + final Client client; @override Future call(HttpRequest request) async { - final response = await _send(Request(request.method, request.uri) + final response = await _call(Request(request.method, request.uri) ..headers.addAll(request.headers) ..body = request.body); return HttpResponse(response.statusCode, body: response.body, headers: response.headers); } - final Client _client; + Future _call(Request request) async { + if (client != null) return await _send(request, client); + final tempClient = Client(); + try { + return await _send(request, tempClient); + } finally { + tempClient.close(); + } + } - Future _send(Request request) async => - Response.fromStream(await _client.send(request)); + Future _send(Request request, Client client) async => + await Response.fromStream(await client.send(request)); } diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 327c0e30..2392ca29 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -14,10 +14,25 @@ class JsonApiClient { final HttpHandler _http; final UriFactory _uriFactory; + // /// Adds identifiers to a to-many relationship + // RelationshipResponse addMany(String type, String id, + // String relationship, List identifiers) async => + // Request('post', RelationshipTarget(type, id, relationship), + // RelationshipResponse.decodeMany, + // document: OutboundDataDocument.many(Many(identifiers))); + + /// Sends the [request] to the server. /// Returns the response when the server responds with a JSON:API document. /// Throws a [RequestFailure] if the server responds with an error. Future call(JsonApiRequest request) async { + return request.response(await _call(request)); + } + + /// Sends the [request] to the server. + /// Returns the response when the server responds with a JSON:API document. + /// Throws a [RequestFailure] if the server responds with an error. + Future _call(JsonApiRequest request) async { final response = await _http.call(_toHttp(request)); if (!response.isSuccessful && !response.isPending) { throw RequestFailure(response, @@ -25,7 +40,7 @@ class JsonApiClient { ? InboundDocument.decode(response.body) : null); } - return request.response(response); + return response; } HttpRequest _toHttp(JsonApiRequest request) { diff --git a/lib/src/client/json_api_request.dart b/lib/src/client/json_api_request.dart index b5c8b7f5..47894008 100644 --- a/lib/src/client/json_api_request.dart +++ b/lib/src/client/json_api_request.dart @@ -1,19 +1,19 @@ -import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +/// An abstract request consumed by the client abstract class JsonApiRequest { /// HTTP method String get method; /// The outbound document. Nullable. - OutboundDocument /*?*/ get document; + Object /*?*/ get document; /// Any extra headers. Map get headers; /// Returns the request URI - Uri uri(TargetMapper urls); + Uri uri(UriFactory uriFactory); /// Converts the HTTP response to the response object T response(HttpResponse response); diff --git a/lib/src/client/response/new_resource_response.dart b/lib/src/client/new_resource_response.dart similarity index 82% rename from lib/src/client/response/new_resource_response.dart rename to lib/src/client/new_resource_response.dart index 8a89f977..dcad8d61 100644 --- a/lib/src/client/response/new_resource_response.dart +++ b/lib/src/client/new_resource_response.dart @@ -2,6 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/src/client/identity_collection.dart'; +/// A response to a new resource creation request. class NewResourceResponse { NewResourceResponse(this.http, this.resource, {Map links = const {}, @@ -16,10 +17,15 @@ class NewResourceResponse { links: doc.links, included: doc.included); } - /// Original HTTP response + /// Original HTTP response. final HttpResponse http; + /// Nullable. Created resource. final Resource /*?*/ resource; + + /// Included resources. final IdentityCollection included; + + /// Document links. final links = {}; } diff --git a/lib/src/client/response/relationship_response.dart b/lib/src/client/relationship_response.dart similarity index 96% rename from lib/src/client/response/relationship_response.dart rename to lib/src/client/relationship_response.dart index 907ec686..773b4a55 100644 --- a/lib/src/client/response/relationship_response.dart +++ b/lib/src/client/relationship_response.dart @@ -2,6 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/src/client/identity_collection.dart'; +/// A response to a relationship request. class RelationshipResponse { RelationshipResponse(this.http, this.relationship, {Iterable included = const []}) diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 56becdb1..ba938e2f 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -2,23 +2,25 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/collection_response.dart'; import 'package:json_api/src/client/json_api_request.dart'; -import 'package:json_api/src/client/response/collection_response.dart'; -import 'package:json_api/src/client/response/new_resource_response.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; - +import 'package:json_api/src/client/new_resource_response.dart'; +import 'package:json_api/src/client/relationship_response.dart'; +import 'package:json_api/src/client/resource_response.dart'; +import 'package:json_api/src/client/response.dart'; + +/// A basic implementation of [JsonApiRequest]. +/// Allows to easily add query parameters. +/// Contains a collection of static factory methods for common JSON:API requests. class Request implements JsonApiRequest { - Request(this.method, this.target, this.convert, [this.document]); + Request(this.method, this.target, this.convert, {this.document}); /// Adds identifiers to a to-many relationship static Request> addMany(String type, String id, String relationship, List identifiers) => - Request( - 'post', - RelationshipTarget(type, id, relationship), + Request('post', RelationshipTarget(type, id, relationship), RelationshipResponse.decodeMany, - OutboundDataDocument.many(Many(identifiers))); + document: OutboundDataDocument.many(Many(identifiers))); /// Creates a new resource on the server. The server is responsible for assigning the resource id. static Request createNew(String type, @@ -26,11 +28,8 @@ class Request implements JsonApiRequest { Map one = const {}, Map> many = const {}, Map meta = const {}}) => - Request( - 'post', - CollectionTarget(type), - NewResourceResponse.decode, - OutboundDataDocument.newResource(NewResource(type) + Request('post', CollectionTarget(type), NewResourceResponse.decode, + document: OutboundDataDocument.newResource(NewResource(type) ..attributes.addAll(attributes) ..relationships.addAll({ ...one.map((key, value) => MapEntry(key, One(value))), @@ -40,11 +39,9 @@ class Request implements JsonApiRequest { static Request> deleteMany(String type, String id, String relationship, List identifiers) => - Request( - 'delete', - RelationshipTarget(type, id, relationship), + Request('delete', RelationshipTarget(type, id, relationship), RelationshipResponse.decode, - OutboundDataDocument.many(Many(identifiers))); + document: OutboundDataDocument.many(Many(identifiers))); static Request fetchCollection(String type) => Request('get', CollectionTarget(type), CollectionResponse.decode); @@ -59,6 +56,16 @@ class Request implements JsonApiRequest { Request('get', RelationshipTarget(type, id, relationship), RelationshipResponse.decode); + static Request> fetchOne( + String type, String id, String relationship) => + Request('get', RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeOne); + + static Request> fetchMany( + String type, String id, String relationship) => + Request('get', RelationshipTarget(type, id, relationship), + RelationshipResponse.decodeMany); + static Request fetchRelatedResource( String type, String id, String relationship) => Request('get', RelatedTarget(type, id, relationship), @@ -75,11 +82,8 @@ class Request implements JsonApiRequest { Map> many = const {}, Map meta = const {}, }) => - Request( - 'patch', - ResourceTarget(type, id), - ResourceResponse.decode, - OutboundDataDocument.resource(Resource(type, id) + Request('patch', ResourceTarget(type, id), ResourceResponse.decode, + document: OutboundDataDocument.resource(Resource(type, id) ..attributes.addAll(attributes) ..relationships.addAll({ ...one.map((key, value) => MapEntry(key, One(value))), @@ -96,11 +100,8 @@ class Request implements JsonApiRequest { Map> many = const {}, Map meta = const {}, }) => - Request( - 'post', - CollectionTarget(type), - ResourceResponse.decode, - OutboundDataDocument.resource(Resource(type, id) + Request('post', CollectionTarget(type), ResourceResponse.decode, + document: OutboundDataDocument.resource(Resource(type, id) ..attributes.addAll(attributes) ..relationships.addAll({ ...one.map((k, v) => MapEntry(k, One(v))), @@ -110,27 +111,24 @@ class Request implements JsonApiRequest { static Request> replaceOne( String type, String id, String relationship, Identifier identifier) => - Request( - 'patch', - RelationshipTarget(type, id, relationship), + Request('patch', RelationshipTarget(type, id, relationship), RelationshipResponse.decodeOne, - OutboundDataDocument.one(One(identifier))); + document: OutboundDataDocument.one(One(identifier))); static Request> replaceMany(String type, String id, String relationship, Iterable identifiers) => - Request( - 'patch', - RelationshipTarget(type, id, relationship), + Request('patch', RelationshipTarget(type, id, relationship), RelationshipResponse.decodeMany, - OutboundDataDocument.many(Many(identifiers))); + document: OutboundDataDocument.many(Many(identifiers))); static Request> deleteOne( String type, String id, String relationship) => - Request( - 'patch', - RelationshipTarget(type, id, relationship), + Request('patch', RelationshipTarget(type, id, relationship), RelationshipResponse.decodeOne, - OutboundDataDocument.one(One.empty())); + document: OutboundDataDocument.one(One.empty())); + + static JsonApiRequest deleteResource(String type, String id) => + Request('delete', ResourceTarget(type, id), Response.decode); /// Request target final Target target; @@ -139,7 +137,7 @@ class Request implements JsonApiRequest { final String method; @override - final OutboundDocument document; + final Object document; final T Function(HttpResponse response) convert; diff --git a/lib/src/client/request_failure.dart b/lib/src/client/request_failure.dart index 93449aff..da8cb3bd 100644 --- a/lib/src/client/request_failure.dart +++ b/lib/src/client/request_failure.dart @@ -8,4 +8,8 @@ class RequestFailure implements Exception { /// The response itself. final HttpResponse http; final InboundDocument /*?*/ document; + + @override + String toString() => + 'JSON:API request failed with HTTP status ${http.statusCode}'; } diff --git a/lib/src/client/response/resource_response.dart b/lib/src/client/resource_response.dart similarity index 95% rename from lib/src/client/response/resource_response.dart rename to lib/src/client/resource_response.dart index 8ca5c1bd..19057b74 100644 --- a/lib/src/client/response/resource_response.dart +++ b/lib/src/client/resource_response.dart @@ -22,6 +22,10 @@ class ResourceResponse { /// The created resource. Null for "204 No Content" responses. final Resource /*?*/ resource; + + /// Included resources final IdentityCollection included; + + /// Document links final links = {}; } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart new file mode 100644 index 00000000..0cb80821 --- /dev/null +++ b/lib/src/client/response.dart @@ -0,0 +1,12 @@ +import 'package:json_api/http.dart'; + +/// A response sent by the server +class Response { + Response(this.http); + + static Response decode(HttpResponse response) { + return Response(response); + } + + final HttpResponse http; +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index d10e9460..368c7c35 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -4,6 +4,12 @@ import 'package:json_api/src/document/identity.dart'; class Identifier with Identity { Identifier(this.type, this.id); + /// Created a new [Identifier] from an [Identity] key. + static Identifier fromKey(String key) { + final p = Identity.split(key); + return Identifier(p.first, p.last); + } + @override final String type; diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart index 3e5ecbe1..172b98cc 100644 --- a/lib/src/document/identity.dart +++ b/lib/src/document/identity.dart @@ -2,6 +2,12 @@ mixin Identity { static const separator = ':'; + /// Makes a string key from [type] and [id] + static String makeKey(String type, String id) => '$type$separator$id'; + + /// Splits the key into the type and id. Returns a list of 2 elements. + static List split(String key) => key.split(separator); + /// Resource type String get type; @@ -9,5 +15,5 @@ mixin Identity { String get id; /// Compound key, uniquely identifying the resource - String get key => '$type$separator$id'; + String get key => makeKey(type, id); } diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index e363f3fb..ad8d8fc5 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -4,7 +4,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/document/error_source.dart'; import 'package:json_api/src/document/many.dart'; import 'package:json_api/src/document/one.dart'; -import 'package:json_api/src/extensions.dart'; import 'package:json_api/src/nullable.dart'; /// A generic inbound JSON:API document @@ -58,7 +57,11 @@ class InboundDocument { return nullable(_resource)(_json.getNullable('data')); } - Relationship dataAsRelationship() => _relationship(_json); + R dataAsRelationship() { + final rel = _relationship(_json); + if (rel is R) return rel; + throw FormatException('Invalid relationship type'); + } static Map _links(Map json) => json .get('links', orGet: () => {}) @@ -141,3 +144,27 @@ class InboundDocument { .get('relationships', orGet: () => {}) .map((key, value) => MapEntry(key, _relationship(value))); } + +extension _TypedGetter on Map { + T get(String key, {T Function() /*?*/ orGet}) { + if (containsKey(key)) { + final val = this[key]; + if (val is T) return val; + throw FormatException( + 'Key "$key": expected $T, found ${val.runtimeType}'); + } + if (orGet != null) return orGet(); + throw FormatException('Key "$key" does not exist'); + } + + T /*?*/ getNullable(String key, {T /*?*/ Function() /*?*/ orGet}) { + if (containsKey(key)) { + final val = this[key]; + if (val is T || val == null) return val; + throw FormatException( + 'Key "$key": expected $T, found ${val.runtimeType}'); + } + if (orGet != null) return orGet(); + throw FormatException('Key "$key" does not exist'); + } +} diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart index bde0e295..2d816c0e 100644 --- a/lib/src/document/one.dart +++ b/lib/src/document/one.dart @@ -6,6 +6,10 @@ class One extends Relationship { One.empty() : identifier = null; + /// Returns the key of the relationship identifier. + /// If the identifier is null, returns an empty string. + String get key => identifier?.key ?? ''; + @override Map toJson() => {'data': identifier, ...super.toJson()}; diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index edef7345..824ff26c 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,3 +1,4 @@ +import 'package:json_api/document.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/identity.dart'; import 'package:json_api/src/document/link.dart'; @@ -15,9 +16,21 @@ class Resource with ResourceProperties, Identity { @override final String id; + /// Resource links final links = {}; - Identifier get identifier => Identifier(type, id); + /// Converts the resource to its identifier + Identifier toIdentifier() => Identifier(type, id); + + /// Returns a to-one relationship by its [name]. + /// Throws [StateError] if the relationship does not exist. + /// Throws [StateError] if the relationship is not a to-one. + One one(String name) => _rel(name); + + /// Returns a to-many relationship by its [name]. + /// Throws [StateError] if the relationship does not exist. + /// Throws [StateError] if the relationship is not a to-many. + Many many(String name) => _rel(name); Map toJson() => { 'type': type, @@ -27,4 +40,14 @@ class Resource with ResourceProperties, Identity { if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; + + /// Returns a typed relationship by its [name]. + /// Throws [StateError] if the relationship does not exist. + /// Throws [StateError] if the relationship is not of the given type. + R _rel(String name) { + final r = relationships[name]; + if (r is R) return r; + throw StateError( + 'Relationship $name (${r.runtimeType}) is not of type ${R}'); + } } diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart index e9b4bae4..dd8c8a27 100644 --- a/lib/src/document/resource_properties.dart +++ b/lib/src/document/resource_properties.dart @@ -1,3 +1,5 @@ +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/document/relationship.dart'; mixin ResourceProperties { @@ -13,4 +15,5 @@ mixin ResourceProperties { /// /// See https://jsonapi.org/format/#document-resource-object-relationships final relationships = {}; + } diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart deleted file mode 100644 index be75c4f9..00000000 --- a/lib/src/extensions.dart +++ /dev/null @@ -1,23 +0,0 @@ -extension TypedGetter on Map { - T get(String key, {T Function() /*?*/ orGet}) { - if (containsKey(key)) { - final val = this[key]; - if (val is T) return val; - throw FormatException( - 'Key "$key": expected $T, found ${val.runtimeType}'); - } - if (orGet != null) return orGet(); - throw FormatException('Key "$key" does not exist'); - } - - T /*?*/ getNullable(String key, {T /*?*/ Function() /*?*/ orGet}) { - if (containsKey(key)) { - final val = this[key]; - if (val is T || val == null) return val; - throw FormatException( - 'Key "$key": expected $T, found ${val.runtimeType}'); - } - if (orGet != null) return orGet(); - throw FormatException('Key "$key" does not exist'); - } -} diff --git a/lib/src/http/callback_http_logger.dart b/lib/src/http/callback_http_logger.dart new file mode 100644 index 00000000..00a700c9 --- /dev/null +++ b/lib/src/http/callback_http_logger.dart @@ -0,0 +1,24 @@ +import 'package:json_api/http.dart'; + +class CallbackHttpLogger implements HttpLogger { + const CallbackHttpLogger( + {_Consumer onRequest, _Consumer onResponse}) + : _onRequest = onRequest, + _onResponse = onResponse; + + final _Consumer /*?*/ _onRequest; + + final _Consumer /*?*/ _onResponse; + + @override + void onRequest(HttpRequest request) { + _onRequest?.call(request); + } + + @override + void onResponse(HttpResponse response) { + _onResponse?.call(response); + } +} + +typedef _Consumer = void Function(R r); diff --git a/lib/src/http/last_value_logger.dart b/lib/src/http/last_value_logger.dart deleted file mode 100644 index 8ce77e38..00000000 --- a/lib/src/http/last_value_logger.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:json_api/src/http/http_logger.dart'; -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -class LastValueLogger implements HttpLogger { - @override - void onRequest(HttpRequest request) => this.request = request; - - @override - void onResponse(HttpResponse response) => this.response = response; - - /// Last received response or null. - HttpResponse /*?*/ response; - - /// Last sent request or null. - HttpRequest /*?*/ request; -} diff --git a/lib/src/routing/reference.dart b/lib/src/routing/_reference.dart similarity index 100% rename from lib/src/routing/reference.dart rename to lib/src/routing/_reference.dart diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 88ee3b0b..573b5312 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,5 +1,4 @@ -import 'package:json_api/routing.dart'; -import 'package:json_api/src/routing/reference.dart'; +import 'package:json_api/src/routing/_reference.dart'; /// A request target abstract class Target { diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 4a85d596..3bf5a438 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -2,9 +2,30 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; abstract class JsonApiController { + /// Fetch a primary resource collection T fetchCollection(HttpRequest request, CollectionTarget target); + /// Create resource T createResource(HttpRequest request, CollectionTarget target); + /// Fetch a single primary resource T fetchResource(HttpRequest request, ResourceTarget target); + + /// Updates a primary resource + T updateResource(HttpRequest request, ResourceTarget target); + + /// Deletes the primary resource + T deleteResource(HttpRequest request, ResourceTarget target); + + /// Fetches a relationship + T fetchRelationship(HttpRequest rq, RelationshipTarget target ); + + /// Add new entries to a to-many relationship + T addMany(HttpRequest request, RelationshipTarget target); + + /// Updates the relationship + T replaceRelationship(HttpRequest request, RelationshipTarget target); + + /// Deletes the members from the to-many relationship + T deleteMany(HttpRequest request, RelationshipTarget target); } diff --git a/lib/src/server/model.dart b/lib/src/server/model.dart deleted file mode 100644 index 2751f947..00000000 --- a/lib/src/server/model.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/document.dart'; - -class Model { - Model(this.type); - - final String type; - final attributes = {}; - final one = {}; - final many = >{}; - final meta = {}; -} diff --git a/lib/src/server/relationship_node.dart b/lib/src/server/relationship_node.dart new file mode 100644 index 00000000..ffaa4e8f --- /dev/null +++ b/lib/src/server/relationship_node.dart @@ -0,0 +1,27 @@ +/// Relationship tree node +class RelationshipNode { + RelationshipNode(this.name); + + static Iterable forest(Iterable relationships) { + final root = RelationshipNode(''); + relationships + .map((rel) => rel.trim().split('.').map((e) => e.trim())) + .forEach(root.add); + return root.children; + } + + /// The name of the relationship + final String name; + + Iterable get children => _map.values; + + final _map = {}; + + /// Adds the chain to the tree + void add(Iterable chain) { + if (chain.isEmpty) return; + final key = chain.first; + _map[key] ??= RelationshipNode(key); + _map[key].add(chain.skip(1)); + } +} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart new file mode 100644 index 00000000..d4b57f03 --- /dev/null +++ b/lib/src/server/response.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:json_api/http.dart'; +import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/nullable.dart'; + +/// JSON:API response +class Response extends HttpResponse { + Response(int statusCode, + {Object /*?*/ document, Map headers = const {}}) + : super(statusCode, body: nullable(jsonEncode)(document) ?? '', headers: { + ...headers, + if (document != null) 'content-type': MediaType.jsonApi + }); + + Response.ok(Object document) : this(200, document: document); + + Response.noContent() : this(204); + + Response.notFound({Object /*?*/ document}) : this(404, document: document); + + Response.created(Object document, {String location = ''}) + : this(201, + document: document, + headers: {if (location.isNotEmpty) 'location': location}); +} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index c4be8421..55e1dc67 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -8,17 +8,26 @@ class Router { final TargetMatcher matcher; - T route(HttpRequest request, JsonApiController controller) { - final target = matcher.match(request.uri); + T route(HttpRequest rq, JsonApiController controller) { + final target = matcher.match(rq.uri); if (target is CollectionTarget) { - if (request.isGet) return controller.fetchCollection(request, target); - if (request.isPost) return controller.createResource(request, target); - throw MethodNotAllowed(request.method); + if (rq.isGet) return controller.fetchCollection(rq, target); + if (rq.isPost) return controller.createResource(rq, target); + throw MethodNotAllowed(rq.method); } if (target is ResourceTarget) { - if (request.isGet) return controller.fetchResource(request, target); - throw MethodNotAllowed(request.method); + if (rq.isGet) return controller.fetchResource(rq, target); + if (rq.isDelete) return controller.deleteResource(rq, target); + if (rq.isPatch) return controller.updateResource(rq, target); + throw MethodNotAllowed(rq.method); } - throw 'UnmatchedTarget'; + if (target is RelationshipTarget) { + if (rq.isGet) return controller.fetchRelationship(rq, target); + if (rq.isPost) return controller.addMany(rq, target); + if (rq.isPatch) return controller.replaceRelationship(rq, target); + if (rq.isDelete) return controller.deleteMany(rq, target); + throw MethodNotAllowed(rq.method); + } + throw 'UnmatchedTarget $target'; } } diff --git a/lib/src/test/mock_handler.dart b/lib/src/test/mock_handler.dart new file mode 100644 index 00000000..88c21d2f --- /dev/null +++ b/lib/src/test/mock_handler.dart @@ -0,0 +1,12 @@ +import 'package:json_api/http.dart'; + +class MockHandler implements HttpHandler { + HttpResponse /*?*/ response; + HttpRequest /*?*/ request; + + @override + Future call(HttpRequest request) async { + this.request = request; + return response; + } +} diff --git a/test/document/payload.dart b/lib/src/test/payload.dart similarity index 100% rename from test/document/payload.dart rename to lib/src/test/payload.dart diff --git a/test/client/response.dart b/lib/src/test/response.dart similarity index 100% rename from test/client/response.dart rename to lib/src/test/response.dart diff --git a/pubspec.yaml b/pubspec.yaml index bc806aca..f5c1bc89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,6 @@ dependencies: dev_dependencies: uuid: ^2.2.2 pedantic: ^1.9.2 - sqlite3: ^0.1.7 test: ^1.15.4 test_coverage: ^0.5.0 stream_channel: ^2.0.0 diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart new file mode 100644 index 00000000..13c85f11 --- /dev/null +++ b/test/e2e/browser_test.dart @@ -0,0 +1,46 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/http/callback_http_logger.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + StreamChannel channel; + JsonApiClient client; + + Future after(JsonApiRequest request, + [void Function(R response) check]) async { + final response = await client(request); + check?.call(response); + } + + setUp(() async { + channel = spawnHybridUri('hybrid_server.dart'); + final serverUrl = await channel.stream.first; + // final serverUrl = 'http://localhost:8080'; + + client = JsonApiClient(LoggingHttpHandler(DartHttp(), CallbackHttpLogger()), + RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); + }); + + /// Goal: test different HTTP methods in a browser + test('Basic Client-Server interaction over HTTP', () async { + final id = Uuid().v4(); + await client( + Request.create('posts', id, attributes: {'title': 'Hello world'})); + await after(Request.fetchResource('posts', id), (r) { + expect(r.resource.attributes['title'], 'Hello world'); + }); + await client(Request.updateResource('posts', id, + attributes: {'title': 'Bye world'})); + await after(Request.fetchResource('posts', id), (r) { + expect(r.resource.attributes['title'], 'Bye world'); + }); + await client(Request.deleteResource('posts', id)); + await after(Request.fetchCollection('posts'), (r) { + expect(r.collection, isEmpty); + }); + }); +} diff --git a/test/e2e/dart_http_handler.dart b/test/e2e/dart_http_handler.dart deleted file mode 100644 index 526437bf..00000000 --- a/test/e2e/dart_http_handler.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:json_api/http.dart'; - -class DartHttpHandler { - DartHttpHandler(this._handler); - - final HttpHandler _handler; - - Future call(io.HttpRequest ioRequest) async { - final request = await _convertRequest(ioRequest); - final response = await _handler(request); - await _sendResponse(response, ioRequest.response); - } - - Future _sendResponse( - HttpResponse response, io.HttpResponse ioResponse) async { - response.headers.forEach(ioResponse.headers.add); - ioResponse.statusCode = response.statusCode; - ioResponse.write(response.body); - await ioResponse.close(); - } - - Future _convertRequest(io.HttpRequest ioRequest) async => - HttpRequest(ioRequest.method, ioRequest.requestedUri, - body: await _readBody(ioRequest), - headers: _convertHeaders(ioRequest.headers)); - - Future _readBody(io.HttpRequest ioRequest) => - ioRequest.cast>().transform(utf8.decoder).join(); - - Map _convertHeaders(io.HttpHeaders ioHeaders) { - final headers = {}; - ioHeaders.forEach((k, v) => headers[k] = v.join(',')); - return headers; - } -} diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart deleted file mode 100644 index 06e4371d..00000000 --- a/test/e2e/e2e_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:stream_channel/stream_channel.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import '../../example/demo/printing_logger.dart'; - -void main() { - final sql = ''' - CREATE TABLE books ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT - ); - '''; - - final logger = PrintingLogger(); - - StreamChannel channel; - http.Client httpClient; - JsonApiClient client; - - setUp(() async { - channel = spawnHybridUri('hybrid_server.dart', message: sql); - final serverUrl = await channel.stream.first; - // final serverUrl = 'http://localhost:8080'; - httpClient = http.Client(); - - client = JsonApiClient(LoggingHttpHandler(DartHttp(httpClient), logger), - RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); - }); - - tearDown(() async { - httpClient.close(); - }); - - group('Basic Client-Server interaction over HTTP', () { - test('Create new resource, read collection', () async { - final r0 = await client( - Request.createNew('books', attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 201); - expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); - expect(r0.resource.type, 'books'); - expect(r0.resource.id, isNotEmpty); - expect(r0.resource.attributes['title'], 'Hello world'); - expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); - - final r1 = await client(Request.fetchCollection('books')); - expect(r1.http.statusCode, 200); - expect(r1.collection.first.type, 'books'); - expect(r1.collection.first.attributes['title'], 'Hello world'); - }); - - test('Create new resource sets Location header', () async { - // TODO: Why does this not work in browsers? - final r0 = await client( - Request.createNew('books', attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 201); - expect(r0.http.headers['location'], '/books/${r0.resource.id}'); - }, testOn: 'vm'); - - test('Create resource with id, read resource by id', () async { - final id = Uuid().v4(); - final r0 = await client( - Request.create('books', id, attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 204); - expect(r0.resource, isNull); - expect(r0.http.headers['location'], isNull); - - final r1 = await client(Request.fetchResource('books', id)); - expect(r1.http.statusCode, 200); - expect(r1.http.headers['content-type'], 'application/vnd.api+json'); - expect(r1.resource.type, 'books'); - expect(r1.resource.id, id); - expect(r1.resource.attributes['title'], 'Hello world'); - }); - }); -} diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 7bacc28a..1ff711c5 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,9 +1,9 @@ +import 'package:json_api/src/_demo/demo_server.dart'; +import 'package:json_api/src/_demo/in_memory_repo.dart'; import 'package:stream_channel/stream_channel.dart'; -import '../../example/demo/demo_server.dart'; - -void hybridMain(StreamChannel channel, Object initSql) async { - final demo = DemoServer(initSql); +void hybridMain(StreamChannel channel, Object message) async { + final demo = DemoServer(InMemoryRepo(['users', 'posts', 'comments'])); await demo.start(); - channel.sink.add(demo.uri); + channel.sink.add(demo.uri.toString()); } diff --git a/test/e2e/usecase_test.dart b/test/e2e/usecase_test.dart new file mode 100644 index 00000000..ac0ccef3 --- /dev/null +++ b/test/e2e/usecase_test.dart @@ -0,0 +1,144 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/_demo/demo_server.dart'; +import 'package:json_api/src/_demo/in_memory_repo.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +void main() { + JsonApiClient client; + DemoServer server; + + setUp(() async { + final repo = InMemoryRepo(['users', 'posts', 'comments']); + server = DemoServer(repo, port: 8123); + await server.start(); + client = JsonApiClient(DartHttp(), RecommendedUrlDesign(server.uri)); + }); + + tearDown(() async { + await server.stop(); + }); + + group('Use cases', () { + group('Resource creation', () { + test('Resource id assigned on the server', () async { + final r = await client( + Request.createNew('posts', attributes: {'title': 'Hello world'})); + expect(r.http.statusCode, 201); + // TODO: Why does "Location" header not work in browsers? + expect(r.http.headers['location'], '/posts/${r.resource.id}'); + expect(r.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); + expect(r.resource.id, isNotEmpty); + expect(r.resource.attributes['title'], 'Hello world'); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); + }); + test('Resource id assigned on the client', () async { + final id = Uuid().v4(); + final r = await client( + Request.create('posts', id, attributes: {'title': 'Hello world'})); + expect(r.http.statusCode, 204); + expect(r.resource, isNull); + expect(r.http.headers['location'], isNull); + }); + }); + + group('CRUD', () { + Resource alice; + Resource bob; + Resource post; + Resource comment; + Resource secretComment; + + setUp(() async { + alice = (await client( + Request.createNew('users', attributes: {'name': 'Alice'}))) + .resource; + bob = (await client( + Request.createNew('users', attributes: {'name': 'Bob'}))) + .resource; + post = (await client(Request.createNew('posts', + attributes: {'title': 'Hello world'}, + one: {'author': alice.toIdentifier()}))) + .resource; + comment = (await client(Request.createNew('comments', + attributes: {'text': 'Hi Alice'}, + one: {'author': bob.toIdentifier()}))) + .resource; + secretComment = (await client(Request.createNew('comments', + attributes: {'text': 'Secret comment'}, + one: {'author': bob.toIdentifier()}))) + .resource; + await client(Request.addMany( + post.type, post.id, 'comments', [comment.toIdentifier()])); + }); + + test('Fetch a complex resource', () async { + final response = await client(Request.fetchCollection('posts') + ..include(['author', 'comments', 'comments.author'])); + + expect(response.http.statusCode, 200); + expect(response.collection.length, 1); + expect(response.included.length, 3); + + final fetchedPost = response.collection.first; + expect(fetchedPost.attributes['title'], 'Hello world'); + + final fetchedAuthor = response.included[fetchedPost.one('author').key]; + expect(fetchedAuthor.attributes['name'], 'Alice'); + + final fetchedComment = + response.included[fetchedPost.many('comments').single.key]; + expect(fetchedComment.attributes['text'], 'Hi Alice'); + }); + + test('Fetch a to-one relationship', () async { + final r = await client( + Request.fetchOne(post.type, post.id, 'author')); + expect(r.relationship.identifier.id, alice.id); + }); + + test('Fetch a to-many relationship', () async { + final r = await client( + Request.fetchMany(post.type, post.id, 'comments')); + expect(r.relationship.single.id, comment.id); + }); + + test('Delete a to-one relationship', () async { + await client(Request.deleteOne(post.type, post.id, 'author')); + final r = await client( + Request.fetchResource(post.type, post.id)..include(['author'])); + expect(r.resource.one('author'), isEmpty); + }); + + test('Replace a to-one relationship', () async { + await client(Request.replaceOne( + post.type, post.id, 'author', bob.toIdentifier())); + final r = await client( + Request.fetchResource(post.type, post.id)..include(['author'])); + expect( + r.included[r.resource.one('author').key].attributes['name'], 'Bob'); + }); + + test('Delete from a to-many relationship', () async { + await client(Request.deleteMany( + post.type, post.id, 'comments', [comment.toIdentifier()])); + final r = await client(Request.fetchResource(post.type, post.id)); + expect(r.resource.many('comments'), isEmpty); + }); + + test('Replace a to-many relationship', () async { + await client(Request.replaceMany( + post.type, post.id, 'comments', [secretComment.toIdentifier()])); + final r = await client( + Request.fetchResource(post.type, post.id)..include(['comments'])); + expect( + r.included[r.resource.many('comments').single.key] + .attributes['text'], + 'Secret comment'); + }); + }); + }, testOn: 'vm'); +} diff --git a/test/integration/integration_test.dart b/test/integration/integration_test.dart deleted file mode 100644 index 64eb6d93..00000000 --- a/test/integration/integration_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:sqlite3/sqlite3.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import '../../example/demo/printing_logger.dart'; -import '../../example/demo/sqlite_controller.dart'; - -void main() { - final sql = ''' - CREATE TABLE books ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT - ); - '''; - - JsonApiClient client; - - setUp(() async { - final db = sqlite3.openInMemory(); - db.execute(sql); - final controller = SqliteController(db); - final jsonApiServer = - JsonApiHandler(controller, exposeInternalErrors: true); - - client = JsonApiClient( - LoggingHttpHandler(jsonApiServer, const PrintingLogger()), - RecommendedUrlDesign.pathOnly); - }); - - group('Basic Client-Server interaction over HTTP', () { - test('Create new resource, read collection', () async { - final r0 = await client( - Request.createNew('books', attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 201); - expect(r0.links['self'].toString(), '/books/${r0.resource.id}'); - expect(r0.resource.type, 'books'); - expect(r0.resource.id, isNotEmpty); - expect(r0.resource.attributes['title'], 'Hello world'); - expect(r0.resource.links['self'].toString(), '/books/${r0.resource.id}'); - - final r1 = await client(Request.fetchCollection('books')); - expect(r1.http.statusCode, 200); - expect(r1.collection.first.type, 'books'); - expect(r1.collection.first.attributes['title'], 'Hello world'); - }); - - test('Create new resource sets Location header', () async { - // TODO: Why does this not work in browsers? - final r0 = await client( - Request.createNew('books', attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 201); - expect(r0.http.headers['location'], '/books/${r0.resource.id}'); - }, testOn: 'vm'); - - test('Create resource with id, read resource by id', () async { - final id = Uuid().v4(); - final r0 = await client( - Request.create('books', id, attributes: {'title': 'Hello world'})); - expect(r0.http.statusCode, 204); - expect(r0.resource, isNull); - expect(r0.http.headers['location'], isNull); - - final r1 = await client(Request.fetchResource('books', id)); - expect(r1.http.statusCode, 200); - expect(r1.http.headers['content-type'], 'application/vnd.api+json'); - expect(r1.resource.type, 'books'); - expect(r1.resource.id, id); - expect(r1.resource.attributes['title'], 'Hello world'); - }); - }); -} diff --git a/test/client/client_test.dart b/test/unit/client/client_test.dart similarity index 90% rename from test/client/client_test.dart rename to test/unit/client/client_test.dart index ec65861c..37a6a125 100644 --- a/test/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -2,16 +2,23 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/test/mock_handler.dart'; +import 'package:json_api/src/test/response.dart' as mock; import 'package:test/test.dart'; -import 'response.dart' as mock; - void main() { final http = MockHandler(); final client = JsonApiClient(http, RecommendedUrlDesign(Uri(path: '/'))); + group('Client', () { + // test('Can send request with a document', () { + // client(Request('post', CollectionTarget('apples'), (_) => _)) + // + // }); + + }); + group('Failure', () { test('RequestFailure', () async { http.response = mock.error422; @@ -181,8 +188,7 @@ void main() { test('Min', () async { http.response = mock.one; final response = - await client(Request.fetchRelationship('articles', '1', 'author')); - expect(response.relationship, isA()); + await client(Request.fetchOne('articles', '1', 'author')); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); @@ -191,11 +197,9 @@ void main() { test('Full', () async { http.response = mock.one; - final response = - await client(Request.fetchRelationship('articles', '1', 'author') - ..headers['foo'] = 'bar' - ..query['foo'] = 'bar'); - expect(response.relationship, isA()); + final response = await client(Request.fetchOne('articles', '1', 'author') + ..headers['foo'] = 'bar' + ..query['foo'] = 'bar'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1/relationships/author'); @@ -717,73 +721,4 @@ void main() { throwsFormatException); }); }); - -// group('Call', () { -// test('Sends correct request when given minimum arguments', () async { -// http.response = HttpResponse(204); -// final response = -// await client.call(Request('get'), Uri.parse('/foo')); -// expect(response, http.response); -// expect(http.request.method, 'get'); -// expect(http.request.uri.toString(), '/foo'); -// expect(http.request.headers, { -// 'accept': 'application/vnd.api+json', -// }); -// expect(http.request.body, isEmpty); -// }); -// -// test('Sends correct request when given all possible arguments', () async { -// http.response = HttpResponse(204); -// final response = await client.call( -// Request('get', document: { -// 'data': null -// }, headers: { -// 'foo': 'bar' -// }, include: [ -// 'author' -// ], fields: { -// 'author': ['name'] -// }, sort: [ -// 'title', -// '-date' -// ], page: { -// 'limit': '10' -// }, query: { -// 'foo': 'bar' -// }), -// Uri.parse('/foo')); -// expect(response, http.response); -// expect(http.request.method, 'get'); -// expect(http.request.uri.toString(), -// r'/foo?include=author&fields%5Bauthor%5D=name&sort=title%2C-date&page%5Blimit%5D=10&foo=bar'); -// expect(http.request.headers, { -// 'accept': 'application/vnd.api+json', -// 'content-type': 'application/vnd.api+json', -// 'foo': 'bar' -// }); -// expect(jsonDecode(http.request.body), {'data': null}); -// }); -// -// test('Throws RequestFailure', () async { -// http.response = mock.error422; -// try { -// await client.call(Request('get'), Uri.parse('/foo')); -// fail('Exception expected'); -// } on RequestFailure catch (e) { -// expect(e.response.statusCode, 422); -// expect(e.errors.first.status, '422'); -// } -// }); -// }); -} - -class MockHandler implements HttpHandler { - HttpResponse /*?*/ response; - HttpRequest /*?*/ request; - - @override - Future call(HttpRequest request) async { - this.request = request; - return response; - } } diff --git a/test/document/error_object_test.dart b/test/unit/document/error_object_test.dart similarity index 100% rename from test/document/error_object_test.dart rename to test/unit/document/error_object_test.dart diff --git a/test/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart similarity index 99% rename from test/document/inbound_document_test.dart rename to test/unit/document/inbound_document_test.dart index 19f13f64..b7b4b547 100644 --- a/test/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,8 +1,7 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/test/payload.dart' as payload; import 'package:test/test.dart'; -import 'payload.dart' as payload; - void main() { group('InboundDocument', () { group('Errors', () { diff --git a/test/document/link_test.dart b/test/unit/document/link_test.dart similarity index 100% rename from test/document/link_test.dart rename to test/unit/document/link_test.dart diff --git a/test/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart similarity index 100% rename from test/document/new_resource_test.dart rename to test/unit/document/new_resource_test.dart diff --git a/test/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart similarity index 96% rename from test/document/outbound_document_test.dart rename to test/unit/document/outbound_document_test.dart index 1809bc83..3c7aab78 100644 --- a/test/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -89,7 +89,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.one(One(book.identifier) + toObject(OutboundDataDocument.one(One(book.toIdentifier()) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -110,7 +110,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.many(Many([book.identifier]) + toObject(OutboundDataDocument.many(Many([book.toIdentifier()]) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), diff --git a/test/document/relationship_test.dart b/test/unit/document/relationship_test.dart similarity index 100% rename from test/document/relationship_test.dart rename to test/unit/document/relationship_test.dart diff --git a/test/document/resource_test.dart b/test/unit/document/resource_test.dart similarity index 100% rename from test/document/resource_test.dart rename to test/unit/document/resource_test.dart diff --git a/test/http/headers_test.dart b/test/unit/http/headers_test.dart similarity index 100% rename from test/http/headers_test.dart rename to test/unit/http/headers_test.dart diff --git a/test/http/logging_http_handler_test.dart b/test/unit/http/logging_http_handler_test.dart similarity index 64% rename from test/http/logging_http_handler_test.dart rename to test/unit/http/logging_http_handler_test.dart index 84e602b5..f610e648 100644 --- a/test/http/logging_http_handler_test.dart +++ b/test/unit/http/logging_http_handler_test.dart @@ -1,16 +1,18 @@ import 'package:json_api/http.dart'; -import 'package:json_api/src/http/last_value_logger.dart'; +import 'package:json_api/src/http/callback_http_logger.dart'; import 'package:test/test.dart'; void main() { test('Logging handler can log', () async { final rq = HttpRequest('get', Uri.parse('http://localhost')); final rs = HttpResponse(200, body: 'Hello'); - final log = LastValueLogger(); + final log = CallbackHttpLogger(onRequest: (r) { + expect(r, same(rq)); + }, onResponse: (r) { + expect(r, same(rs)); + }); final handler = LoggingHttpHandler(HttpHandler.fromFunction((_) async => rs), log); await handler(rq); - expect(log.request, same(rq)); - expect(log.response, same(rs)); }); } diff --git a/test/http/request_test.dart b/test/unit/http/request_test.dart similarity index 100% rename from test/http/request_test.dart rename to test/unit/http/request_test.dart diff --git a/test/query/fields_test.dart b/test/unit/query/fields_test.dart similarity index 100% rename from test/query/fields_test.dart rename to test/unit/query/fields_test.dart diff --git a/test/query/filter_test.dart b/test/unit/query/filter_test.dart similarity index 100% rename from test/query/filter_test.dart rename to test/unit/query/filter_test.dart diff --git a/test/query/include_test.dart b/test/unit/query/include_test.dart similarity index 99% rename from test/query/include_test.dart rename to test/unit/query/include_test.dart index ef91bcc9..5526d8eb 100644 --- a/test/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -28,4 +28,5 @@ void main() { expect(Include(['author', 'comments.author']).asQueryParameters, {'include': 'author,comments.author'}); }); + } diff --git a/test/query/page_test.dart b/test/unit/query/page_test.dart similarity index 100% rename from test/query/page_test.dart rename to test/unit/query/page_test.dart diff --git a/test/query/sort_test.dart b/test/unit/query/sort_test.dart similarity index 100% rename from test/query/sort_test.dart rename to test/unit/query/sort_test.dart diff --git a/test/routing/url_test.dart b/test/unit/routing/url_test.dart similarity index 100% rename from test/routing/url_test.dart rename to test/unit/routing/url_test.dart From b01c216a561e1d664e3ff8af9b24f86770bd46db Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 22 Nov 2020 18:41:24 -0800 Subject: [PATCH 81/99] WIP --- example/server.dart | 4 +- lib/client.dart | 3 +- lib/routing.dart | 1 + lib/server.dart | 2 +- lib/src/_demo/cors_handler.dart | 27 -- lib/src/_demo/demo_server.dart | 49 --- lib/src/client/dart_http.dart | 10 +- lib/src/client/json_api_client.dart | 324 ++++++++++++++++-- lib/src/client/json_api_request.dart | 20 -- lib/src/client/request.dart | 200 ++--------- lib/src/client/resource_response.dart | 8 +- lib/src/document/inbound_document.dart | 3 +- lib/src/http/http_request.dart | 7 +- lib/src/http/http_response.dart | 5 +- lib/src/query/fields.dart | 6 +- lib/src/query/include.dart | 3 +- lib/src/query/sort.dart | 3 +- lib/src/routing/recommended_url_design.dart | 2 - .../{_reference.dart => reference.dart} | 0 lib/src/routing/target.dart | 4 +- lib/src/server/_internal/cors_handler.dart | 27 ++ .../_internal}/dart_io_http_handler.dart | 4 +- lib/src/server/_internal/demo_server.dart | 56 +++ .../{_demo => server/_internal}/entity.dart | 0 .../_internal}/in_memory_repo.dart | 12 +- .../{ => _internal}/method_not_allowed.dart | 0 .../{ => _internal}/relationship_node.dart | 0 lib/src/{_demo => server/_internal}/repo.dart | 6 +- .../_internal}/repository_controller.dart | 43 ++- .../_internal/repository_error_converter.dart | 14 + .../routing_http_handler.dart} | 18 +- .../server/_internal/unmatched_target.dart | 5 + lib/src/server/chain_error_converter.dart | 19 + lib/src/server/controller.dart | 31 +- lib/src/server/error_converter.dart | 6 + lib/src/server/json_api_handler.dart | 42 --- lib/src/server/response.dart | 33 +- lib/src/server/routing_error_handler.dart | 20 ++ lib/src/server/try_catch_http_handler.dart | 27 ++ lib/src/test/response.dart | 34 +- test/contract/crud_test.dart | 143 ++++++++ test/contract/errors_test.dart | 58 ++++ test/contract/resource_creation_test.dart | 42 +++ test/contract/shared.dart | 19 + test/e2e/browser_test.dart | 36 +- test/e2e/hybrid_server.dart | 8 +- test/e2e/usecase_test.dart | 144 -------- test/integration_test.dart | 26 ++ test/shared.dart | 23 ++ test/unit/client/client_test.dart | 244 ++++++------- test/unit/document/inbound_document_test.dart | 14 + test/unit/document/resource_test.dart | 6 + test/unit/http/request_test.dart | 29 +- .../server/try_catch_http_handler_test.dart | 21 ++ 54 files changed, 1163 insertions(+), 728 deletions(-) delete mode 100644 lib/src/_demo/cors_handler.dart delete mode 100644 lib/src/_demo/demo_server.dart delete mode 100644 lib/src/client/json_api_request.dart rename lib/src/routing/{_reference.dart => reference.dart} (100%) create mode 100644 lib/src/server/_internal/cors_handler.dart rename lib/src/{_demo => server/_internal}/dart_io_http_handler.dart (91%) create mode 100644 lib/src/server/_internal/demo_server.dart rename lib/src/{_demo => server/_internal}/entity.dart (100%) rename lib/src/{_demo => server/_internal}/in_memory_repo.dart (87%) rename lib/src/server/{ => _internal}/method_not_allowed.dart (100%) rename lib/src/server/{ => _internal}/relationship_node.dart (100%) rename lib/src/{_demo => server/_internal}/repo.dart (90%) rename lib/src/{_demo => server/_internal}/repository_controller.dart (83%) create mode 100644 lib/src/server/_internal/repository_error_converter.dart rename lib/src/server/{router.dart => _internal/routing_http_handler.dart} (64%) create mode 100644 lib/src/server/_internal/unmatched_target.dart create mode 100644 lib/src/server/chain_error_converter.dart create mode 100644 lib/src/server/error_converter.dart delete mode 100644 lib/src/server/json_api_handler.dart create mode 100644 lib/src/server/routing_error_handler.dart create mode 100644 lib/src/server/try_catch_http_handler.dart create mode 100644 test/contract/crud_test.dart create mode 100644 test/contract/errors_test.dart create mode 100644 test/contract/resource_creation_test.dart create mode 100644 test/contract/shared.dart delete mode 100644 test/e2e/usecase_test.dart create mode 100644 test/integration_test.dart create mode 100644 test/shared.dart create mode 100644 test/unit/server/try_catch_http_handler_test.dart diff --git a/example/server.dart b/example/server.dart index 86115bf8..3a8d6233 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'package:json_api/http.dart'; -import 'package:json_api/src/_demo/demo_server.dart'; -import 'package:json_api/src/_demo/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/demo_server.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; Future main() async { final demo = DemoServer(InMemoryRepo(['users', 'posts', 'comments']), diff --git a/lib/client.dart b/lib/client.dart index 1cdd90e5..95d65b72 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,9 +1,8 @@ library json_api; -export 'package:json_api/src/client/json_api_request.dart'; +export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/request_failure.dart'; export 'package:json_api/src/client/collection_response.dart'; export 'package:json_api/src/client/relationship_response.dart'; diff --git a/lib/routing.dart b/lib/routing.dart index 51803c72..d707e384 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -3,6 +3,7 @@ library routing; export 'package:json_api/src/routing/recommended_url_design.dart'; +export 'package:json_api/src/routing/reference.dart'; export 'package:json_api/src/routing/target.dart'; export 'package:json_api/src/routing/target_matcher.dart'; export 'package:json_api/src/routing/uri_factory.dart'; diff --git a/lib/server.dart b/lib/server.dart index 559bcd75..777563fb 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,3 +1,3 @@ export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/json_api_handler.dart'; +export 'package:json_api/src/server/try_catch_http_handler.dart'; export 'package:json_api/src/server/response.dart'; diff --git a/lib/src/_demo/cors_handler.dart b/lib/src/_demo/cors_handler.dart deleted file mode 100644 index ddc10748..00000000 --- a/lib/src/_demo/cors_handler.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_api/http.dart'; - -class CorsHandler implements HttpHandler { - CorsHandler(this.wrapped, {this.origin = '*'}); - - final String origin; - - final HttpHandler wrapped; - - @override - Future call(HttpRequest request) async { - if (request.method == 'options') { - return HttpResponse(204, headers: { - 'Access-Control-Allow-Origin': request.headers['origin'] ?? origin, - 'Access-Control-Allow-Methods': - // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? - request.headers['Access-Control-Request-Method'].toUpperCase(), - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? '*', - }); - } - final response = await wrapped(request); - response.headers['Access-Control-Allow-Origin'] = - request.headers['origin'] ?? origin; - return response; - } -} diff --git a/lib/src/_demo/demo_server.dart b/lib/src/_demo/demo_server.dart deleted file mode 100644 index b1e3e57e..00000000 --- a/lib/src/_demo/demo_server.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/_demo/cors_handler.dart'; -import 'package:json_api/src/_demo/dart_io_http_handler.dart'; -import 'package:json_api/src/_demo/repo.dart'; -import 'package:json_api/src/_demo/repository_controller.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:uuid/uuid.dart'; - -class DemoServer { - DemoServer(Repo repo, - {this.host = 'localhost', - this.port = 8080, - HttpLogger logger = const CallbackHttpLogger(), - String Function() idGenerator, - bool exposeInternalErrors = false}) - : _handler = LoggingHttpHandler( - CorsHandler(JsonApiHandler( - RepositoryController(repo, idGenerator ?? Uuid().v4), - exposeInternalErrors: exposeInternalErrors)), - logger); - - final String host; - final int port; - final HttpHandler _handler; - - HttpServer _server; - - Uri get uri => Uri(scheme: 'http', host: host, port: port); - - Future start() async { - if (_server != null) return; - try { - _server = await HttpServer.bind(host, port); - unawaited(_server.forEach(DartIOHttpHandler(_handler))); - } on Exception { - await stop(); - rethrow; - } - } - - Future stop({bool force = false}) async { - if (_server == null) return; - await _server.close(force: force); - _server = null; - } -} diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart index 818a6be8..0f3ee035 100644 --- a/lib/src/client/dart_http.dart +++ b/lib/src/client/dart_http.dart @@ -7,21 +7,21 @@ class DartHttp implements HttpHandler { /// If [client] is passed, it will be used to keep a persistent connection. /// In this case it is your responsibility to call [Client.close]. /// If [client] is omitted, a new connection will be established for each call. - DartHttp({this.client}); + const DartHttp({Client client}) : _client = client; - final Client client; + final Client _client; @override Future call(HttpRequest request) async { final response = await _call(Request(request.method, request.uri) ..headers.addAll(request.headers) ..body = request.body); - return HttpResponse(response.statusCode, - body: response.body, headers: response.headers); + return HttpResponse(response.statusCode, body: response.body) + ..headers.addAll(response.headers); } Future _call(Request request) async { - if (client != null) return await _send(request, client); + if (_client != null) return await _send(request, _client); final tempClient = Client(); try { return await _send(request, tempClient); diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 2392ca29..77a6c87f 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,40 +1,310 @@ -import 'dart:convert'; - +import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/json_api_request.dart'; +import 'package:json_api/src/client/collection_response.dart'; +import 'package:json_api/src/client/new_resource_response.dart'; +import 'package:json_api/src/client/relationship_response.dart'; +import 'package:json_api/src/client/request.dart'; import 'package:json_api/src/client/request_failure.dart'; -import 'package:json_api/src/http/media_type.dart'; +import 'package:json_api/src/client/resource_response.dart'; +import 'package:json_api/src/client/response.dart'; /// The JSON:API client class JsonApiClient { - JsonApiClient(this._http, this._uriFactory); + JsonApiClient(this._uriFactory, {HttpHandler httpHandler}) + : _http = httpHandler ?? DartHttp(); final HttpHandler _http; final UriFactory _uriFactory; - // /// Adds identifiers to a to-many relationship - // RelationshipResponse addMany(String type, String id, - // String relationship, List identifiers) async => - // Request('post', RelationshipTarget(type, id, relationship), - // RelationshipResponse.decodeMany, - // document: OutboundDataDocument.many(Many(identifiers))); + /// Adds [identifiers] to a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future> addMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async => + RelationshipResponse.decodeMany(await send(Request( + 'post', RelationshipTarget(type, id, relationship), + document: OutboundDataDocument.many(Many(identifiers))) + ..headers.addAll(headers))); + /// Creates a new resource in the collection of type [type]. + /// The server is responsible for assigning the resource id. + /// + /// Optional arguments: + /// - [attributes] - resource attributes + /// - [one] - resource to-one relationships + /// - [many] - resource to-many relationships + /// - [meta] - resource meta data + /// - [resourceType] - resource type (if different from collection [type]) + /// - [headers] - any extra HTTP headers + Future createNew( + String type, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + String /*?*/ resourceType, + Map headers = const {}, + }) async => + NewResourceResponse.decode(await send(Request( + 'post', CollectionTarget(type), + document: + OutboundDataDocument.newResource(NewResource(resourceType ?? type) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers))); - /// Sends the [request] to the server. - /// Returns the response when the server responds with a JSON:API document. - /// Throws a [RequestFailure] if the server responds with an error. - Future call(JsonApiRequest request) async { - return request.response(await _call(request)); - } + /// Deletes [identifiers] from a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future> deleteMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async => + RelationshipResponse.decode(await send(Request( + 'delete', RelationshipTarget(type, id, relationship), + document: OutboundDataDocument.many(Many(identifiers))) + ..headers.addAll(headers))); + + /// Fetches a primary collection of type [type]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchCollection( + String type, { + Map headers = const {}, + Map query = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + }) async => + CollectionResponse.decode(await send( + Request('get', CollectionTarget(type)) + ..headers.addAll(headers) + ..query.addAll(query) + ..page.addAll(page) + ..filter.addAll(filter) + ..include.addAll(include) + ..sort.addAll(sort) + ..fields.addAll(fields))); + + /// Fetches a related resource collection + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchRelatedCollection( + String type, + String id, + String relationship, { + Map headers = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + Map query = const {}, + }) async => + CollectionResponse.decode(await send( + Request('get', RelatedTarget(type, id, relationship)) + ..headers.addAll(headers) + ..query.addAll(query) + ..page.addAll(page) + ..filter.addAll(filter) + ..include.addAll(include) + ..sort.addAll(sort) + ..fields.addAll(fields))); + + Future> fetchOne( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async => + RelationshipResponse.decodeOne(await send( + Request('get', RelationshipTarget(type, id, relationship)) + ..headers.addAll(headers) + ..query.addAll(query))); + + Future> fetchMany( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async => + RelationshipResponse.decodeMany(await send( + Request('get', RelationshipTarget(type, id, relationship)) + ..headers.addAll(headers) + ..query.addAll(query))); + + Future fetchRelatedResource( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + }) async => + ResourceResponse.decode(await send( + Request('get', RelatedTarget(type, id, relationship)) + ..headers.addAll(headers) + ..query.addAll(query) + ..filter.addAll(filter) + ..include.addAll(include) + ..fields.addAll(fields))); + + Future fetchResource( + String type, + String id, { + Map headers = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + Map query = const {}, + }) async => + ResourceResponse.decode(await send( + Request('get', ResourceTarget(type, id)) + ..headers.addAll(headers) + ..query.addAll(query) + ..filter.addAll(filter) + ..include.addAll(include) + ..fields.addAll(fields))); + + Future updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}}) async => + ResourceResponse.decode( + await send(Request('patch', ResourceTarget(type, id), + document: OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, One(value))), + ...many.map((key, value) => MapEntry(key, Many(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers))); + + /// Creates a new resource with the given id on the server. + Future create( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}, + }) async => + ResourceResponse.decode(await send(Request('post', CollectionTarget(type), + document: OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((k, v) => MapEntry(k, One(v))), + ...many.map((k, v) => MapEntry(k, Many(v))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers))); + + Future> replaceOne( + String type, + String id, + String relationship, + Identifier identifier, { + Map headers = const {}, + }) async => + RelationshipResponse.decodeOne(await send(Request( + 'patch', RelationshipTarget(type, id, relationship), + document: OutboundDataDocument.one(One(identifier))) + ..headers.addAll(headers))); + + Future> replaceMany( + String type, + String id, + String relationship, + Iterable identifiers, { + Map headers = const {}, + }) async => + RelationshipResponse.decodeMany(await send(Request( + 'patch', RelationshipTarget(type, id, relationship), + document: OutboundDataDocument.many(Many(identifiers))) + ..headers.addAll(headers))); + + Future> deleteOne( + String type, String id, String relationship, + {Map headers = const {}}) async => + RelationshipResponse.decodeOne(await send(Request( + 'patch', RelationshipTarget(type, id, relationship), + document: OutboundDataDocument.one(One.empty())) + ..headers.addAll(headers))); + + Future deleteResource(String type, String id) async => + Response.decode(await send(Request('delete', ResourceTarget(type, id)))); /// Sends the [request] to the server. - /// Returns the response when the server responds with a JSON:API document. /// Throws a [RequestFailure] if the server responds with an error. - Future _call(JsonApiRequest request) async { - final response = await _http.call(_toHttp(request)); - if (!response.isSuccessful && !response.isPending) { + Future send(Request request) async { + final query = { + ...Include(request.include).asQueryParameters, + ...Sort(request.sort).asQueryParameters, + ...Fields(request.fields).asQueryParameters, + ...Page(request.page).asQueryParameters, + ...Filter(request.filter).asQueryParameters, + ...request.query + }; + + final baseUri = request.target.map(_uriFactory); + final uri = + query.isEmpty ? baseUri : baseUri.replace(queryParameters: query); + + final headers = { + 'Accept': MediaType.jsonApi, + if (request.body.isNotEmpty) 'Content-Type': MediaType.jsonApi, + ...request.headers + }; + + final response = await _http.call( + HttpRequest(request.method, uri, body: request.body) + ..headers.addAll(headers)); + + if (response.isFailed) { throw RequestFailure(response, document: response.hasDocument ? InboundDocument.decode(response.body) @@ -42,16 +312,4 @@ class JsonApiClient { } return response; } - - HttpRequest _toHttp(JsonApiRequest request) { - final headers = {'accept': MediaType.jsonApi}; - var body = ''; - if (request.document != null) { - headers['content-type'] = MediaType.jsonApi; - body = jsonEncode(request.document); - } - headers.addAll(request.headers); - return HttpRequest(request.method, request.uri(_uriFactory), - body: body, headers: headers); - } } diff --git a/lib/src/client/json_api_request.dart b/lib/src/client/json_api_request.dart deleted file mode 100644 index 47894008..00000000 --- a/lib/src/client/json_api_request.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; - -/// An abstract request consumed by the client -abstract class JsonApiRequest { - /// HTTP method - String get method; - - /// The outbound document. Nullable. - Object /*?*/ get document; - - /// Any extra headers. - Map get headers; - - /// Returns the request URI - Uri uri(UriFactory uriFactory); - - /// Converts the HTTP response to the response object - T response(HttpResponse response); -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index ba938e2f..10b51b2b 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,185 +1,47 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/collection_response.dart'; -import 'package:json_api/src/client/json_api_request.dart'; -import 'package:json_api/src/client/new_resource_response.dart'; -import 'package:json_api/src/client/relationship_response.dart'; -import 'package:json_api/src/client/resource_response.dart'; -import 'package:json_api/src/client/response.dart'; - -/// A basic implementation of [JsonApiRequest]. -/// Allows to easily add query parameters. -/// Contains a collection of static factory methods for common JSON:API requests. -class Request implements JsonApiRequest { - Request(this.method, this.target, this.convert, {this.document}); - - /// Adds identifiers to a to-many relationship - static Request> addMany(String type, String id, - String relationship, List identifiers) => - Request('post', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeMany, - document: OutboundDataDocument.many(Many(identifiers))); - - /// Creates a new resource on the server. The server is responsible for assigning the resource id. - static Request createNew(String type, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}}) => - Request('post', CollectionTarget(type), NewResourceResponse.decode, - document: OutboundDataDocument.newResource(NewResource(type) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), - }) - ..meta.addAll(meta))); - - static Request> deleteMany(String type, String id, - String relationship, List identifiers) => - Request('delete', RelationshipTarget(type, id, relationship), - RelationshipResponse.decode, - document: OutboundDataDocument.many(Many(identifiers))); - - static Request fetchCollection(String type) => - Request('get', CollectionTarget(type), CollectionResponse.decode); - - static Request fetchRelatedCollection( - String type, String id, String relationship) => - Request('get', RelatedTarget(type, id, relationship), - CollectionResponse.decode); - - static Request fetchRelationship( - String type, String id, String relationship) => - Request('get', RelationshipTarget(type, id, relationship), - RelationshipResponse.decode); - - static Request> fetchOne( - String type, String id, String relationship) => - Request('get', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeOne); - - static Request> fetchMany( - String type, String id, String relationship) => - Request('get', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeMany); - - static Request fetchRelatedResource( - String type, String id, String relationship) => - Request('get', RelatedTarget(type, id, relationship), - ResourceResponse.decode); - - static Request fetchResource(String type, String id) => - Request('get', ResourceTarget(type, id), ResourceResponse.decode); +import 'dart:convert'; - static Request updateResource( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - }) => - Request('patch', ResourceTarget(type, id), ResourceResponse.decode, - document: OutboundDataDocument.resource(Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), - }) - ..meta.addAll(meta))); - - /// Creates a new resource with the given id on the server. - static Request create( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - }) => - Request('post', CollectionTarget(type), ResourceResponse.decode, - document: OutboundDataDocument.resource(Resource(type, id) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((k, v) => MapEntry(k, One(v))), - ...many.map((k, v) => MapEntry(k, Many(v))), - }) - ..meta.addAll(meta))); - - static Request> replaceOne( - String type, String id, String relationship, Identifier identifier) => - Request('patch', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeOne, - document: OutboundDataDocument.one(One(identifier))); - - static Request> replaceMany(String type, String id, - String relationship, Iterable identifiers) => - Request('patch', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeMany, - document: OutboundDataDocument.many(Many(identifiers))); +import 'package:json_api/routing.dart'; +import 'package:json_api/src/nullable.dart'; - static Request> deleteOne( - String type, String id, String relationship) => - Request('patch', RelationshipTarget(type, id, relationship), - RelationshipResponse.decodeOne, - document: OutboundDataDocument.one(One.empty())); +/// JSON:API request consumed by the client +class Request { + Request(this.method, this.target, {Object document}) + : body = nullable(jsonEncode)(document) ?? ''; - static JsonApiRequest deleteResource(String type, String id) => - Request('delete', ResourceTarget(type, id), Response.decode); + /// HTTP method + final String method; /// Request target final Target target; - @override - final String method; - - @override - final Object document; - - final T Function(HttpResponse response) convert; + /// Encoded document or an empty string. + final String body; - @override + /// Any extra HTTP headers. final headers = {}; - @override - Uri uri(TargetMapper urls) { - final path = target.map(urls); - return query.isEmpty - ? path - : path.replace(queryParameters: {...path.queryParameters, ...query}); - } - - /// URL Query String parameters - final query = {}; - - /// Adds the request to include the [related] resources to the [query]. - void include(Iterable related) { - query.addAll(Include(related).asQueryParameters); - } + /// A list of dot-separated relationships to include. + /// See https://jsonapi.org/format/#fetching-includes + final include = []; - /// Adds the request for the sparse [fields] to the [query]. - void fields(Map> fields) { - query.addAll(Fields(fields).asQueryParameters); - } + /// Sorting parameters. + /// See https://jsonapi.org/format/#fetching-sorting + final sort = []; - /// Adds the request for pagination to the [query]. - void page(Map page) { - query.addAll(Page(page).asQueryParameters); - } + /// Sparse fieldsets. + /// See https://jsonapi.org/format/#fetching-sparse-fieldsets + final fields = >{}; - /// Adds the filter parameters to the [query]. - void filter(Map page) { - query.addAll(Filter(page).asQueryParameters); - } + /// Pagination parameters. + /// See https://jsonapi.org/format/#fetching-pagination + final page = {}; - /// Adds the request for page sorting to the [query]. - void sort(Iterable fields) { - query.addAll(Sort(fields).asQueryParameters); - } + /// Response filtering. + /// https://jsonapi.org/format/#fetching-filtering + final filter = {}; - @override - T response(HttpResponse response) => convert(response); + /// Any general query parameters. + /// If passed, this parameter will override other parameters set through + /// [include], [sort], [fields], [page], and [filter]. + final query = {}; } diff --git a/lib/src/client/resource_response.dart b/lib/src/client/resource_response.dart index 19057b74..90baea33 100644 --- a/lib/src/client/resource_response.dart +++ b/lib/src/client/resource_response.dart @@ -10,10 +10,14 @@ class ResourceResponse { this.links.addAll(links); } + ResourceResponse.noContent(this.http) + : resource = null, + included = IdentityCollection(const []); + static ResourceResponse decode(HttpResponse response) { - if (response.isNoContent) return ResourceResponse(response, null); + if (response.isNoContent) return ResourceResponse.noContent(response); final doc = InboundDocument.decode(response.body); - return ResourceResponse(response, doc.resource(), + return ResourceResponse(response, doc.nullableResource(), links: doc.links, included: doc.included); } diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index ad8d8fc5..a2b8c500 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -157,14 +157,13 @@ extension _TypedGetter on Map { throw FormatException('Key "$key" does not exist'); } - T /*?*/ getNullable(String key, {T /*?*/ Function() /*?*/ orGet}) { + T /*?*/ getNullable(String key) { if (containsKey(key)) { final val = this[key]; if (val is T || val == null) return val; throw FormatException( 'Key "$key": expected $T, found ${val.runtimeType}'); } - if (orGet != null) return orGet(); throw FormatException('Key "$key" does not exist'); } } diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 321f33b2..36af87f8 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -2,11 +2,8 @@ import 'package:json_api/src/http/headers.dart'; /// The request which is sent by the client and received by the server class HttpRequest { - HttpRequest(String method, this.uri, - {this.body = '', Map headers = const {}}) - : method = method.toLowerCase() { - this.headers.addAll(headers); - } + HttpRequest(String method, this.uri, {this.body = ''}) + : method = method.toLowerCase(); /// Requested URI final Uri uri; diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index d2b972f9..66cfb14b 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -3,10 +3,7 @@ import 'package:json_api/src/http/media_type.dart'; /// The response sent by the server and received by the client class HttpResponse { - HttpResponse(this.statusCode, - {this.body = '', Map headers = const {}}) { - this.headers.addAll(headers); - } + HttpResponse(this.statusCode, {this.body = ''}); /// Response status code final int statusCode; diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 02621afd..843b001e 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -2,14 +2,14 @@ import 'dart:collection'; /// Query parameters defining Sparse Fieldsets /// @see https://jsonapi.org/format/#fetching-sparse-fieldsets -class Fields with MapMixin> { +class Fields with MapMixin> { /// The [fields] argument maps the resource type to a list of fields. /// /// Example: /// ```dart /// Fields({'articles': ['title', 'body'], 'people': ['name']}); /// ``` - Fields([Map> fields = const {}]) { + Fields([Map> fields = const {}]) { addAll(fields); } @@ -29,7 +29,7 @@ class Fields with MapMixin> { _map.map((k, v) => MapEntry('fields[$k]', v.join(','))); @override - void operator []=(String key, List value) => _map[key] = value; + void operator []=(String key, Iterable value) => _map[key] = value; @override void clear() => _map.clear(); diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index a2fb7d63..8b25bdb9 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -17,7 +17,8 @@ class Include with IterableMixin { final _ = []; /// Converts to a map of query parameters - Map get asQueryParameters => {'include': join(',')}; + Map get asQueryParameters => + {if (isNotEmpty) 'include': join(',')}; @override Iterator get iterator => _.iterator; diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index bae7877d..85200cc5 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -19,7 +19,8 @@ class Sort with IterableMixin { final _ = []; /// Converts to a map of query parameters - Map get asQueryParameters => {'sort': join(',')}; + Map get asQueryParameters => + {if (isNotEmpty) 'sort': join(',')}; @override int get length => _.length; diff --git a/lib/src/routing/recommended_url_design.dart b/lib/src/routing/recommended_url_design.dart index 4d1fceed..66d74c4c 100644 --- a/lib/src/routing/recommended_url_design.dart +++ b/lib/src/routing/recommended_url_design.dart @@ -6,8 +6,6 @@ import 'package:json_api/src/routing/target_matcher.dart'; class RecommendedUrlDesign implements UriFactory, TargetMatcher { /// Creates an instance of RecommendedUrlDesign. /// The [base] URI will be used as a prefix for the generated URIs. - /// - /// To generate URIs without a hostname, pass `Uri(path: '/')` as [base]. const RecommendedUrlDesign(this.base); /// A "path only" version of the recommended URL design, e.g. diff --git a/lib/src/routing/_reference.dart b/lib/src/routing/reference.dart similarity index 100% rename from lib/src/routing/_reference.dart rename to lib/src/routing/reference.dart diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 573b5312..76a4b967 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,11 +1,11 @@ -import 'package:json_api/src/routing/_reference.dart'; +import 'package:json_api/src/routing/reference.dart'; /// A request target abstract class Target { /// Targeted resource type String get type; - T map(TargetMapper mapper) => mapper.collection(this); + T map(TargetMapper mapper); } abstract class TargetMapper { diff --git a/lib/src/server/_internal/cors_handler.dart b/lib/src/server/_internal/cors_handler.dart new file mode 100644 index 00000000..9cd2182d --- /dev/null +++ b/lib/src/server/_internal/cors_handler.dart @@ -0,0 +1,27 @@ +import 'package:json_api/http.dart'; + +class CorsHandler implements HttpHandler { + CorsHandler(this.wrapped, {this.origin = '*'}); + + final String origin; + + final HttpHandler wrapped; + + @override + Future call(HttpRequest request) async { + if (request.method == 'options') { + return HttpResponse(204) + ..headers.addAll({ + 'Access-Control-Allow-Origin': request.headers['origin'] ?? origin, + 'Access-Control-Allow-Methods': + // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? + request.headers['Access-Control-Request-Method'].toUpperCase(), + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? '*', + }); + } + return await wrapped(request) + ..headers['Access-Control-Allow-Origin'] = + request.headers['origin'] ?? origin; + } +} diff --git a/lib/src/_demo/dart_io_http_handler.dart b/lib/src/server/_internal/dart_io_http_handler.dart similarity index 91% rename from lib/src/_demo/dart_io_http_handler.dart rename to lib/src/server/_internal/dart_io_http_handler.dart index a35d8237..b6b200fd 100644 --- a/lib/src/_demo/dart_io_http_handler.dart +++ b/lib/src/server/_internal/dart_io_http_handler.dart @@ -24,8 +24,8 @@ class DartIOHttpHandler { Future _convertRequest(io.HttpRequest ioRequest) async => HttpRequest(ioRequest.method, ioRequest.requestedUri, - body: await _readBody(ioRequest), - headers: _convertHeaders(ioRequest.headers)); + body: await _readBody(ioRequest)) + ..headers.addAll(_convertHeaders(ioRequest.headers)); Future _readBody(io.HttpRequest ioRequest) => ioRequest.cast>().transform(utf8.decoder).join(); diff --git a/lib/src/server/_internal/demo_server.dart b/lib/src/server/_internal/demo_server.dart new file mode 100644 index 00000000..9836efbd --- /dev/null +++ b/lib/src/server/_internal/demo_server.dart @@ -0,0 +1,56 @@ +import 'dart:io'; + +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_handler.dart'; +import 'package:json_api/src/server/_internal/dart_io_http_handler.dart'; +import 'package:json_api/src/server/_internal/repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/repository_error_converter.dart'; +import 'package:json_api/src/server/_internal/routing_http_handler.dart'; +import 'package:json_api/src/server/chain_error_converter.dart'; +import 'package:json_api/src/server/routing_error_handler.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:uuid/uuid.dart'; + +class DemoServer { + DemoServer( + Repo repo, { + this.host = 'localhost', + this.port = 8080, + HttpLogger logger = const CallbackHttpLogger(), + String Function() idGenerator, + }) : _handler = LoggingHttpHandler( + CorsHandler(TryCatchHttpHandler( + RoutingHttpHandler( + RepositoryController(repo, idGenerator ?? Uuid().v4)), + ChainErrorConverter( + [RepositoryErrorConverter(), RoutingErrorHandler()]), + )), + logger); + + final String host; + final int port; + final HttpHandler _handler; + + HttpServer _server; + + Uri get uri => Uri(scheme: 'http', host: host, port: port); + + Future start() async { + if (_server != null) return; + try { + _server = await HttpServer.bind(host, port); + unawaited(_server.forEach(DartIOHttpHandler(_handler))); + } on Exception { + await stop(); + rethrow; + } + } + + Future stop({bool force = false}) async { + if (_server == null) return; + await _server.close(force: force); + _server = null; + } +} diff --git a/lib/src/_demo/entity.dart b/lib/src/server/_internal/entity.dart similarity index 100% rename from lib/src/_demo/entity.dart rename to lib/src/server/_internal/entity.dart diff --git a/lib/src/_demo/in_memory_repo.dart b/lib/src/server/_internal/in_memory_repo.dart similarity index 87% rename from lib/src/_demo/in_memory_repo.dart rename to lib/src/server/_internal/in_memory_repo.dart index 34d3d021..8503eb7b 100644 --- a/lib/src/_demo/in_memory_repo.dart +++ b/lib/src/server/_internal/in_memory_repo.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/_demo/repo.dart'; +import 'repo.dart'; class InMemoryRepo implements Repo { InMemoryRepo(Iterable types) { @@ -10,9 +10,13 @@ class InMemoryRepo implements Repo { final _storage = >{}; @override - Stream> fetchAll(String type) { - return Stream.fromIterable(_storage[type].entries) - .map((_) => Entity(_.key, _.value)); + Stream> fetchCollection(String type) async* { + if (!_storage.containsKey(type)) { + throw CollectionNotFound(); + } + for (final e in _storage[type].entries) { + yield Entity(e.key, e.value); + } } @override diff --git a/lib/src/server/method_not_allowed.dart b/lib/src/server/_internal/method_not_allowed.dart similarity index 100% rename from lib/src/server/method_not_allowed.dart rename to lib/src/server/_internal/method_not_allowed.dart diff --git a/lib/src/server/relationship_node.dart b/lib/src/server/_internal/relationship_node.dart similarity index 100% rename from lib/src/server/relationship_node.dart rename to lib/src/server/_internal/relationship_node.dart diff --git a/lib/src/_demo/repo.dart b/lib/src/server/_internal/repo.dart similarity index 90% rename from lib/src/_demo/repo.dart rename to lib/src/server/_internal/repo.dart index 6a159af3..d13db28f 100644 --- a/lib/src/_demo/repo.dart +++ b/lib/src/server/_internal/repo.dart @@ -1,5 +1,7 @@ abstract class Repo { - Stream> fetchAll(String type); + /// Fetches a collection. + /// Throws [CollectionNotFound]. + Stream> fetchCollection(String type); Future fetch(String type, String id); @@ -31,6 +33,8 @@ abstract class Repo { String type, String id, String relationship, Iterable refs); } +class CollectionNotFound implements Exception {} + class Entity { const Entity(this.id, this.model); diff --git a/lib/src/_demo/repository_controller.dart b/lib/src/server/_internal/repository_controller.dart similarity index 83% rename from lib/src/_demo/repository_controller.dart rename to lib/src/server/_internal/repository_controller.dart index 6b743606..e09deaa0 100644 --- a/lib/src/_demo/repository_controller.dart +++ b/lib/src/server/_internal/repository_controller.dart @@ -2,13 +2,13 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/_demo/repo.dart'; import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/_internal/relationship_node.dart'; +import 'package:json_api/src/server/_internal/repo.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/relationship_node.dart'; import 'package:json_api/src/server/response.dart'; -class RepositoryController implements JsonApiController> { +class RepositoryController implements Controller { RepositoryController(this.repo, this.getId); final Repo repo; @@ -60,7 +60,7 @@ class RepositoryController implements JsonApiController> { ..links['self'] = self; return Response.created( OutboundDataDocument.resource(resource)..links['self'] = self, - location: self.uri.toString()); + self.uri.toString()); } @override @@ -129,7 +129,7 @@ class RepositoryController implements JsonApiController> { @override Future fetchRelationship( - HttpRequest rq, RelationshipTarget target) async { + HttpRequest request, RelationshipTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final doc = OutboundDataDocument.one( @@ -145,6 +145,26 @@ class RepositoryController implements JsonApiController> { throw UnimplementedError(); } + @override + Future fetchRelated( + HttpRequest request, RelatedTarget target) async { + final model = await repo.fetch(target.type, target.id); + if (model.one.containsKey(target.relationship)) { + final related = + await _fetchRelatedResource(model.one[target.relationship]); + final doc = OutboundDataDocument.resource(related); + return Response.ok(doc); + } + if (model.many.containsKey(target.relationship)) { + final doc = OutboundDataDocument.collection( + await _fetchRelatedCollection(model.many[target.relationship]) + .toList()); + return Response.ok(doc); + } + // TODO: implement fetchRelated + throw UnimplementedError(); + } + /// Returns a stream of related resources recursively Stream _getAllRelated( Resource resource, Iterable forest) async* { @@ -172,7 +192,7 @@ class RepositoryController implements JsonApiController> { } Stream _fetchAll(String type) => - repo.fetchAll(type).map((e) => _toResource(e.id, type, e.model)); + repo.fetchCollection(type).map((e) => _toResource(e.id, type, e.model)); /// Fetches and builds a resource object Future _fetchResource(String type, String id) async { @@ -181,6 +201,17 @@ class RepositoryController implements JsonApiController> { return _toResource(id, type, model); } + Future _fetchRelatedResource(String key) { + final id = Identifier.fromKey(key); + return _fetchLinkedResource(id.type, id.id); + } + + Stream _fetchRelatedCollection(Iterable keys) async* { + for (final key in keys) { + yield await _fetchRelatedResource(key); + } + } + Resource _toResource(String id, String type, Model model) { final res = Resource(type, id); model.attributes.forEach((key, value) { diff --git a/lib/src/server/_internal/repository_error_converter.dart b/lib/src/server/_internal/repository_error_converter.dart new file mode 100644 index 00000000..da3d9beb --- /dev/null +++ b/lib/src/server/_internal/repository_error_converter.dart @@ -0,0 +1,14 @@ +import 'package:json_api/src/http/http_response.dart'; +import 'package:json_api/src/server/_internal/repo.dart'; +import 'package:json_api/src/server/error_converter.dart'; +import 'package:json_api/src/server/response.dart'; + +class RepositoryErrorConverter implements ErrorConverter { + @override + Future convert(Object error) async { + if (error is CollectionNotFound) { + return Response.notFound(); + } + return null; + } +} diff --git a/lib/src/server/router.dart b/lib/src/server/_internal/routing_http_handler.dart similarity index 64% rename from lib/src/server/router.dart rename to lib/src/server/_internal/routing_http_handler.dart index 55e1dc67..1410c968 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/_internal/routing_http_handler.dart @@ -1,14 +1,18 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/method_not_allowed.dart'; +import 'package:json_api/src/server/_internal/method_not_allowed.dart'; +import 'package:json_api/src/server/_internal/unmatched_target.dart'; -class Router { - const Router(this.matcher); +class RoutingHttpHandler implements HttpHandler { + RoutingHttpHandler(this.controller, {TargetMatcher matcher}) + : matcher = matcher ?? RecommendedUrlDesign.pathOnly; + final Controller controller; final TargetMatcher matcher; - T route(HttpRequest rq, JsonApiController controller) { + @override + Future call(HttpRequest rq) async { final target = matcher.match(rq.uri); if (target is CollectionTarget) { if (rq.isGet) return controller.fetchCollection(rq, target); @@ -28,6 +32,10 @@ class Router { if (rq.isDelete) return controller.deleteMany(rq, target); throw MethodNotAllowed(rq.method); } - throw 'UnmatchedTarget $target'; + if (target is RelatedTarget) { + if (rq.isGet) return controller.fetchRelated(rq, target); + throw MethodNotAllowed(rq.method); + } + throw UnmatchedTarget(rq.uri); } } diff --git a/lib/src/server/_internal/unmatched_target.dart b/lib/src/server/_internal/unmatched_target.dart new file mode 100644 index 00000000..c3ce1fa0 --- /dev/null +++ b/lib/src/server/_internal/unmatched_target.dart @@ -0,0 +1,5 @@ +class UnmatchedTarget implements Exception { + UnmatchedTarget(this.uri); + + final Uri uri; +} diff --git a/lib/src/server/chain_error_converter.dart b/lib/src/server/chain_error_converter.dart new file mode 100644 index 00000000..17101846 --- /dev/null +++ b/lib/src/server/chain_error_converter.dart @@ -0,0 +1,19 @@ +import 'package:json_api/src/http/http_response.dart'; +import 'package:json_api/src/server/error_converter.dart'; + +class ChainErrorConverter implements ErrorConverter { + ChainErrorConverter(Iterable chain) { + _chain.addAll(chain); + } + + final _chain = []; + + @override + Future convert(Object error) async { + for (final h in _chain) { + final r = await h.convert(error); + if (r != null) return r; + } + return null; + } +} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 3bf5a438..e3ae6b5d 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,31 +1,42 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -abstract class JsonApiController { +abstract class Controller { /// Fetch a primary resource collection - T fetchCollection(HttpRequest request, CollectionTarget target); + Future fetchCollection( + HttpRequest request, CollectionTarget target); /// Create resource - T createResource(HttpRequest request, CollectionTarget target); + Future createResource( + HttpRequest request, CollectionTarget target); /// Fetch a single primary resource - T fetchResource(HttpRequest request, ResourceTarget target); + Future fetchResource( + HttpRequest request, ResourceTarget target); /// Updates a primary resource - T updateResource(HttpRequest request, ResourceTarget target); + Future updateResource( + HttpRequest request, ResourceTarget target); /// Deletes the primary resource - T deleteResource(HttpRequest request, ResourceTarget target); + Future deleteResource( + HttpRequest request, ResourceTarget target); /// Fetches a relationship - T fetchRelationship(HttpRequest rq, RelationshipTarget target ); + Future fetchRelationship( + HttpRequest rq, RelationshipTarget target); /// Add new entries to a to-many relationship - T addMany(HttpRequest request, RelationshipTarget target); + Future addMany(HttpRequest request, RelationshipTarget target); /// Updates the relationship - T replaceRelationship(HttpRequest request, RelationshipTarget target); + Future replaceRelationship( + HttpRequest request, RelationshipTarget target); /// Deletes the members from the to-many relationship - T deleteMany(HttpRequest request, RelationshipTarget target); + Future deleteMany( + HttpRequest request, RelationshipTarget target); + + /// Fetches related resource or collection + Future fetchRelated(HttpRequest request, RelatedTarget target); } diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart new file mode 100644 index 00000000..73bca880 --- /dev/null +++ b/lib/src/server/error_converter.dart @@ -0,0 +1,6 @@ +import 'package:json_api/http.dart'; + +/// Converts errors to HTTP responses. +abstract class ErrorConverter { + Future convert(Object error); +} diff --git a/lib/src/server/json_api_handler.dart b/lib/src/server/json_api_handler.dart deleted file mode 100644 index 423f0595..00000000 --- a/lib/src/server/json_api_handler.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/method_not_allowed.dart'; -import 'package:json_api/src/server/router.dart'; - -class JsonApiHandler implements HttpHandler { - JsonApiHandler(this.controller, - {TargetMatcher matcher, - UriFactory urlDesign, - this.exposeInternalErrors = false}) - : router = Router(matcher ?? RecommendedUrlDesign.pathOnly); - - final JsonApiController> controller; - final Router router; - final bool exposeInternalErrors; - - /// Handles the request by calling the appropriate method of the controller - @override - Future call(HttpRequest request) async { - try { - return await router.route(request, controller); - } on MethodNotAllowed { - return HttpResponse(405); - } catch (e) { - var body = ''; - if (exposeInternalErrors) { - final error = ErrorObject( - title: 'Uncaught exception', detail: e.toString(), status: '500'); - error.meta['runtimeType'] = e.runtimeType.toString(); - if (e is Error) { - error.meta['stackTrace'] = e.stackTrace.toString().trim().split('\n'); - } - body = jsonEncode(OutboundErrorDocument([error])); - } - return HttpResponse(500, body: body); - } - } -} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index d4b57f03..7baf4c34 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,26 +1,31 @@ import 'dart:convert'; import 'package:json_api/http.dart'; -import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/nullable.dart'; /// JSON:API response class Response extends HttpResponse { - Response(int statusCode, - {Object /*?*/ document, Map headers = const {}}) - : super(statusCode, body: nullable(jsonEncode)(document) ?? '', headers: { - ...headers, - if (document != null) 'content-type': MediaType.jsonApi - }); + Response(int statusCode, {Object /*?*/ document}) + : super(statusCode, body: nullable(jsonEncode)(document) ?? '') { + if (body.isNotEmpty) headers['content-type'] = MediaType.jsonApi; + } - Response.ok(Object document) : this(200, document: document); + static Response ok(Object document) => Response(200, document: document); - Response.noContent() : this(204); + static Response noContent() => Response(204); - Response.notFound({Object /*?*/ document}) : this(404, document: document); + static Response created(Object document, String location) => + Response(201, document: document)..headers['location'] = location; - Response.created(Object document, {String location = ''}) - : this(201, - document: document, - headers: {if (location.isNotEmpty) 'location': location}); + static Response notFound({Object /*?*/ document}) => + Response(404, document: document); + + static Response methodNotAllowed({Object /*?*/ document}) => + Response(405, document: document); + + static Response badRequest({Object /*?*/ document}) => + Response(400, document: document); + + static Response internalServerError({Object /*?*/ document}) => + Response(500, document: document); } diff --git a/lib/src/server/routing_error_handler.dart b/lib/src/server/routing_error_handler.dart new file mode 100644 index 00000000..402b7910 --- /dev/null +++ b/lib/src/server/routing_error_handler.dart @@ -0,0 +1,20 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/server/_internal/method_not_allowed.dart'; +import 'package:json_api/src/server/_internal/unmatched_target.dart'; +import 'package:json_api/src/server/error_converter.dart'; +import 'package:json_api/src/server/response.dart'; + +class RoutingErrorHandler implements ErrorConverter { + const RoutingErrorHandler(); + + @override + Future convert(Object error) async { + if (error is MethodNotAllowed) { + return Response.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return Response.badRequest(); + } + return null; + } +} diff --git a/lib/src/server/try_catch_http_handler.dart b/lib/src/server/try_catch_http_handler.dart new file mode 100644 index 00000000..0235d8d8 --- /dev/null +++ b/lib/src/server/try_catch_http_handler.dart @@ -0,0 +1,27 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/server/error_converter.dart'; +import 'package:json_api/src/server/response.dart'; + +/// Calls the wrapped handler within a try-catch block. +/// When an [HttpResponse] is thrown, returns it. +/// When any other error is thrown, ties to convert it using [ErrorConverter], +/// or returns an HTTP 500. +class TryCatchHttpHandler implements HttpHandler { + TryCatchHttpHandler(this.httpHandler, this.errorConverter); + + final HttpHandler httpHandler; + final ErrorConverter errorConverter; + + /// Handles the request by calling the appropriate method of the controller + @override + Future call(HttpRequest request) async { + try { + return await httpHandler(request); + } on HttpResponse catch (response) { + return response; + } catch (error) { + return (await errorConverter.convert(error)) ?? + Response.internalServerError(); + } + } +} diff --git a/lib/src/test/response.dart b/lib/src/test/response.dart index eb5c957e..10213a0f 100644 --- a/lib/src/test/response.dart +++ b/lib/src/test/response.dart @@ -1,18 +1,17 @@ import 'dart:convert'; -import 'package:json_api/src/http/media_type.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/http/media_type.dart'; final collectionMin = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'data': [ {'type': 'articles', 'id': '1'} ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final collectionFull = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': { 'self': 'http://example.com/articles', @@ -80,10 +79,10 @@ final collectionFull = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final primaryResource = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': {'self': 'http://example.com/articles/1'}, 'data': { @@ -130,15 +129,15 @@ final primaryResource = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final relatedResourceNull = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': {'self': 'http://example.com/articles/1/author'}, 'data': null - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final one = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': { 'self': '/articles/1/relationships/author', @@ -179,10 +178,10 @@ final one = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final oneEmpty = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': { 'self': '/articles/1/relationships/author', @@ -223,10 +222,10 @@ final oneEmpty = HttpResponse(200, 'links': {'self': 'http://example.com/comments/12'} } ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final many = HttpResponse(200, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'links': { 'self': '/articles/1/relationships/tags', @@ -235,12 +234,12 @@ final many = HttpResponse(200, 'data': [ {'type': 'tags', 'id': '12'} ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final noContent = HttpResponse(204); final error422 = HttpResponse(422, - headers: {'Content-Type': MediaType.jsonApi}, body: jsonEncode({ 'errors': [ { @@ -250,6 +249,7 @@ final error422 = HttpResponse(422, 'detail': 'First name must contain at least three characters.' } ] - })); + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); final error500 = HttpResponse(500); diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart new file mode 100644 index 00000000..bcbf74ce --- /dev/null +++ b/test/contract/crud_test.dart @@ -0,0 +1,143 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import 'shared.dart'; + +void main() { + HttpHandler server; + JsonApiClient client; + + setUp(() async { + server = initServer(); + client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + }); + + group('CRUD', () { + Resource alice; + Resource bob; + Resource post; + Resource comment; + Resource secretComment; + + setUp(() async { + alice = (await client.createNew('users', attributes: {'name': 'Alice'})) + .resource; + bob = (await client.createNew('users', attributes: {'name': 'Bob'})) + .resource; + post = (await client.createNew('posts', + attributes: {'title': 'Hello world'}, + one: {'author': alice.toIdentifier()})) + .resource; + comment = (await client.createNew('comments', + attributes: {'text': 'Hi Alice'}, + one: {'author': bob.toIdentifier()})) + .resource; + secretComment = (await client.createNew('comments', + attributes: {'text': 'Secret comment'}, + one: {'author': bob.toIdentifier()})) + .resource; + await client + .addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); + }); + + test('Fetch a complex resource', () async { + final response = await client.fetchCollection('posts', + include: ['author', 'comments', 'comments.author']); + + expect(response.http.statusCode, 200); + expect(response.collection.length, 1); + expect(response.included.length, 3); + + final fetchedPost = response.collection.first; + expect(fetchedPost.attributes['title'], 'Hello world'); + + final fetchedAuthor = response.included[fetchedPost.one('author').key]; + expect(fetchedAuthor.attributes['name'], 'Alice'); + + final fetchedComment = + response.included[fetchedPost.many('comments').single.key]; + expect(fetchedComment.attributes['text'], 'Hi Alice'); + }); + + test('Delete a resource', () async { + await client.deleteResource(post.type, post.id); + await client.fetchCollection('posts').then((r) { + expect(r.collection, isEmpty); + }); + }); + + test('Update a resource', () async { + await client.updateResource(post.type, post.id, + attributes: {'title': 'Bob was here'}); + await client.fetchCollection('posts').then((r) { + expect(r.collection.single.attributes['title'], 'Bob was here'); + }); + }); + + test('Fetch a related resource', () async { + await client.fetchRelatedResource(post.type, post.id, 'author').then((r) { + expect(r.resource.attributes['name'], 'Alice'); + }); + }); + + test('Fetch a related collection', () async { + await client + .fetchRelatedCollection(post.type, post.id, 'comments') + .then((r) { + expect(r.collection.single.attributes['text'], 'Hi Alice'); + }); + }); + + test('Fetch a to-one relationship', () async { + await client.fetchOne(post.type, post.id, 'author').then((r) { + expect(r.relationship.identifier.id, alice.id); + }); + }); + + test('Fetch a to-many relationship', () async { + await client.fetchMany(post.type, post.id, 'comments').then((r) { + expect(r.relationship.single.id, comment.id); + }); + }); + + test('Delete a to-one relationship', () async { + await client.deleteOne(post.type, post.id, 'author'); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { + expect(r.resource.one('author'), isEmpty); + }); + }); + + test('Replace a to-one relationship', () async { + await client.replaceOne(post.type, post.id, 'author', bob.toIdentifier()); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { + expect( + r.included[r.resource.one('author').key].attributes['name'], 'Bob'); + }); + }); + + test('Delete from a to-many relationship', () async { + await client + .deleteMany(post.type, post.id, 'comments', [comment.toIdentifier()]); + await client.fetchResource(post.type, post.id).then((r) { + expect(r.resource.many('comments'), isEmpty); + }); + }); + + test('Replace a to-many relationship', () async { + await client.replaceMany( + post.type, post.id, 'comments', [secretComment.toIdentifier()]); + await client + .fetchResource(post.type, post.id, include: ['comments']).then((r) { + expect( + r.included[r.resource.many('comments').single.key] + .attributes['text'], + 'Secret comment'); + }); + }); + }); +} diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart new file mode 100644 index 00000000..77709223 --- /dev/null +++ b/test/contract/errors_test.dart @@ -0,0 +1,58 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import 'shared.dart'; + +void main() { + HttpHandler server; + JsonApiClient client; + + setUp(() async { + server = initServer(); + client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + }); + + group('Errors', () { + test('Method not allowed', () async { + final badRequests = [ + Request('delete', CollectionTarget('posts')), + Request('post', ResourceTarget('posts', '1')), + Request('post', RelatedTarget('posts', '1', 'author')), + Request('head', RelationshipTarget('posts', '1', 'author')), + ]; + for (final request in badRequests) { + try { + await client.send(request); + fail('Exception expected'); + } on RequestFailure catch (response) { + expect(response.http.statusCode, 405); + } + } + }); + test('Bad request when target can not be matched', () async { + try { + await JsonApiClient(RecommendedUrlDesign(Uri.parse('/a/long/prefix/')), + httpHandler: server) + .fetchCollection('posts'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 400); + } + }); + test('404', () async { + final actions = [ + () => client.fetchCollection('unicorns') + ]; + for (final action in actions) { + try { + await action(); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + } + } + }); + }); +} diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart new file mode 100644 index 00000000..8fc183e5 --- /dev/null +++ b/test/contract/resource_creation_test.dart @@ -0,0 +1,42 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +import 'shared.dart'; + +void main() { + HttpHandler server; + JsonApiClient client; + + setUp(() async { + server = initServer(); + client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + }); + + group('Resource creation', () { + test('Resource id assigned on the server', () async { + await client + .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { + expect(r.http.statusCode, 201); + // TODO: Why does "Location" header not work in browsers? + expect(r.http.headers['location'], '/posts/${r.resource.id}'); + expect(r.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); + expect(r.resource.id, isNotEmpty); + expect(r.resource.attributes['title'], 'Hello world'); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); + }); + }); + test('Resource id assigned on the client', () async { + final id = Uuid().v4(); + await client + .create('posts', id, attributes: {'title': 'Hello world'}).then((r) { + expect(r.http.statusCode, 204); + expect(r.resource, isNull); + expect(r.http.headers['location'], isNull); + }); + }); + }); +} diff --git a/test/contract/shared.dart b/test/contract/shared.dart new file mode 100644 index 00000000..1bab0e3f --- /dev/null +++ b/test/contract/shared.dart @@ -0,0 +1,19 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/repository_error_converter.dart'; +import 'package:json_api/src/server/_internal/routing_http_handler.dart'; +import 'package:json_api/src/server/chain_error_converter.dart'; +import 'package:json_api/src/server/routing_error_handler.dart'; +import 'package:uuid/uuid.dart'; + + + + +HttpHandler initServer() => TryCatchHttpHandler( + RoutingHttpHandler(RepositoryController( + InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), + ChainErrorConverter([RepositoryErrorConverter(), RoutingErrorHandler()])); diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 13c85f11..3d7cc1ae 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,46 +1,24 @@ import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/http/callback_http_logger.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; + +import '../shared.dart'; + void main() { StreamChannel channel; JsonApiClient client; - Future after(JsonApiRequest request, - [void Function(R response) check]) async { - final response = await client(request); - check?.call(response); - } - setUp(() async { channel = spawnHybridUri('hybrid_server.dart'); final serverUrl = await channel.stream.first; // final serverUrl = 'http://localhost:8080'; - client = JsonApiClient(LoggingHttpHandler(DartHttp(), CallbackHttpLogger()), - RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); + client = + JsonApiClient(RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); }); - /// Goal: test different HTTP methods in a browser - test('Basic Client-Server interaction over HTTP', () async { - final id = Uuid().v4(); - await client( - Request.create('posts', id, attributes: {'title': 'Hello world'})); - await after(Request.fetchResource('posts', id), (r) { - expect(r.resource.attributes['title'], 'Hello world'); - }); - await client(Request.updateResource('posts', id, - attributes: {'title': 'Bye world'})); - await after(Request.fetchResource('posts', id), (r) { - expect(r.resource.attributes['title'], 'Bye world'); - }); - await client(Request.deleteResource('posts', id)); - await after(Request.fetchCollection('posts'), (r) { - expect(r.collection, isEmpty); - }); - }); + test('All possible HTTP methods are usable in browsers', + () => expectAllHttpMethodsToWork(client)); } diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 1ff711c5..083c926c 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,9 +1,11 @@ -import 'package:json_api/src/_demo/demo_server.dart'; -import 'package:json_api/src/_demo/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/demo_server.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; import 'package:stream_channel/stream_channel.dart'; + void hybridMain(StreamChannel channel, Object message) async { - final demo = DemoServer(InMemoryRepo(['users', 'posts', 'comments'])); + final demo = + DemoServer(InMemoryRepo(['users', 'posts', 'comments']), port: 8000); await demo.start(); channel.sink.add(demo.uri.toString()); } diff --git a/test/e2e/usecase_test.dart b/test/e2e/usecase_test.dart deleted file mode 100644 index ac0ccef3..00000000 --- a/test/e2e/usecase_test.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/_demo/demo_server.dart'; -import 'package:json_api/src/_demo/in_memory_repo.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -void main() { - JsonApiClient client; - DemoServer server; - - setUp(() async { - final repo = InMemoryRepo(['users', 'posts', 'comments']); - server = DemoServer(repo, port: 8123); - await server.start(); - client = JsonApiClient(DartHttp(), RecommendedUrlDesign(server.uri)); - }); - - tearDown(() async { - await server.stop(); - }); - - group('Use cases', () { - group('Resource creation', () { - test('Resource id assigned on the server', () async { - final r = await client( - Request.createNew('posts', attributes: {'title': 'Hello world'})); - expect(r.http.statusCode, 201); - // TODO: Why does "Location" header not work in browsers? - expect(r.http.headers['location'], '/posts/${r.resource.id}'); - expect(r.links['self'].toString(), '/posts/${r.resource.id}'); - expect(r.resource.type, 'posts'); - expect(r.resource.id, isNotEmpty); - expect(r.resource.attributes['title'], 'Hello world'); - expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); - }); - test('Resource id assigned on the client', () async { - final id = Uuid().v4(); - final r = await client( - Request.create('posts', id, attributes: {'title': 'Hello world'})); - expect(r.http.statusCode, 204); - expect(r.resource, isNull); - expect(r.http.headers['location'], isNull); - }); - }); - - group('CRUD', () { - Resource alice; - Resource bob; - Resource post; - Resource comment; - Resource secretComment; - - setUp(() async { - alice = (await client( - Request.createNew('users', attributes: {'name': 'Alice'}))) - .resource; - bob = (await client( - Request.createNew('users', attributes: {'name': 'Bob'}))) - .resource; - post = (await client(Request.createNew('posts', - attributes: {'title': 'Hello world'}, - one: {'author': alice.toIdentifier()}))) - .resource; - comment = (await client(Request.createNew('comments', - attributes: {'text': 'Hi Alice'}, - one: {'author': bob.toIdentifier()}))) - .resource; - secretComment = (await client(Request.createNew('comments', - attributes: {'text': 'Secret comment'}, - one: {'author': bob.toIdentifier()}))) - .resource; - await client(Request.addMany( - post.type, post.id, 'comments', [comment.toIdentifier()])); - }); - - test('Fetch a complex resource', () async { - final response = await client(Request.fetchCollection('posts') - ..include(['author', 'comments', 'comments.author'])); - - expect(response.http.statusCode, 200); - expect(response.collection.length, 1); - expect(response.included.length, 3); - - final fetchedPost = response.collection.first; - expect(fetchedPost.attributes['title'], 'Hello world'); - - final fetchedAuthor = response.included[fetchedPost.one('author').key]; - expect(fetchedAuthor.attributes['name'], 'Alice'); - - final fetchedComment = - response.included[fetchedPost.many('comments').single.key]; - expect(fetchedComment.attributes['text'], 'Hi Alice'); - }); - - test('Fetch a to-one relationship', () async { - final r = await client( - Request.fetchOne(post.type, post.id, 'author')); - expect(r.relationship.identifier.id, alice.id); - }); - - test('Fetch a to-many relationship', () async { - final r = await client( - Request.fetchMany(post.type, post.id, 'comments')); - expect(r.relationship.single.id, comment.id); - }); - - test('Delete a to-one relationship', () async { - await client(Request.deleteOne(post.type, post.id, 'author')); - final r = await client( - Request.fetchResource(post.type, post.id)..include(['author'])); - expect(r.resource.one('author'), isEmpty); - }); - - test('Replace a to-one relationship', () async { - await client(Request.replaceOne( - post.type, post.id, 'author', bob.toIdentifier())); - final r = await client( - Request.fetchResource(post.type, post.id)..include(['author'])); - expect( - r.included[r.resource.one('author').key].attributes['name'], 'Bob'); - }); - - test('Delete from a to-many relationship', () async { - await client(Request.deleteMany( - post.type, post.id, 'comments', [comment.toIdentifier()])); - final r = await client(Request.fetchResource(post.type, post.id)); - expect(r.resource.many('comments'), isEmpty); - }); - - test('Replace a to-many relationship', () async { - await client(Request.replaceMany( - post.type, post.id, 'comments', [secretComment.toIdentifier()])); - final r = await client( - Request.fetchResource(post.type, post.id)..include(['comments'])); - expect( - r.included[r.resource.many('comments').single.key] - .attributes['text'], - 'Secret comment'); - }); - }); - }, testOn: 'vm'); -} diff --git a/test/integration_test.dart b/test/integration_test.dart new file mode 100644 index 00000000..5cf25a13 --- /dev/null +++ b/test/integration_test.dart @@ -0,0 +1,26 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/_internal/demo_server.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:test/test.dart'; + +import 'shared.dart'; + +void main() { + JsonApiClient client; + DemoServer server; + + setUp(() async { + final repo = InMemoryRepo(['posts']); + server = DemoServer(repo, port: 8001); + await server.start(); + client = JsonApiClient(RecommendedUrlDesign(server.uri)); + }); + + tearDown(() async { + await server.stop(); + }); + + test('Client and server can interact over HTTP', + () => expectAllHttpMethodsToWork(client)); +} diff --git a/test/shared.dart b/test/shared.dart new file mode 100644 index 00000000..fb520cb1 --- /dev/null +++ b/test/shared.dart @@ -0,0 +1,23 @@ +import 'package:json_api/client.dart'; +import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; + +void expectAllHttpMethodsToWork(JsonApiClient client) async { + final id = Uuid().v4(); + // POST + await client.create('posts', id, attributes: {'title': 'Hello world'}); + // GET + await client.fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Hello world'); + }); + // PATCH + await client.updateResource('posts', id, attributes: {'title': 'Bye world'}); + await client.fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Bye world'); + }); + // DELETE + await client.deleteResource('posts', id); + await client.fetchCollection('posts').then((r) { + expect(r.collection, isEmpty); + }); +} diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 37a6a125..77aa11c1 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -9,21 +9,14 @@ import 'package:test/test.dart'; void main() { final http = MockHandler(); - final client = JsonApiClient(http, RecommendedUrlDesign(Uri(path: '/'))); - - group('Client', () { - // test('Can send request with a document', () { - // client(Request('post', CollectionTarget('apples'), (_) => _)) - // - // }); - - }); + final client = + JsonApiClient(RecommendedUrlDesign(Uri(path: '/')), httpHandler: http); group('Failure', () { test('RequestFailure', () async { http.response = mock.error422; try { - await client(Request.fetchCollection('articles')); + await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -34,7 +27,7 @@ void main() { test('ServerError', () async { http.response = mock.error500; try { - await client(Request.fetchCollection('articles')); + await client.fetchCollection('articles'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 500); @@ -45,7 +38,7 @@ void main() { group('Fetch Collection', () { test('Min', () async { http.response = mock.collectionMin; - final response = await client.call(Request.fetchCollection('articles')); + final response = await client.fetchCollection('articles'); expect(response.collection.single.key, 'articles:1'); expect(response.included, isEmpty); expect(http.request.method, 'get'); @@ -56,15 +49,20 @@ void main() { test('Full', () async { http.response = mock.collectionFull; - final response = await client.call(Request.fetchCollection('articles') - ..headers['foo'] = 'bar' - ..query['foo'] = 'bar' - ..include(['author']) - ..fields({ - 'author': ['name'] - }) - ..page({'limit': '10'}) - ..sort(['title', '-date'])); + final response = await client.fetchCollection('articles', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, page: { + 'limit': '10' + }, sort: [ + 'title', + '-date' + ]); expect(response.collection.length, 1); expect(response.included.length, 3); @@ -84,8 +82,8 @@ void main() { group('Fetch Related Collection', () { test('Min', () async { http.response = mock.collectionFull; - final response = await client( - Request.fetchRelatedCollection('people', '1', 'articles')); + final response = + await client.fetchRelatedCollection('people', '1', 'articles'); expect(response.collection.length, 1); expect(http.request.method, 'get'); expect(http.request.uri.path, '/people/1/articles'); @@ -95,15 +93,20 @@ void main() { test('Full', () async { http.response = mock.collectionFull; final response = await client - .call(Request.fetchRelatedCollection('people', '1', 'articles') - ..headers['foo'] = 'bar' - ..query['foo'] = 'bar' - ..include(['author']) - ..fields({ - 'author': ['name'] - }) - ..page({'limit': '10'}) - ..sort(['title', '-date'])); + .fetchRelatedCollection('people', '1', 'articles', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, page: { + 'limit': '10' + }, sort: [ + 'title', + '-date' + ]); expect(response.collection.length, 1); expect(response.included.length, 3); @@ -124,7 +127,7 @@ void main() { group('Fetch Primary Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(Request.fetchResource('articles', '1')); + final response = await client.fetchResource('articles', '1'); expect(response.resource.type, 'articles'); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1'); @@ -133,13 +136,15 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = await client(Request.fetchResource('articles', '1') - ..headers['foo'] = 'bar' - ..include(['author']) - ..fields({ - 'author': ['name'] - }) - ..query['foo'] = 'bar'); + final response = await client.fetchResource('articles', '1', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }); expect(response.resource.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -155,7 +160,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = - await client(Request.fetchRelatedResource('articles', '1', 'author')); + await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -165,14 +170,16 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = - await client(Request.fetchRelatedResource('articles', '1', 'author') - ..headers['foo'] = 'bar' - ..include(['author']) - ..fields({ - 'author': ['name'] - }) - ..query['foo'] = 'bar'); + final response = await client + .fetchRelatedResource('articles', '1', 'author', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }); expect(response.resource?.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); @@ -182,13 +189,23 @@ void main() { expect(http.request.headers, {'accept': 'application/vnd.api+json', 'foo': 'bar'}); }); + + test('Missing resource', () async { + http.response = mock.relatedResourceNull; + final response = + await client.fetchRelatedResource('articles', '1', 'author'); + expect(response.resource, isNull); + expect(response.included, isEmpty); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/author'); + expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + }); }); group('Fetch Relationship', () { test('Min', () async { http.response = mock.one; - final response = - await client(Request.fetchOne('articles', '1', 'author')); + final response = await client.fetchOne('articles', '1', 'author'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); @@ -197,9 +214,8 @@ void main() { test('Full', () async { http.response = mock.one; - final response = await client(Request.fetchOne('articles', '1', 'author') - ..headers['foo'] = 'bar' - ..query['foo'] = 'bar'); + final response = await client.fetchOne('articles', '1', 'author', + headers: {'foo': 'bar'}, query: {'foo': 'bar'}); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1/relationships/author'); @@ -212,7 +228,7 @@ void main() { group('Create New Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(Request.createNew('articles')); + final response = await client.createNew('articles'); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); @@ -230,7 +246,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = await client(Request.createNew('articles', attributes: { + final response = await client.createNew('articles', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -238,8 +254,9 @@ void main() { 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 - }) - ..headers['foo'] = 'bar'); + }, headers: { + 'foo': 'bar' + }); expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); @@ -279,7 +296,7 @@ void main() { group('Create Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(Request.create('articles', '1')); + final response = await client.create('articles', '1'); expect(response.resource.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); @@ -294,7 +311,7 @@ void main() { test('Min with 204 No Content', () async { http.response = mock.noContent; - final response = await client(Request.create('articles', '1')); + final response = await client.create('articles', '1'); expect(response.resource, isNull); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); @@ -309,8 +326,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; - final response = - await client(Request.create('articles', '1', attributes: { + final response = await client.create('articles', '1', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -318,8 +334,9 @@ void main() { 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 - }) - ..headers['foo'] = 'bar'); + }, headers: { + 'foo': 'bar' + }); expect(response.resource?.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); @@ -357,7 +374,7 @@ void main() { group('Update Resource', () { test('Min', () async { http.response = mock.primaryResource; - final response = await client(Request.updateResource('articles', '1')); + final response = await client.updateResource('articles', '1'); expect(response.resource?.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); @@ -372,7 +389,7 @@ void main() { test('Min with 204 No Content', () async { http.response = mock.noContent; - final response = await client(Request.updateResource('articles', '1')); + final response = await client.updateResource('articles', '1'); expect(response.resource, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); @@ -388,7 +405,7 @@ void main() { test('Full', () async { http.response = mock.primaryResource; final response = - await client(Request.updateResource('articles', '1', attributes: { + await client.updateResource('articles', '1', attributes: { 'cool': true }, one: { 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) @@ -396,8 +413,9 @@ void main() { 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 - }) - ..headers['foo'] = 'bar'); + }, headers: { + 'foo': 'bar' + }); expect(response.resource?.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); @@ -435,8 +453,8 @@ void main() { group('Replace One', () { test('Min', () async { http.response = mock.one; - final response = await client(Request.replaceOne( - 'articles', '1', 'author', Identifier('people', '42'))); + final response = await client.replaceOne( + 'articles', '1', 'author', Identifier('people', '42')); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); @@ -451,11 +469,9 @@ void main() { test('Full', () async { http.response = mock.one; - final response = await client( - Request.replaceOne( - 'articles', '1', 'author', Identifier('people', '42')) - ..headers['foo'] = 'bar', - ); + final response = await client.replaceOne( + 'articles', '1', 'author', Identifier('people', '42'), + headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); @@ -472,8 +488,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(Request.replaceOne( - 'articles', '1', 'author', Identifier('people', '42'))); + await client.replaceOne( + 'articles', '1', 'author', Identifier('people', '42')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -484,8 +500,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; expect( - () async => await client(Request.replaceOne( - 'articles', '1', 'author', Identifier('people', '42'))), + () => client.replaceOne( + 'articles', '1', 'author', Identifier('people', '42')), throwsFormatException); }); }); @@ -493,8 +509,7 @@ void main() { group('Delete One', () { test('Min', () async { http.response = mock.oneEmpty; - final response = - await client(Request.deleteOne('articles', '1', 'author')); + final response = await client.deleteOne('articles', '1', 'author'); expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); @@ -508,8 +523,8 @@ void main() { test('Full', () async { http.response = mock.oneEmpty; - final response = await client( - Request.deleteOne('articles', '1', 'author')..headers['foo'] = 'bar'); + final response = await client + .deleteOne('articles', '1', 'author', headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); @@ -525,7 +540,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(Request.deleteOne('articles', '1', 'author')); + await client.deleteOne('articles', '1', 'author'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -535,9 +550,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; - expect( - () async => - await client(Request.deleteOne('articles', '1', 'author')), + expect(() => client.deleteOne('articles', '1', 'author'), throwsFormatException); }); }); @@ -545,8 +558,8 @@ void main() { group('Delete Many', () { test('Min', () async { http.response = mock.many; - final response = await client(Request.deleteMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])); + final response = await client + .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -563,9 +576,9 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client( - Request.deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]) - ..headers['foo'] = 'bar'); + final response = await client.deleteMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -584,8 +597,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(Request.deleteMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])); + await client + .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -596,8 +609,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(Request.deleteMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])), + () => client + .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); @@ -605,8 +618,8 @@ void main() { group('Replace Many', () { test('Min', () async { http.response = mock.many; - final response = await client(Request.replaceMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])); + final response = await client + .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -623,9 +636,9 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client(Request.replaceMany( - 'articles', '1', 'tags', [Identifier('tags', '1')]) - ..headers['foo'] = 'bar'); + final response = await client.replaceMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -644,8 +657,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(Request.replaceMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])); + await client + .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -656,8 +669,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(Request.replaceMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])), + () => client + .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); @@ -665,8 +678,8 @@ void main() { group('Add Many', () { test('Min', () async { http.response = mock.many; - final response = await client( - Request.addMany('articles', '1', 'tags', [Identifier('tags', '1')])); + final response = await client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -683,9 +696,9 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client( - Request.addMany('articles', '1', 'tags', [Identifier('tags', '1')]) - ..headers['foo'] = 'bar'); + final response = await client.addMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -704,20 +717,21 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client(Request.addMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])); + await client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); expect(e.document.errors.first.status, '422'); + expect(e.toString(), 'JSON:API request failed with HTTP status 422'); } }); test('Throws FormatException', () async { http.response = mock.one; expect( - () async => await client(Request.addMany( - 'articles', '1', 'tags', [Identifier('tags', '1')])), + () => client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index b7b4b547..3726e7e1 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; import 'package:json_api/src/test/payload.dart' as payload; import 'package:test/test.dart'; @@ -169,6 +171,18 @@ void main() { }).dataAsRelationship(), throwsFormatException); }); + + test('throws on invalid JSON', () { + expect(() => InboundDocument.decode(jsonEncode('oops')), + throwsFormatException); + }); + + test('throws on invalid relationship kind', () { + expect(() => InboundDocument(payload.one).dataAsRelationship(), + throwsFormatException); + expect(() => InboundDocument(payload.many).dataAsRelationship(), + throwsFormatException); + }); }); }); } diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index beeb25cc..5b47d827 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -54,5 +54,11 @@ void main() { } })); }); + test('one() throws StateError when relationship does not exist', () { + expect(() => Resource('books', '1').one('author'), throwsStateError); + }); + test('many() throws StateError when relationship does not exist', () { + expect(() => Resource('books', '1').many('tags'), throwsStateError); + }); }); } diff --git a/test/unit/http/request_test.dart b/test/unit/http/request_test.dart index 561e8947..96eaaffb 100644 --- a/test/unit/http/request_test.dart +++ b/test/unit/http/request_test.dart @@ -2,12 +2,29 @@ import 'package:json_api/http.dart'; import 'package:test/test.dart'; void main() { - test('HttpRequest converts method to lowercase', () { - expect(HttpRequest('pAtCh', Uri()).method, 'patch'); - }); + group('HttpRequest', () { + final uri = Uri(); + final get = HttpRequest('get', uri); + final post = HttpRequest('post', uri); + final delete = HttpRequest('delete', uri); + final patch = HttpRequest('patch', uri); + final options = HttpRequest('options', uri); + final fail = HttpRequest('fail', uri); + test('getters', () { + expect(get.isGet, isTrue); + expect(post.isPost, isTrue); + expect(delete.isDelete, isTrue); + expect(patch.isPatch, isTrue); + expect(options.isOptions, isTrue); - test('HttpRequest converts headers keys to lowercase', () { - expect(HttpRequest('post', Uri(), headers: {'FoO': 'Bar'}).headers, - {'foo': 'Bar'}); + expect(fail.isGet, isFalse); + expect(fail.isPost, isFalse); + expect(fail.isDelete, isFalse); + expect(fail.isPatch, isFalse); + expect(fail.isOptions, isFalse); + }); + test('converts method to lowercase', () { + expect(HttpRequest('pAtCh', Uri()).method, 'patch'); + }); }); } diff --git a/test/unit/server/try_catch_http_handler_test.dart b/test/unit/server/try_catch_http_handler_test.dart new file mode 100644 index 00000000..0c6a855a --- /dev/null +++ b/test/unit/server/try_catch_http_handler_test.dart @@ -0,0 +1,21 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/chain_error_converter.dart'; +import 'package:test/test.dart'; + +void main() { + test('HTTP 500 is returned', () async { + await TryCatchHttpHandler(Oops(), ChainErrorConverter([])) + .call(HttpRequest('get', Uri.parse('/'))) + .then((r) { + expect(r.statusCode, 500); + }); + }); +} + +class Oops implements HttpHandler { + @override + Future call(HttpRequest request) { + throw 'Oops'; + } +} From c634a4c7affdc6a526b60b8f88332ec6f139a43d Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 23 Nov 2020 12:12:54 -0800 Subject: [PATCH 82/99] WIP --- example/server.dart | 29 ++++++++++++++++++----- lib/src/server/_internal/demo_server.dart | 26 ++++---------------- test/e2e/hybrid_server.dart | 15 ++++++++++-- test/integration_test.dart | 8 +++++-- 4 files changed, 46 insertions(+), 32 deletions(-) diff --git a/example/server.dart b/example/server.dart index 3a8d6233..1c73637b 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,16 +1,33 @@ import 'dart:io'; import 'package:json_api/http.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_handler.dart'; import 'package:json_api/src/server/_internal/demo_server.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/repository_error_converter.dart'; +import 'package:json_api/src/server/_internal/routing_http_handler.dart'; +import 'package:json_api/src/server/chain_error_converter.dart'; +import 'package:json_api/src/server/routing_error_handler.dart'; +import 'package:uuid/uuid.dart'; Future main() async { - final demo = DemoServer(InMemoryRepo(['users', 'posts', 'comments']), - logger: CallbackHttpLogger(onRequest: (r) { - print('${r.method} ${r.uri}\n${r.headers}\n${r.body}\n\n'); - }, onResponse: (r) { - print('${r.statusCode}\n${r.headers}\n${r.body}\n\n'); - })); + final logger = CallbackHttpLogger(onRequest: (r) { + print('${r.method} ${r.uri}\n${r.headers}\n${r.body}\n\n'); + }, onResponse: (r) { + print('${r.statusCode}\n${r.headers}\n${r.body}\n\n'); + }); + final logging = LoggingHttpHandler( + CorsHandler(TryCatchHttpHandler( + RoutingHttpHandler(RepositoryController( + InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), + ChainErrorConverter( + [RepositoryErrorConverter(), RoutingErrorHandler()]), + )), + logger); + final demo = DemoServer(logging); + await demo.start(); ProcessSignal.sigint.watch().listen((event) async { await demo.stop(); diff --git a/lib/src/server/_internal/demo_server.dart b/lib/src/server/_internal/demo_server.dart index 9836efbd..d5bc4026 100644 --- a/lib/src/server/_internal/demo_server.dart +++ b/lib/src/server/_internal/demo_server.dart @@ -1,37 +1,19 @@ import 'dart:io'; import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_handler.dart'; import 'package:json_api/src/server/_internal/dart_io_http_handler.dart'; -import 'package:json_api/src/server/_internal/repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/_internal/routing_http_handler.dart'; -import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:json_api/src/server/routing_error_handler.dart'; import 'package:pedantic/pedantic.dart'; -import 'package:uuid/uuid.dart'; class DemoServer { DemoServer( - Repo repo, { + this.handler, { this.host = 'localhost', this.port = 8080, - HttpLogger logger = const CallbackHttpLogger(), - String Function() idGenerator, - }) : _handler = LoggingHttpHandler( - CorsHandler(TryCatchHttpHandler( - RoutingHttpHandler( - RepositoryController(repo, idGenerator ?? Uuid().v4)), - ChainErrorConverter( - [RepositoryErrorConverter(), RoutingErrorHandler()]), - )), - logger); + }); final String host; final int port; - final HttpHandler _handler; + final HttpHandler handler; HttpServer _server; @@ -41,7 +23,7 @@ class DemoServer { if (_server != null) return; try { _server = await HttpServer.bind(host, port); - unawaited(_server.forEach(DartIOHttpHandler(_handler))); + unawaited(_server.forEach(DartIOHttpHandler(handler))); } on Exception { await stop(); rethrow; diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 083c926c..92dabd5e 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,11 +1,22 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_handler.dart'; import 'package:json_api/src/server/_internal/demo_server.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/repository_error_converter.dart'; +import 'package:json_api/src/server/_internal/routing_http_handler.dart'; +import 'package:json_api/src/server/chain_error_converter.dart'; +import 'package:json_api/src/server/routing_error_handler.dart'; import 'package:stream_channel/stream_channel.dart'; - +import 'package:uuid/uuid.dart'; void hybridMain(StreamChannel channel, Object message) async { + final handler = CorsHandler(TryCatchHttpHandler( + RoutingHttpHandler(RepositoryController(InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), + ChainErrorConverter([RepositoryErrorConverter(), RoutingErrorHandler()]), + )); final demo = - DemoServer(InMemoryRepo(['users', 'posts', 'comments']), port: 8000); + DemoServer(handler, port: 8000); await demo.start(); channel.sink.add(demo.uri.toString()); } diff --git a/test/integration_test.dart b/test/integration_test.dart index 5cf25a13..341e9041 100644 --- a/test/integration_test.dart +++ b/test/integration_test.dart @@ -2,7 +2,10 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/server/_internal/demo_server.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/routing_http_handler.dart'; import 'package:test/test.dart'; +import 'package:uuid/uuid.dart'; import 'shared.dart'; @@ -11,8 +14,9 @@ void main() { DemoServer server; setUp(() async { - final repo = InMemoryRepo(['posts']); - server = DemoServer(repo, port: 8001); + final handler = + RoutingHttpHandler(RepositoryController(InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)); + server = DemoServer(handler, port: 8001); await server.start(); client = JsonApiClient(RecommendedUrlDesign(server.uri)); }); From 46aa4fb880717ae7a13e74b4689bc21ae9bc222f Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 23 Nov 2020 12:15:40 -0800 Subject: [PATCH 83/99] dev.4 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index f5c1bc89..3c196d2e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-dev.3 +version: 5.0.0-dev.4 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: From 3d8a008efcaaa525128a562298114bb6ce61d398 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 23 Nov 2020 23:29:16 -0800 Subject: [PATCH 84/99] dev.5 --- example/server.dart | 53 ++++++++++-------- lib/client.dart | 8 +-- lib/handler.dart | 38 +++++++++++++ lib/http.dart | 4 -- lib/server.dart | 7 ++- lib/src/client/dart_http.dart | 3 +- lib/src/client/json_api_client.dart | 24 +++++---- .../{ => response}/collection_response.dart | 0 .../{ => response}/new_resource_response.dart | 0 .../{ => response}/relationship_response.dart | 0 .../{ => response}/request_failure.dart | 8 ++- .../{ => response}/resource_response.dart | 0 lib/src/client/{ => response}/response.dart | 0 lib/src/http/callback_http_logger.dart | 24 --------- lib/src/http/http_handler.dart | 23 -------- lib/src/http/http_logger.dart | 7 --- lib/src/http/logging_http_handler.dart | 20 ------- ...rs_handler.dart => cors_http_handler.dart} | 18 +++---- .../_internal/dart_io_http_handler.dart | 38 ------------- lib/src/server/_internal/demo_server.dart | 38 ------------- .../_internal/repository_controller.dart | 54 +++++++++---------- .../_internal/repository_error_converter.dart | 12 ++--- lib/src/server/chain_error_converter.dart | 17 +++--- lib/src/server/controller.dart | 30 ++++------- lib/src/server/error_converter.dart | 6 --- lib/src/server/json_api_response.dart | 33 ++++++++++++ .../{_internal => }/method_not_allowed.dart | 0 lib/src/server/response.dart | 31 ----------- lib/src/server/response_encoder.dart | 24 +++++++++ .../routing_http_handler.dart => router.dart} | 13 ++--- lib/src/server/routing_error_converter.dart | 19 +++++++ lib/src/server/routing_error_handler.dart | 20 ------- lib/src/server/try_catch_handler.dart | 25 +++++++++ lib/src/server/try_catch_http_handler.dart | 27 ---------- .../{_internal => }/unmatched_target.dart | 0 lib/src/test/mock_handler.dart | 10 ++-- pubspec.yaml | 3 +- test/contract/crud_test.dart | 3 +- test/contract/errors_test.dart | 6 ++- test/contract/resource_creation_test.dart | 3 +- test/contract/shared.dart | 26 +++++---- test/e2e/browser_test.dart | 2 +- test/e2e/hybrid_server.dart | 23 ++------ test/e2e/integration_test.dart | 27 ++++++++++ test/{ => e2e}/shared.dart | 0 test/handler/logging_handler_test.dart | 19 +++++++ test/integration_test.dart | 30 ----------- test/src/demo_server.dart | 21 ++++++++ test/unit/client/client_test.dart | 19 +++---- test/unit/http/logging_http_handler_test.dart | 18 ------- .../server/try_catch_http_handler_test.dart | 10 ++-- 51 files changed, 389 insertions(+), 455 deletions(-) create mode 100644 lib/handler.dart rename lib/src/client/{ => response}/collection_response.dart (100%) rename lib/src/client/{ => response}/new_resource_response.dart (100%) rename lib/src/client/{ => response}/relationship_response.dart (100%) rename lib/src/client/{ => response}/request_failure.dart (67%) rename lib/src/client/{ => response}/resource_response.dart (100%) rename lib/src/client/{ => response}/response.dart (100%) delete mode 100644 lib/src/http/callback_http_logger.dart delete mode 100644 lib/src/http/http_handler.dart delete mode 100644 lib/src/http/http_logger.dart delete mode 100644 lib/src/http/logging_http_handler.dart rename lib/src/server/_internal/{cors_handler.dart => cors_http_handler.dart} (61%) delete mode 100644 lib/src/server/_internal/dart_io_http_handler.dart delete mode 100644 lib/src/server/_internal/demo_server.dart delete mode 100644 lib/src/server/error_converter.dart create mode 100644 lib/src/server/json_api_response.dart rename lib/src/server/{_internal => }/method_not_allowed.dart (100%) delete mode 100644 lib/src/server/response.dart create mode 100644 lib/src/server/response_encoder.dart rename lib/src/server/{_internal/routing_http_handler.dart => router.dart} (79%) create mode 100644 lib/src/server/routing_error_converter.dart delete mode 100644 lib/src/server/routing_error_handler.dart create mode 100644 lib/src/server/try_catch_handler.dart delete mode 100644 lib/src/server/try_catch_http_handler.dart rename lib/src/server/{_internal => }/unmatched_target.dart (100%) create mode 100644 test/e2e/integration_test.dart rename test/{ => e2e}/shared.dart (100%) create mode 100644 test/handler/logging_handler_test.dart delete mode 100644 test/integration_test.dart create mode 100644 test/src/demo_server.dart delete mode 100644 test/unit/http/logging_http_handler_test.dart diff --git a/example/server.dart b/example/server.dart index 1c73637b..9036ef64 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,36 +1,45 @@ import 'dart:io'; -import 'package:json_api/http.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_handler.dart'; -import 'package:json_api/src/server/_internal/demo_server.dart'; +import 'package:json_api/src/server/_internal/cors_http_handler.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; import 'package:json_api/src/server/_internal/repository_controller.dart'; import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/_internal/routing_http_handler.dart'; -import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:json_api/src/server/routing_error_handler.dart'; +import 'package:json_api/src/server/response_encoder.dart'; +import 'package:json_api_server/json_api_server.dart'; import 'package:uuid/uuid.dart'; Future main() async { - final logger = CallbackHttpLogger(onRequest: (r) { - print('${r.method} ${r.uri}\n${r.headers}\n${r.body}\n\n'); - }, onResponse: (r) { - print('${r.statusCode}\n${r.headers}\n${r.body}\n\n'); - }); - final logging = LoggingHttpHandler( - CorsHandler(TryCatchHttpHandler( - RoutingHttpHandler(RepositoryController( - InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), - ChainErrorConverter( - [RepositoryErrorConverter(), RoutingErrorHandler()]), - )), - logger); - final demo = DemoServer(logging); + final repo = InMemoryRepo(['users', 'posts', 'comments']); + final controller = RepositoryController(repo, Uuid().v4); + final errorConverter = ChainErrorConverter([ + RepositoryErrorConverter(), + RoutingErrorConverter(), + ], () async => JsonApiResponse.internalServerError()); + final handler = CorsHttpHandler(JsonApiResponseEncoder( + TryCatchHandler(Router(controller), errorConverter))); + final loggingHandler = LoggingHandler( + handler, + (rq) => print([ + '>> ${rq.method.toUpperCase()} ${rq.uri}', + 'Headers: ${rq.headers}', + 'Body: ${rq.body}', + ].join('\n') + + '\n'), + (rs) => print([ + '<< ${rs.statusCode}', + 'Headers: ${rs.headers}', + 'Body: ${rs.body}', + ].join('\n') + + '\n')); + final server = JsonApiServer(loggingHandler); - await demo.start(); ProcessSignal.sigint.watch().listen((event) async { - await demo.stop(); + await server.stop(); exit(0); }); + + await server.start(); + print('Server is listening at ${server.uri}'); } diff --git a/lib/client.dart b/lib/client.dart index 95d65b72..a901bea1 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -3,7 +3,7 @@ library json_api; export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/dart_http.dart'; export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/request_failure.dart'; -export 'package:json_api/src/client/collection_response.dart'; -export 'package:json_api/src/client/relationship_response.dart'; -export 'package:json_api/src/client/resource_response.dart'; +export 'package:json_api/src/client/response/request_failure.dart'; +export 'package:json_api/src/client/response/collection_response.dart'; +export 'package:json_api/src/client/response/relationship_response.dart'; +export 'package:json_api/src/client/response/resource_response.dart'; diff --git a/lib/handler.dart b/lib/handler.dart new file mode 100644 index 00000000..303d2b50 --- /dev/null +++ b/lib/handler.dart @@ -0,0 +1,38 @@ +/// This library defines the idea of a composable generic +/// async (request/response) handler. +library handler; + +/// A generic async handler +abstract class Handler { + Future call(Rq request); +} + +/// A generic async handler function +typedef HandlerFun = Future Function(Rq request); + +/// Generic handler from function +class FunHandler implements Handler { + const FunHandler(this._fun); + + final HandlerFun _fun; + + @override + Future call(Rq request) => _fun(request); +} + +/// A wrapper over [Handler] which allows logging +class LoggingHandler implements Handler { + LoggingHandler(this._handler, this._onRequest, this._onResponse); + + final Handler _handler; + final void Function(Rq request) _onRequest; + final void Function(Rs response) _onResponse; + + @override + Future call(Rq request) async { + _onRequest(request); + final response = await _handler(request); + _onResponse(response); + return response; + } +} diff --git a/lib/http.dart b/lib/http.dart index 7be2b9ba..ea582a7d 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,11 +1,7 @@ /// This is a thin HTTP layer abstraction used by the client library http; -export 'package:json_api/src/http/callback_http_logger.dart'; export 'package:json_api/src/http/headers.dart'; -export 'package:json_api/src/http/http_handler.dart'; -export 'package:json_api/src/http/http_logger.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; -export 'package:json_api/src/http/logging_http_handler.dart'; export 'package:json_api/src/http/media_type.dart'; diff --git a/lib/server.dart b/lib/server.dart index 777563fb..b3b291f7 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,3 +1,6 @@ +export 'package:json_api/src/server/chain_error_converter.dart'; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/try_catch_http_handler.dart'; -export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/routing_error_converter.dart'; +export 'package:json_api/src/server/router.dart'; +export 'package:json_api/src/server/try_catch_handler.dart'; diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart index 0f3ee035..612ca8eb 100644 --- a/lib/src/client/dart_http.dart +++ b/lib/src/client/dart_http.dart @@ -1,8 +1,9 @@ import 'package:http/http.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; /// A handler using the built-in http client -class DartHttp implements HttpHandler { +class DartHttp implements Handler { /// Creates an instance of [DartHttp]. /// If [client] is passed, it will be used to keep a persistent connection. /// In this case it is your responsibility to call [Client.close]. diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 77a6c87f..3477bdd6 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,22 +1,24 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/collection_response.dart'; -import 'package:json_api/src/client/new_resource_response.dart'; -import 'package:json_api/src/client/relationship_response.dart'; import 'package:json_api/src/client/request.dart'; -import 'package:json_api/src/client/request_failure.dart'; -import 'package:json_api/src/client/resource_response.dart'; -import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/client/response/collection_response.dart'; +import 'package:json_api/src/client/response/new_resource_response.dart'; +import 'package:json_api/src/client/response/relationship_response.dart'; +import 'package:json_api/src/client/response/request_failure.dart'; +import 'package:json_api/src/client/response/resource_response.dart'; +import 'package:json_api/src/client/response/response.dart'; /// The JSON:API client class JsonApiClient { - JsonApiClient(this._uriFactory, {HttpHandler httpHandler}) + JsonApiClient(this._uriFactory, + {Handler httpHandler}) : _http = httpHandler ?? DartHttp(); - final HttpHandler _http; + final Handler _http; final UriFactory _uriFactory; /// Adds [identifiers] to a to-many relationship @@ -306,9 +308,9 @@ class JsonApiClient { if (response.isFailed) { throw RequestFailure(response, - document: response.hasDocument - ? InboundDocument.decode(response.body) - : null); + errors: response.hasDocument + ? InboundDocument.decode(response.body).errors + : []); } return response; } diff --git a/lib/src/client/collection_response.dart b/lib/src/client/response/collection_response.dart similarity index 100% rename from lib/src/client/collection_response.dart rename to lib/src/client/response/collection_response.dart diff --git a/lib/src/client/new_resource_response.dart b/lib/src/client/response/new_resource_response.dart similarity index 100% rename from lib/src/client/new_resource_response.dart rename to lib/src/client/response/new_resource_response.dart diff --git a/lib/src/client/relationship_response.dart b/lib/src/client/response/relationship_response.dart similarity index 100% rename from lib/src/client/relationship_response.dart rename to lib/src/client/response/relationship_response.dart diff --git a/lib/src/client/request_failure.dart b/lib/src/client/response/request_failure.dart similarity index 67% rename from lib/src/client/request_failure.dart rename to lib/src/client/response/request_failure.dart index da8cb3bd..d41baed6 100644 --- a/lib/src/client/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -3,11 +3,15 @@ import 'package:json_api/http.dart'; /// Thrown when the server returns a non-successful response. class RequestFailure implements Exception { - RequestFailure(this.http, {this.document}); + RequestFailure(this.http, {Iterable errors}) { + this.errors.addAll(errors); + } /// The response itself. final HttpResponse http; - final InboundDocument /*?*/ document; + + /// JSON:API errors (if any) + final errors = []; @override String toString() => diff --git a/lib/src/client/resource_response.dart b/lib/src/client/response/resource_response.dart similarity index 100% rename from lib/src/client/resource_response.dart rename to lib/src/client/response/resource_response.dart diff --git a/lib/src/client/response.dart b/lib/src/client/response/response.dart similarity index 100% rename from lib/src/client/response.dart rename to lib/src/client/response/response.dart diff --git a/lib/src/http/callback_http_logger.dart b/lib/src/http/callback_http_logger.dart deleted file mode 100644 index 00a700c9..00000000 --- a/lib/src/http/callback_http_logger.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_api/http.dart'; - -class CallbackHttpLogger implements HttpLogger { - const CallbackHttpLogger( - {_Consumer onRequest, _Consumer onResponse}) - : _onRequest = onRequest, - _onResponse = onResponse; - - final _Consumer /*?*/ _onRequest; - - final _Consumer /*?*/ _onResponse; - - @override - void onRequest(HttpRequest request) { - _onRequest?.call(request); - } - - @override - void onResponse(HttpResponse response) { - _onResponse?.call(response); - } -} - -typedef _Consumer = void Function(R r); diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart deleted file mode 100644 index 9f941129..00000000 --- a/lib/src/http/http_handler.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -/// A callable class which converts requests to responses -abstract class HttpHandler { - /// Sends the request over the network and returns the received response - Future call(HttpRequest request); - - /// Creates an instance of [HttpHandler] from a function - static HttpHandler fromFunction(HttpHandlerFunc f) => _HandlerFromFunction(f); -} - -/// This typedef is compatible with [HttpHandler] -typedef HttpHandlerFunc = Future Function(HttpRequest request); - -class _HandlerFromFunction implements HttpHandler { - const _HandlerFromFunction(this._f); - - @override - Future call(HttpRequest request) => _f(request); - - final HttpHandlerFunc _f; -} diff --git a/lib/src/http/http_logger.dart b/lib/src/http/http_logger.dart deleted file mode 100644 index 70fd03fa..00000000 --- a/lib/src/http/http_logger.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:json_api/http.dart'; - -abstract class HttpLogger { - void onRequest(HttpRequest /*!*/ request); - - void onResponse(HttpResponse /*!*/ response); -} diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart deleted file mode 100644 index 0df78328..00000000 --- a/lib/src/http/logging_http_handler.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/src/http/http_handler.dart'; -import 'package:json_api/src/http/http_logger.dart'; -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -/// A wrapper over [HttpHandler] which allows logging -class LoggingHttpHandler implements HttpHandler { - LoggingHttpHandler(this._handler, this._logger); - - final HttpHandler _handler; - final HttpLogger _logger; - - @override - Future call(HttpRequest request) async { - _logger.onRequest(request); - final response = await _handler(request); - _logger.onResponse(response); - return response; - } -} diff --git a/lib/src/server/_internal/cors_handler.dart b/lib/src/server/_internal/cors_http_handler.dart similarity index 61% rename from lib/src/server/_internal/cors_handler.dart rename to lib/src/server/_internal/cors_http_handler.dart index 9cd2182d..03323ace 100644 --- a/lib/src/server/_internal/cors_handler.dart +++ b/lib/src/server/_internal/cors_http_handler.dart @@ -1,18 +1,18 @@ +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; -class CorsHandler implements HttpHandler { - CorsHandler(this.wrapped, {this.origin = '*'}); +/// An [HttpHandler] wrapper. Adds CORS headers and handles pre-flight requests. +class CorsHttpHandler implements Handler { + CorsHttpHandler(this._wrapped); - final String origin; - - final HttpHandler wrapped; + final Handler _wrapped; @override Future call(HttpRequest request) async { - if (request.method == 'options') { + if (request.isOptions) { return HttpResponse(204) ..headers.addAll({ - 'Access-Control-Allow-Origin': request.headers['origin'] ?? origin, + 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', 'Access-Control-Allow-Methods': // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? request.headers['Access-Control-Request-Method'].toUpperCase(), @@ -20,8 +20,8 @@ class CorsHandler implements HttpHandler { request.headers['Access-Control-Request-Headers'] ?? '*', }); } - return await wrapped(request) + return await _wrapped(request) ..headers['Access-Control-Allow-Origin'] = - request.headers['origin'] ?? origin; + request.headers['origin'] ?? '*'; } } diff --git a/lib/src/server/_internal/dart_io_http_handler.dart b/lib/src/server/_internal/dart_io_http_handler.dart deleted file mode 100644 index b6b200fd..00000000 --- a/lib/src/server/_internal/dart_io_http_handler.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:json_api/http.dart'; - -class DartIOHttpHandler { - DartIOHttpHandler(this._handler); - - final HttpHandler _handler; - - Future call(io.HttpRequest ioRequest) async { - final request = await _convertRequest(ioRequest); - final response = await _handler(request); - await _sendResponse(response, ioRequest.response); - } - - Future _sendResponse( - HttpResponse response, io.HttpResponse ioResponse) async { - response.headers.forEach(ioResponse.headers.add); - ioResponse.statusCode = response.statusCode; - ioResponse.write(response.body); - await ioResponse.close(); - } - - Future _convertRequest(io.HttpRequest ioRequest) async => - HttpRequest(ioRequest.method, ioRequest.requestedUri, - body: await _readBody(ioRequest)) - ..headers.addAll(_convertHeaders(ioRequest.headers)); - - Future _readBody(io.HttpRequest ioRequest) => - ioRequest.cast>().transform(utf8.decoder).join(); - - Map _convertHeaders(io.HttpHeaders ioHeaders) { - final headers = {}; - ioHeaders.forEach((k, v) => headers[k] = v.join(',')); - return headers; - } -} diff --git a/lib/src/server/_internal/demo_server.dart b/lib/src/server/_internal/demo_server.dart deleted file mode 100644 index d5bc4026..00000000 --- a/lib/src/server/_internal/demo_server.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/http.dart'; -import 'package:json_api/src/server/_internal/dart_io_http_handler.dart'; -import 'package:pedantic/pedantic.dart'; - -class DemoServer { - DemoServer( - this.handler, { - this.host = 'localhost', - this.port = 8080, - }); - - final String host; - final int port; - final HttpHandler handler; - - HttpServer _server; - - Uri get uri => Uri(scheme: 'http', host: host, port: port); - - Future start() async { - if (_server != null) return; - try { - _server = await HttpServer.bind(host, port); - unawaited(_server.forEach(DartIOHttpHandler(handler))); - } on Exception { - await stop(); - rethrow; - } - } - - Future stop({bool force = false}) async { - if (_server == null) return; - await _server.close(force: force); - _server = null; - } -} diff --git a/lib/src/server/_internal/repository_controller.dart b/lib/src/server/_internal/repository_controller.dart index e09deaa0..4c34234b 100644 --- a/lib/src/server/_internal/repository_controller.dart +++ b/lib/src/server/_internal/repository_controller.dart @@ -6,9 +6,9 @@ import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/_internal/relationship_node.dart'; import 'package:json_api/src/server/_internal/repo.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/response.dart'; +import 'package:json_api/src/server/json_api_response.dart'; -class RepositoryController implements Controller { +class RepositoryController implements Controller { RepositoryController(this.repo, this.getId); final Repo repo; @@ -18,7 +18,7 @@ class RepositoryController implements Controller { final urlDesign = RecommendedUrlDesign.pathOnly; @override - Future fetchCollection( + Future fetchCollection( HttpRequest request, CollectionTarget target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) @@ -29,69 +29,69 @@ class RepositoryController implements Controller { doc.included.add(r); } } - return Response.ok(doc); + return JsonApiResponse.ok(doc); } @override - Future fetchResource( + Future fetchResource( HttpRequest request, ResourceTarget target) async { final resource = await _fetchLinkedResource(target.type, target.id); - if (resource == null) return Response.notFound(); + if (resource == null) return JsonApiResponse.notFound(); final doc = OutboundDataDocument.resource(resource) ..links['self'] = Link(target.map(urlDesign)); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); await for (final r in _getAllRelated(resource, forest)) { doc.included.add(r); } - return Response.ok(doc); + return JsonApiResponse.ok(doc); } @override - Future createResource( + Future createResource( HttpRequest request, CollectionTarget target) async { final res = _decode(request).newResource(); final id = res.id ?? getId(); await repo.persist(res.type, id, _toModel(res)); if (res.id != null) { - return Response.noContent(); + return JsonApiResponse.noContent(); } final self = Link(ResourceTarget(target.type, id).map(urlDesign)); final resource = await _fetchResource(target.type, id) ..links['self'] = self; - return Response.created( + return JsonApiResponse.created( OutboundDataDocument.resource(resource)..links['self'] = self, self.uri.toString()); } @override - Future addMany( + Future addMany( HttpRequest request, RelationshipTarget target) async { final many = _decode(request).dataAsRelationship(); final refs = await repo .addMany( target.type, target.id, target.relationship, many.map((_) => _.key)) .toList(); - return Response.ok( + return JsonApiResponse.ok( OutboundDataDocument.many(Many(refs.map(Identifier.fromKey)))); } @override - Future deleteResource( + Future deleteResource( HttpRequest request, ResourceTarget target) async { await repo.delete(target.type, target.id); - return Response.noContent(); + return JsonApiResponse.noContent(); } @override - Future updateResource( + Future updateResource( HttpRequest request, ResourceTarget target) async { await repo.update( target.type, target.id, _toModel(_decode(request).resource())); - return Response.noContent(); + return JsonApiResponse.noContent(); } @override - Future replaceRelationship( + Future replaceRelationship( HttpRequest request, RelationshipTarget target) async { final rel = _decode(request).dataAsRelationship(); if (rel is One) { @@ -102,7 +102,7 @@ class RepositoryController implements Controller { await repo.replaceOne( target.type, target.id, target.relationship, id.key); } - return Response.ok(OutboundDataDocument.one(One(id))); + return JsonApiResponse.ok(OutboundDataDocument.one(One(id))); } if (rel is Many) { final ids = await repo @@ -110,13 +110,13 @@ class RepositoryController implements Controller { rel.map((_) => _.key)) .map(Identifier.fromKey) .toList(); - return Response.ok(OutboundDataDocument.many(Many(ids))); + return JsonApiResponse.ok(OutboundDataDocument.many(Many(ids))); } throw FormatException('Incomplete relationship'); } @override - Future deleteMany( + Future deleteMany( HttpRequest request, RelationshipTarget target) async { final rel = _decode(request).dataAsRelationship(); final ids = await repo @@ -124,42 +124,42 @@ class RepositoryController implements Controller { target.type, target.id, target.relationship, rel.map((_) => _.key)) .map(Identifier.fromKey) .toList(); - return Response.ok(OutboundDataDocument.many(Many(ids))); + return JsonApiResponse.ok(OutboundDataDocument.many(Many(ids))); } @override - Future fetchRelationship( + Future fetchRelationship( HttpRequest request, RelationshipTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final doc = OutboundDataDocument.one( One(nullable(Identifier.fromKey)(model.one[target.relationship]))); - return Response.ok(doc); + return JsonApiResponse.ok(doc); } if (model.many.containsKey(target.relationship)) { final doc = OutboundDataDocument.many( Many(model.many[target.relationship].map(Identifier.fromKey))); - return Response.ok(doc); + return JsonApiResponse.ok(doc); } // TODO: implement fetchRelationship throw UnimplementedError(); } @override - Future fetchRelated( + Future fetchRelated( HttpRequest request, RelatedTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final related = await _fetchRelatedResource(model.one[target.relationship]); final doc = OutboundDataDocument.resource(related); - return Response.ok(doc); + return JsonApiResponse.ok(doc); } if (model.many.containsKey(target.relationship)) { final doc = OutboundDataDocument.collection( await _fetchRelatedCollection(model.many[target.relationship]) .toList()); - return Response.ok(doc); + return JsonApiResponse.ok(doc); } // TODO: implement fetchRelated throw UnimplementedError(); diff --git a/lib/src/server/_internal/repository_error_converter.dart b/lib/src/server/_internal/repository_error_converter.dart index da3d9beb..4c070a3c 100644 --- a/lib/src/server/_internal/repository_error_converter.dart +++ b/lib/src/server/_internal/repository_error_converter.dart @@ -1,13 +1,13 @@ -import 'package:json_api/src/http/http_response.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/src/server/_internal/repo.dart'; -import 'package:json_api/src/server/error_converter.dart'; -import 'package:json_api/src/server/response.dart'; +import 'package:json_api/src/server/json_api_response.dart'; -class RepositoryErrorConverter implements ErrorConverter { +class RepositoryErrorConverter + implements Handler { @override - Future convert(Object error) async { + Future call(Object error) async { if (error is CollectionNotFound) { - return Response.notFound(); + return JsonApiResponse.notFound(); } return null; } diff --git a/lib/src/server/chain_error_converter.dart b/lib/src/server/chain_error_converter.dart index 17101846..634fe9d4 100644 --- a/lib/src/server/chain_error_converter.dart +++ b/lib/src/server/chain_error_converter.dart @@ -1,19 +1,20 @@ -import 'package:json_api/src/http/http_response.dart'; -import 'package:json_api/src/server/error_converter.dart'; +import 'package:json_api/handler.dart'; -class ChainErrorConverter implements ErrorConverter { - ChainErrorConverter(Iterable chain) { +class ChainErrorConverter implements Handler { + ChainErrorConverter( + Iterable> chain, this._defaultResponse) { _chain.addAll(chain); } - final _chain = []; + final _chain = >[]; + final Future Function() _defaultResponse; @override - Future convert(Object error) async { + Future call(E error) async { for (final h in _chain) { - final r = await h.convert(error); + final r = await h.call(error); if (r != null) return r; } - return null; + return await _defaultResponse(); } } diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index e3ae6b5d..46fa9e60 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,42 +1,34 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -abstract class Controller { +abstract class Controller { /// Fetch a primary resource collection - Future fetchCollection( - HttpRequest request, CollectionTarget target); + Future fetchCollection(HttpRequest request, CollectionTarget target); /// Create resource - Future createResource( - HttpRequest request, CollectionTarget target); + Future createResource(HttpRequest request, CollectionTarget target); /// Fetch a single primary resource - Future fetchResource( - HttpRequest request, ResourceTarget target); + Future fetchResource(HttpRequest request, ResourceTarget target); /// Updates a primary resource - Future updateResource( - HttpRequest request, ResourceTarget target); + Future updateResource(HttpRequest request, ResourceTarget target); /// Deletes the primary resource - Future deleteResource( - HttpRequest request, ResourceTarget target); + Future deleteResource(HttpRequest request, ResourceTarget target); /// Fetches a relationship - Future fetchRelationship( - HttpRequest rq, RelationshipTarget target); + Future fetchRelationship(HttpRequest rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany(HttpRequest request, RelationshipTarget target); + Future addMany(HttpRequest request, RelationshipTarget target); /// Updates the relationship - Future replaceRelationship( - HttpRequest request, RelationshipTarget target); + Future replaceRelationship(HttpRequest request, RelationshipTarget target); /// Deletes the members from the to-many relationship - Future deleteMany( - HttpRequest request, RelationshipTarget target); + Future deleteMany(HttpRequest request, RelationshipTarget target); /// Fetches related resource or collection - Future fetchRelated(HttpRequest request, RelatedTarget target); + Future fetchRelated(HttpRequest request, RelatedTarget target); } diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart deleted file mode 100644 index 73bca880..00000000 --- a/lib/src/server/error_converter.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:json_api/http.dart'; - -/// Converts errors to HTTP responses. -abstract class ErrorConverter { - Future convert(Object error); -} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart new file mode 100644 index 00000000..aba0a175 --- /dev/null +++ b/lib/src/server/json_api_response.dart @@ -0,0 +1,33 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// JSON:API response +class JsonApiResponse { + JsonApiResponse(this.statusCode, {this.document}); + + final D /*?*/ document; + final int statusCode; + final headers = Headers(); + + static JsonApiResponse ok(OutboundDocument document) => + JsonApiResponse(200, document: document); + + static JsonApiResponse noContent() => JsonApiResponse(204); + + static JsonApiResponse created(OutboundDocument document, String location) => + JsonApiResponse(201, document: document)..headers['location'] = location; + + static JsonApiResponse notFound({OutboundErrorDocument /*?*/ document}) => + JsonApiResponse(404, document: document); + + static JsonApiResponse methodNotAllowed( + {OutboundErrorDocument /*?*/ document}) => + JsonApiResponse(405, document: document); + + static JsonApiResponse badRequest({OutboundErrorDocument /*?*/ document}) => + JsonApiResponse(400, document: document); + + static JsonApiResponse internalServerError( + {OutboundErrorDocument /*?*/ document}) => + JsonApiResponse(500, document: document); +} diff --git a/lib/src/server/_internal/method_not_allowed.dart b/lib/src/server/method_not_allowed.dart similarity index 100% rename from lib/src/server/_internal/method_not_allowed.dart rename to lib/src/server/method_not_allowed.dart diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart deleted file mode 100644 index 7baf4c34..00000000 --- a/lib/src/server/response.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/http.dart'; -import 'package:json_api/src/nullable.dart'; - -/// JSON:API response -class Response extends HttpResponse { - Response(int statusCode, {Object /*?*/ document}) - : super(statusCode, body: nullable(jsonEncode)(document) ?? '') { - if (body.isNotEmpty) headers['content-type'] = MediaType.jsonApi; - } - - static Response ok(Object document) => Response(200, document: document); - - static Response noContent() => Response(204); - - static Response created(Object document, String location) => - Response(201, document: document)..headers['location'] = location; - - static Response notFound({Object /*?*/ document}) => - Response(404, document: document); - - static Response methodNotAllowed({Object /*?*/ document}) => - Response(405, document: document); - - static Response badRequest({Object /*?*/ document}) => - Response(400, document: document); - - static Response internalServerError({Object /*?*/ document}) => - Response(500, document: document); -} diff --git a/lib/src/server/response_encoder.dart b/lib/src/server/response_encoder.dart new file mode 100644 index 00000000..193e0ca7 --- /dev/null +++ b/lib/src/server/response_encoder.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +/// Converts [JsonApiResponse] to [HttpResponse] +class JsonApiResponseEncoder implements Handler { + JsonApiResponseEncoder(this._handler); + + final Handler _handler; + + @override + Future call(Rq request) async { + final r = await _handler.call(request); + final body = nullable(jsonEncode)(r.document) ?? ''; + final headers = { + ...r.headers, + if (body.isNotEmpty) 'Content-Type': MediaType.jsonApi + }; + return HttpResponse(r.statusCode, body: body)..headers.addAll(headers); + } +} diff --git a/lib/src/server/_internal/routing_http_handler.dart b/lib/src/server/router.dart similarity index 79% rename from lib/src/server/_internal/routing_http_handler.dart rename to lib/src/server/router.dart index 1410c968..ad34e5d2 100644 --- a/lib/src/server/_internal/routing_http_handler.dart +++ b/lib/src/server/router.dart @@ -1,18 +1,19 @@ +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/method_not_allowed.dart'; -import 'package:json_api/src/server/_internal/unmatched_target.dart'; +import 'package:json_api/src/server/method_not_allowed.dart'; +import 'package:json_api/src/server/unmatched_target.dart'; -class RoutingHttpHandler implements HttpHandler { - RoutingHttpHandler(this.controller, {TargetMatcher matcher}) +class Router implements Handler { + Router(this.controller, {TargetMatcher matcher}) : matcher = matcher ?? RecommendedUrlDesign.pathOnly; - final Controller controller; + final Controller controller; final TargetMatcher matcher; @override - Future call(HttpRequest rq) async { + Future call(HttpRequest rq) async { final target = matcher.match(rq.uri); if (target is CollectionTarget) { if (rq.isGet) return controller.fetchCollection(rq, target); diff --git a/lib/src/server/routing_error_converter.dart b/lib/src/server/routing_error_converter.dart new file mode 100644 index 00000000..9e423e6a --- /dev/null +++ b/lib/src/server/routing_error_converter.dart @@ -0,0 +1,19 @@ +import 'package:json_api/handler.dart'; +import 'package:json_api/src/server/json_api_response.dart'; +import 'package:json_api/src/server/method_not_allowed.dart'; +import 'package:json_api/src/server/unmatched_target.dart'; + +class RoutingErrorConverter implements Handler { + const RoutingErrorConverter(); + + @override + Future call(Object error) async { + if (error is MethodNotAllowed) { + return JsonApiResponse.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return JsonApiResponse.badRequest(); + } + return null; + } +} diff --git a/lib/src/server/routing_error_handler.dart b/lib/src/server/routing_error_handler.dart deleted file mode 100644 index 402b7910..00000000 --- a/lib/src/server/routing_error_handler.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/server/_internal/method_not_allowed.dart'; -import 'package:json_api/src/server/_internal/unmatched_target.dart'; -import 'package:json_api/src/server/error_converter.dart'; -import 'package:json_api/src/server/response.dart'; - -class RoutingErrorHandler implements ErrorConverter { - const RoutingErrorHandler(); - - @override - Future convert(Object error) async { - if (error is MethodNotAllowed) { - return Response.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return Response.badRequest(); - } - return null; - } -} diff --git a/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart new file mode 100644 index 00000000..c7e158f3 --- /dev/null +++ b/lib/src/server/try_catch_handler.dart @@ -0,0 +1,25 @@ +import 'package:json_api/handler.dart'; +import 'package:json_api/src/server/json_api_response.dart'; + +/// Calls the wrapped handler within a try-catch block. +/// When a [JsonApiResponse] is thrown, returns it. +/// When any other error is thrown, ties to convert it using [ErrorConverter], +/// or returns an HTTP 500. +class TryCatchHandler implements Handler { + TryCatchHandler(this._handler, this._onError); + + final Handler _handler; + final Handler _onError; + + /// Handles the request by calling the appropriate method of the controller + @override + Future call(Rq request) async { + try { + return await _handler(request); + } on Rs catch (response) { + return response; + } catch (error) { + return await _onError.call(error); + } + } +} diff --git a/lib/src/server/try_catch_http_handler.dart b/lib/src/server/try_catch_http_handler.dart deleted file mode 100644 index 0235d8d8..00000000 --- a/lib/src/server/try_catch_http_handler.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/server/error_converter.dart'; -import 'package:json_api/src/server/response.dart'; - -/// Calls the wrapped handler within a try-catch block. -/// When an [HttpResponse] is thrown, returns it. -/// When any other error is thrown, ties to convert it using [ErrorConverter], -/// or returns an HTTP 500. -class TryCatchHttpHandler implements HttpHandler { - TryCatchHttpHandler(this.httpHandler, this.errorConverter); - - final HttpHandler httpHandler; - final ErrorConverter errorConverter; - - /// Handles the request by calling the appropriate method of the controller - @override - Future call(HttpRequest request) async { - try { - return await httpHandler(request); - } on HttpResponse catch (response) { - return response; - } catch (error) { - return (await errorConverter.convert(error)) ?? - Response.internalServerError(); - } - } -} diff --git a/lib/src/server/_internal/unmatched_target.dart b/lib/src/server/unmatched_target.dart similarity index 100% rename from lib/src/server/_internal/unmatched_target.dart rename to lib/src/server/unmatched_target.dart diff --git a/lib/src/test/mock_handler.dart b/lib/src/test/mock_handler.dart index 88c21d2f..3382349f 100644 --- a/lib/src/test/mock_handler.dart +++ b/lib/src/test/mock_handler.dart @@ -1,11 +1,11 @@ -import 'package:json_api/http.dart'; +import 'package:json_api/handler.dart'; -class MockHandler implements HttpHandler { - HttpResponse /*?*/ response; - HttpRequest /*?*/ request; +class MockHandler implements Handler { + Rq /*?*/ request; + Rs /*?*/ response; @override - Future call(HttpRequest request) async { + Future call(Rq request) async { this.request = request; return response; } diff --git a/pubspec.yaml b/pubspec.yaml index 3c196d2e..ffb1b463 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-dev.4 +version: 5.0.0-dev.5 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: @@ -7,6 +7,7 @@ environment: dependencies: http: ^0.12.2 dev_dependencies: + json_api_server: ^0.1.0-dev uuid: ^2.2.2 pedantic: ^1.9.2 test: ^1.15.4 diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index bcbf74ce..c30a316a 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,5 +1,6 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -7,7 +8,7 @@ import 'package:test/test.dart'; import 'shared.dart'; void main() { - HttpHandler server; + Handler server; JsonApiClient client; setUp(() async { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 77709223..7e4b9b8d 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,4 +1,5 @@ import 'package:json_api/client.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -6,7 +7,7 @@ import 'package:test/test.dart'; import 'shared.dart'; void main() { - HttpHandler server; + Handler server; JsonApiClient client; setUp(() async { @@ -43,7 +44,8 @@ void main() { }); test('404', () async { final actions = [ - () => client.fetchCollection('unicorns') + () => client.fetchCollection('unicorns'), + () => client.fetchResource('posts', '1'), ]; for (final action in actions) { try { diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 8fc183e5..21f90827 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,4 +1,5 @@ import 'package:json_api/client.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; @@ -7,7 +8,7 @@ import 'package:uuid/uuid.dart'; import 'shared.dart'; void main() { - HttpHandler server; + Handler server; JsonApiClient client; setUp(() async { diff --git a/test/contract/shared.dart b/test/contract/shared.dart index 1bab0e3f..74639c90 100644 --- a/test/contract/shared.dart +++ b/test/contract/shared.dart @@ -1,19 +1,23 @@ -import 'package:json_api/client.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_http_handler.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; import 'package:json_api/src/server/_internal/repository_controller.dart'; import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/_internal/routing_http_handler.dart'; import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:json_api/src/server/routing_error_handler.dart'; +import 'package:json_api/src/server/response_encoder.dart'; +import 'package:json_api/src/server/router.dart'; +import 'package:json_api/src/server/routing_error_converter.dart'; import 'package:uuid/uuid.dart'; - - - -HttpHandler initServer() => TryCatchHttpHandler( - RoutingHttpHandler(RepositoryController( - InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), - ChainErrorConverter([RepositoryErrorConverter(), RoutingErrorHandler()])); +Handler initServer() { + final repo = InMemoryRepo(['users', 'posts', 'comments']); + final controller = RepositoryController(repo, Uuid().v4); + final errorConverter = ChainErrorConverter([ + RepositoryErrorConverter(), + RoutingErrorConverter(), + ], () async => JsonApiResponse.internalServerError()); + return CorsHttpHandler(JsonApiResponseEncoder( + TryCatchHandler(Router(controller), errorConverter))); +} diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 3d7cc1ae..1f1e952f 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -3,7 +3,7 @@ import 'package:json_api/routing.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:test/test.dart'; -import '../shared.dart'; +import 'shared.dart'; void main() { diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 92dabd5e..b2d3942a 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,22 +1,9 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_handler.dart'; -import 'package:json_api/src/server/_internal/demo_server.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/_internal/routing_http_handler.dart'; -import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:json_api/src/server/routing_error_handler.dart'; import 'package:stream_channel/stream_channel.dart'; -import 'package:uuid/uuid.dart'; + +import '../src/demo_server.dart'; void hybridMain(StreamChannel channel, Object message) async { - final handler = CorsHandler(TryCatchHttpHandler( - RoutingHttpHandler(RepositoryController(InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)), - ChainErrorConverter([RepositoryErrorConverter(), RoutingErrorHandler()]), - )); - final demo = - DemoServer(handler, port: 8000); - await demo.start(); - channel.sink.add(demo.uri.toString()); + final server = demoServer(port: 8000); + await server.start(); + channel.sink.add(server.uri.toString()); } diff --git a/test/e2e/integration_test.dart b/test/e2e/integration_test.dart new file mode 100644 index 00000000..082d89b1 --- /dev/null +++ b/test/e2e/integration_test.dart @@ -0,0 +1,27 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api_server/json_api_server.dart'; +import 'package:test/test.dart'; + +import '../src/demo_server.dart'; +import 'shared.dart'; + +void main() { + JsonApiClient client; + JsonApiServer server; + + setUp(() async { + server = demoServer(port: 8001); + await server.start(); + client = JsonApiClient(RecommendedUrlDesign(server.uri)); + }); + + tearDown(() async { + await server.stop(); + }); + + group('Integration', () { + test('Client and server can interact over HTTP', + () => expectAllHttpMethodsToWork(client)); + }, testOn: 'vm'); +} diff --git a/test/shared.dart b/test/e2e/shared.dart similarity index 100% rename from test/shared.dart rename to test/e2e/shared.dart diff --git a/test/handler/logging_handler_test.dart b/test/handler/logging_handler_test.dart new file mode 100644 index 00000000..ae4aad67 --- /dev/null +++ b/test/handler/logging_handler_test.dart @@ -0,0 +1,19 @@ +import 'package:json_api/handler.dart'; +import 'package:test/test.dart'; + +void main() { + test('Logging handler can log', () async { + String loggedRequest; + String loggedResponse; + + final handler = + LoggingHandler(FunHandler((String s) async => s.toUpperCase()), (rq) { + loggedRequest = rq; + }, (rs) { + loggedResponse = rs; + }); + expect(await handler('foo'), 'FOO'); + expect(loggedRequest, 'foo'); + expect(loggedResponse, 'FOO'); + }); +} diff --git a/test/integration_test.dart b/test/integration_test.dart deleted file mode 100644 index 341e9041..00000000 --- a/test/integration_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/_internal/demo_server.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/routing_http_handler.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import 'shared.dart'; - -void main() { - JsonApiClient client; - DemoServer server; - - setUp(() async { - final handler = - RoutingHttpHandler(RepositoryController(InMemoryRepo(['users', 'posts', 'comments']), Uuid().v4)); - server = DemoServer(handler, port: 8001); - await server.start(); - client = JsonApiClient(RecommendedUrlDesign(server.uri)); - }); - - tearDown(() async { - await server.stop(); - }); - - test('Client and server can interact over HTTP', - () => expectAllHttpMethodsToWork(client)); -} diff --git a/test/src/demo_server.dart b/test/src/demo_server.dart new file mode 100644 index 00000000..084e0bb9 --- /dev/null +++ b/test/src/demo_server.dart @@ -0,0 +1,21 @@ +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_http_handler.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/_internal/repository_error_converter.dart'; +import 'package:json_api/src/server/response_encoder.dart'; +import 'package:json_api_server/json_api_server.dart'; +import 'package:uuid/uuid.dart'; + +JsonApiServer demoServer({int port = 8080}) { + final repo = InMemoryRepo(['users', 'posts', 'comments']); + final controller = RepositoryController(repo, Uuid().v4); + final errorConverter = ChainErrorConverter([ + RepositoryErrorConverter(), + RoutingErrorConverter(), + ], () async => JsonApiResponse.internalServerError()); + final handler = CorsHttpHandler(JsonApiResponseEncoder( + TryCatchHandler(Router(controller), errorConverter))); + + return JsonApiServer(handler, port: port); +} diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 77aa11c1..010ef349 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -2,13 +2,14 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/test/mock_handler.dart'; import 'package:json_api/src/test/response.dart' as mock; import 'package:test/test.dart'; void main() { - final http = MockHandler(); + final http = MockHandler(); final client = JsonApiClient(RecommendedUrlDesign(Uri(path: '/')), httpHandler: http); @@ -20,8 +21,8 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); - expect(e.document.errors.first.title, 'Invalid Attribute'); + expect(e.errors.first.status, '422'); + expect(e.errors.first.title, 'Invalid Attribute'); } }); test('ServerError', () async { @@ -193,7 +194,7 @@ void main() { test('Missing resource', () async { http.response = mock.relatedResourceNull; final response = - await client.fetchRelatedResource('articles', '1', 'author'); + await client.fetchRelatedResource('articles', '1', 'author'); expect(response.resource, isNull); expect(response.included, isEmpty); expect(http.request.method, 'get'); @@ -493,7 +494,7 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); + expect(e.errors.first.status, '422'); } }); @@ -544,7 +545,7 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); + expect(e.errors.first.status, '422'); } }); @@ -602,7 +603,7 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); + expect(e.errors.first.status, '422'); } }); @@ -662,7 +663,7 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); + expect(e.errors.first.status, '422'); } }); @@ -722,7 +723,7 @@ void main() { fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); - expect(e.document.errors.first.status, '422'); + expect(e.errors.first.status, '422'); expect(e.toString(), 'JSON:API request failed with HTTP status 422'); } }); diff --git a/test/unit/http/logging_http_handler_test.dart b/test/unit/http/logging_http_handler_test.dart deleted file mode 100644 index f610e648..00000000 --- a/test/unit/http/logging_http_handler_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/src/http/callback_http_logger.dart'; -import 'package:test/test.dart'; - -void main() { - test('Logging handler can log', () async { - final rq = HttpRequest('get', Uri.parse('http://localhost')); - final rs = HttpResponse(200, body: 'Hello'); - final log = CallbackHttpLogger(onRequest: (r) { - expect(r, same(rq)); - }, onResponse: (r) { - expect(r, same(rs)); - }); - final handler = - LoggingHttpHandler(HttpHandler.fromFunction((_) async => rs), log); - await handler(rq); - }); -} diff --git a/test/unit/server/try_catch_http_handler_test.dart b/test/unit/server/try_catch_http_handler_test.dart index 0c6a855a..0336cad1 100644 --- a/test/unit/server/try_catch_http_handler_test.dart +++ b/test/unit/server/try_catch_http_handler_test.dart @@ -1,3 +1,4 @@ +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/server.dart'; import 'package:json_api/src/server/chain_error_converter.dart'; @@ -5,7 +6,10 @@ import 'package:test/test.dart'; void main() { test('HTTP 500 is returned', () async { - await TryCatchHttpHandler(Oops(), ChainErrorConverter([])) + await TryCatchHandler( + Oops(), + ChainErrorConverter( + [], () async => JsonApiResponse.internalServerError())) .call(HttpRequest('get', Uri.parse('/'))) .then((r) { expect(r.statusCode, 500); @@ -13,9 +17,9 @@ void main() { }); } -class Oops implements HttpHandler { +class Oops implements Handler { @override - Future call(HttpRequest request) { + Future call(HttpRequest request) { throw 'Oops'; } } From d130baba937721277f52852cf1e5ef5d3ee4dcb6 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sun, 29 Nov 2020 11:21:20 -0800 Subject: [PATCH 85/99] 5.0.0-nullsafety.6 --- .github/workflows/dart.yml | 8 +- example/server.dart | 42 ++-- lib/client.dart | 2 +- lib/core.dart | 40 ++++ lib/document.dart | 1 - lib/handler.dart | 54 +++-- lib/http.dart | 1 - lib/routing.dart | 1 - lib/server.dart | 2 - lib/src/client/collection.dart | 22 ++ lib/src/client/dart_http.dart | 36 --- lib/src/client/handlers.dart | 36 +++ lib/src/client/identity_collection.dart | 18 -- lib/src/client/json_api_client.dart | 94 ++++---- lib/src/client/request.dart | 2 +- .../client/response/collection_response.dart | 12 +- .../response/fetch_collection_response.dart | 22 ++ .../fetch_primary_resource_response.dart | 19 ++ .../response/fetch_resource_response.dart | 19 ++ lib/src/client/response/fetch_response.dart | 20 ++ .../response/new_resource_response.dart | 12 +- .../response/relationship_response.dart | 17 +- lib/src/client/response/request_failure.dart | 4 +- .../client/response/resource_response.dart | 14 +- lib/src/document/error_object.dart | 5 +- lib/src/document/identifier.dart | 22 +- lib/src/document/identity.dart | 19 -- lib/src/document/inbound_document.dart | 34 +-- lib/src/document/many.dart | 18 +- lib/src/document/new_resource.dart | 4 +- lib/src/document/one.dart | 23 +- lib/src/document/outbound_document.dart | 12 +- lib/src/document/resource.dart | 43 +--- lib/src/document/resource_properties.dart | 38 ++- lib/src/http/headers.dart | 29 --- lib/src/http/http_message.dart | 13 + lib/src/http/http_request.dart | 15 +- lib/src/http/http_response.dart | 12 +- lib/src/nullable.dart | 2 +- lib/src/query/fields.dart | 6 +- lib/src/query/filter.dart | 4 +- lib/src/query/page.dart | 4 +- lib/src/routing/recommended_url_design.dart | 18 +- lib/src/routing/reference.dart | 32 --- lib/src/routing/target.dart | 35 +-- lib/src/routing/target_matcher.dart | 2 +- .../server/_internal/cors_http_handler.dart | 20 +- lib/src/server/_internal/entity.dart | 7 - lib/src/server/_internal/in_memory_repo.dart | 71 +++--- .../server/_internal/relationship_node.dart | 3 +- lib/src/server/_internal/repo.dart | 65 ++--- .../_internal/repository_controller.dart | 142 +++++------ .../_internal/repository_error_converter.dart | 14 -- lib/src/server/chain_error_converter.dart | 20 -- lib/src/server/json_api_response.dart | 27 ++- lib/src/server/response_encoder.dart | 24 -- lib/src/server/router.dart | 63 +++-- lib/src/server/routing_error_converter.dart | 19 +- lib/src/server/try_catch_handler.dart | 25 -- lib/src/test/mock_handler.dart | 10 +- pubspec.yaml | 10 +- test/contract/crud_test.dart | 103 ++++---- test/contract/errors_test.dart | 28 +-- test/contract/resource_creation_test.dart | 26 +- test/contract/shared.dart | 23 -- test/e2e/browser_test.dart | 18 +- test/e2e/{shared.dart => e2e_test_set.dart} | 19 +- test/e2e/hybrid_server.dart | 6 +- test/e2e/integration_test.dart | 27 --- test/e2e/vm_test.dart | 27 +++ test/handler/logging_handler_test.dart | 11 +- test/src/dart_io_http_handler.dart | 21 ++ test/src/demo_handler.dart | 61 +++++ test/src/demo_server.dart | 21 -- test/src/json_api_server.dart | 59 +++++ test/unit/client/client_test.dart | 226 +++++++++--------- test/unit/document/inbound_document_test.dart | 22 +- test/unit/document/new_resource_test.dart | 6 +- .../unit/document/outbound_document_test.dart | 16 +- test/unit/document/relationship_test.dart | 27 ++- test/unit/document/resource_test.dart | 18 +- test/unit/http/headers_test.dart | 28 --- test/unit/query/include_test.dart | 1 - test/unit/routing/url_test.dart | 8 +- .../server/try_catch_http_handler_test.dart | 25 -- 85 files changed, 1125 insertions(+), 1110 deletions(-) create mode 100644 lib/core.dart create mode 100644 lib/src/client/collection.dart delete mode 100644 lib/src/client/dart_http.dart create mode 100644 lib/src/client/handlers.dart delete mode 100644 lib/src/client/identity_collection.dart create mode 100644 lib/src/client/response/fetch_collection_response.dart create mode 100644 lib/src/client/response/fetch_primary_resource_response.dart create mode 100644 lib/src/client/response/fetch_resource_response.dart create mode 100644 lib/src/client/response/fetch_response.dart delete mode 100644 lib/src/document/identity.dart delete mode 100644 lib/src/http/headers.dart create mode 100644 lib/src/http/http_message.dart delete mode 100644 lib/src/routing/reference.dart delete mode 100644 lib/src/server/_internal/entity.dart delete mode 100644 lib/src/server/_internal/repository_error_converter.dart delete mode 100644 lib/src/server/chain_error_converter.dart delete mode 100644 lib/src/server/response_encoder.dart delete mode 100644 lib/src/server/try_catch_handler.dart delete mode 100644 test/contract/shared.dart rename test/e2e/{shared.dart => e2e_test_set.dart} (56%) delete mode 100644 test/e2e/integration_test.dart create mode 100644 test/e2e/vm_test.dart create mode 100644 test/src/dart_io_http_handler.dart create mode 100644 test/src/demo_handler.dart delete mode 100644 test/src/demo_server.dart create mode 100644 test/src/json_api_server.dart delete mode 100644 test/unit/http/headers_test.dart delete mode 100644 test/unit/server/try_catch_http_handler_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 10b96471..77a9d47b 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest container: - image: google/dart:latest + image: google/dart:beta steps: - uses: actions/checkout@v2 @@ -21,6 +21,8 @@ jobs: - name: Format run: dartfmt --dry-run --set-exit-if-changed lib test - name: Analyzer - run: dart analyze --fatal-infos --fatal-warnings +# run: dart analyze --fatal-infos --fatal-warnings + run: dart analyze --fatal-warnings - name: Tests - run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* \ No newline at end of file +# run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* + run: dart --no-sound-null-safety test diff --git a/example/server.dart b/example/server.dart index 9036ef64..f9429b30 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,43 +1,29 @@ -import 'dart:io'; +// @dart=2.10 +import 'dart:io' as io; -import 'package:json_api/handler.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_http_handler.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/response_encoder.dart'; -import 'package:json_api_server/json_api_server.dart'; -import 'package:uuid/uuid.dart'; +import '../test/src/demo_handler.dart'; +import '../test/src/json_api_server.dart'; Future main() async { - final repo = InMemoryRepo(['users', 'posts', 'comments']); - final controller = RepositoryController(repo, Uuid().v4); - final errorConverter = ChainErrorConverter([ - RepositoryErrorConverter(), - RoutingErrorConverter(), - ], () async => JsonApiResponse.internalServerError()); - final handler = CorsHttpHandler(JsonApiResponseEncoder( - TryCatchHandler(Router(controller), errorConverter))); - final loggingHandler = LoggingHandler( - handler, - (rq) => print([ - '>> ${rq.method.toUpperCase()} ${rq.uri}', + final server = JsonApiServer(DemoHandler( + logRequest: (rq) => print([ + '>> Request >>', + '${rq.method.toUpperCase()} ${rq.uri}', 'Headers: ${rq.headers}', 'Body: ${rq.body}', ].join('\n') + '\n'), - (rs) => print([ - '<< ${rs.statusCode}', + logResponse: (rs) => print([ + '<< Response <<', + 'Status: ${rs.statusCode}', 'Headers: ${rs.headers}', 'Body: ${rs.body}', ].join('\n') + - '\n')); - final server = JsonApiServer(loggingHandler); + '\n'))); - ProcessSignal.sigint.watch().listen((event) async { + io.ProcessSignal.sigint.watch().listen((event) async { await server.stop(); - exit(0); + io.exit(0); }); await server.start(); diff --git a/lib/client.dart b/lib/client.dart index a901bea1..101fa014 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,7 +1,7 @@ library json_api; export 'package:json_api/src/client/request.dart'; -export 'package:json_api/src/client/dart_http.dart'; +export 'package:json_api/src/client/handlers.dart'; export 'package:json_api/src/client/json_api_client.dart'; export 'package:json_api/src/client/response/request_failure.dart'; export 'package:json_api/src/client/response/collection_response.dart'; diff --git a/lib/core.dart b/lib/core.dart new file mode 100644 index 00000000..945dc27d --- /dev/null +++ b/lib/core.dart @@ -0,0 +1,40 @@ +/// A reference to a resource +class Ref { + const Ref(this.type, this.id); + + final String type; + + final String id; + + @override + final hashCode = 0; + + @override + bool operator ==(Object other) => + other is Ref && type == other.type && id == other.id; +} + +class ModelProps { + final attributes = {}; + final one = {}; + final many = >{}; + + void setFrom(ModelProps other) { + other.attributes.forEach((key, value) { + attributes[key] = value; + }); + other.one.forEach((key, value) { + one[key] = value; + }); + other.many.forEach((key, value) { + many[key] = {...value}; + }); + } +} + +/// A model of a resource. Essentially, this is the core of a resource object. +class Model extends ModelProps { + Model(this.ref); + + final Ref ref; +} diff --git a/lib/document.dart b/lib/document.dart index 02beab79..1b1084ed 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -2,7 +2,6 @@ library document; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identity.dart'; export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/many.dart'; diff --git a/lib/handler.dart b/lib/handler.dart index 303d2b50..2610ba33 100644 --- a/lib/handler.dart +++ b/lib/handler.dart @@ -4,25 +4,19 @@ library handler; /// A generic async handler abstract class Handler { - Future call(Rq request); -} - -/// A generic async handler function -typedef HandlerFun = Future Function(Rq request); + static Handler lambda(Future Function(Rq request) fun) => + _FunHandler(fun); -/// Generic handler from function -class FunHandler implements Handler { - const FunHandler(this._fun); - - final HandlerFun _fun; - - @override - Future call(Rq request) => _fun(request); + Future call(Rq request); } /// A wrapper over [Handler] which allows logging class LoggingHandler implements Handler { - LoggingHandler(this._handler, this._onRequest, this._onResponse); + LoggingHandler(this._handler, + {void Function(Rq request)? onRequest, + void Function(Rs response)? onResponse}) + : _onRequest = onRequest ?? _nothing, + _onResponse = onResponse ?? _nothing; final Handler _handler; final void Function(Rq request) _onRequest; @@ -36,3 +30,35 @@ class LoggingHandler implements Handler { return response; } } + +/// Calls the wrapped handler within a try-catch block. +/// When a response object is thrown, returns it. +/// When any other error is thrown, converts it using the callback. +class TryCatchHandler implements Handler { + TryCatchHandler(this._handler, this._onError); + + final Handler _handler; + final Future Function(dynamic error) _onError; + + @override + Future call(Rq request) async { + try { + return await _handler(request); + } on Rs catch (response) { + return response; + } catch (error) { + return await _onError(error); + } + } +} + +class _FunHandler implements Handler { + _FunHandler(this.handle); + + final Future Function(Rq request) handle; + + @override + Future call(Rq request) => handle(request); +} + +void _nothing(dynamic any) {} diff --git a/lib/http.dart b/lib/http.dart index ea582a7d..447b3c50 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,7 +1,6 @@ /// This is a thin HTTP layer abstraction used by the client library http; -export 'package:json_api/src/http/headers.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; export 'package:json_api/src/http/media_type.dart'; diff --git a/lib/routing.dart b/lib/routing.dart index d707e384..51803c72 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -3,7 +3,6 @@ library routing; export 'package:json_api/src/routing/recommended_url_design.dart'; -export 'package:json_api/src/routing/reference.dart'; export 'package:json_api/src/routing/target.dart'; export 'package:json_api/src/routing/target_matcher.dart'; export 'package:json_api/src/routing/uri_factory.dart'; diff --git a/lib/server.dart b/lib/server.dart index b3b291f7..7d1c86e5 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,6 +1,4 @@ -export 'package:json_api/src/server/chain_error_converter.dart'; export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/json_api_response.dart'; export 'package:json_api/src/server/routing_error_converter.dart'; export 'package:json_api/src/server/router.dart'; -export 'package:json_api/src/server/try_catch_handler.dart'; diff --git a/lib/src/client/collection.dart b/lib/src/client/collection.dart new file mode 100644 index 00000000..834f0415 --- /dev/null +++ b/lib/src/client/collection.dart @@ -0,0 +1,22 @@ +import 'dart:collection'; + +import 'package:json_api/core.dart'; +import 'package:json_api/document.dart'; + +/// A collection of objects indexed by ref. +class ResourceCollection with IterableMixin { + final _map = {}; + + Resource? operator [](Object? key) => _map[key]; + + void add(Resource resource) { + _map[resource.ref] = resource; + } + + void addAll(Iterable resources) { + resources.forEach(add); + } + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart deleted file mode 100644 index 612ca8eb..00000000 --- a/lib/src/client/dart_http.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:http/http.dart'; -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; - -/// A handler using the built-in http client -class DartHttp implements Handler { - /// Creates an instance of [DartHttp]. - /// If [client] is passed, it will be used to keep a persistent connection. - /// In this case it is your responsibility to call [Client.close]. - /// If [client] is omitted, a new connection will be established for each call. - const DartHttp({Client client}) : _client = client; - - final Client _client; - - @override - Future call(HttpRequest request) async { - final response = await _call(Request(request.method, request.uri) - ..headers.addAll(request.headers) - ..body = request.body); - return HttpResponse(response.statusCode, body: response.body) - ..headers.addAll(response.headers); - } - - Future _call(Request request) async { - if (_client != null) return await _send(request, _client); - final tempClient = Client(); - try { - return await _send(request, tempClient); - } finally { - tempClient.close(); - } - } - - Future _send(Request request, Client client) async => - await Response.fromStream(await client.send(request)); -} diff --git a/lib/src/client/handlers.dart b/lib/src/client/handlers.dart new file mode 100644 index 00000000..2b38baa0 --- /dev/null +++ b/lib/src/client/handlers.dart @@ -0,0 +1,36 @@ +import 'package:http/http.dart'; +import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; + +abstract class DartHttpHandler implements Handler { + factory DartHttpHandler([Client? client]) => + client != null ? _Persistent(client) : _OneOff(); +} + +class _Persistent implements DartHttpHandler { + _Persistent(this.client); + + final Client client; + + @override + Future call(HttpRequest request) async { + final response = await Response.fromStream( + await client.send(Request(request.method, request.uri) + ..headers.addAll(request.headers) + ..body = request.body)); + return HttpResponse(response.statusCode, body: response.body) + ..headers.addAll(response.headers); + } +} + +class _OneOff implements DartHttpHandler { + @override + Future call(HttpRequest request) async { + final client = Client(); + try { + return await _Persistent(client).call(request); + } finally { + client.close(); + } + } +} diff --git a/lib/src/client/identity_collection.dart b/lib/src/client/identity_collection.dart deleted file mode 100644 index c9419145..00000000 --- a/lib/src/client/identity_collection.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:collection'; - -import 'package:json_api/document.dart'; - -/// A collection of [Identity] objects. -class IdentityCollection with IterableMixin { - IdentityCollection(Iterable resources) { - resources.forEach((element) => _map[element.key] = element); - } - - final _map = {}; - - /// Returns the element by [key] or null. - T /*?*/ operator [](String key) => _map[key]; - - @override - Iterator get iterator => _map.values.iterator; -} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart index 3477bdd6..3f64692d 100644 --- a/lib/src/client/json_api_client.dart +++ b/lib/src/client/json_api_client.dart @@ -1,4 +1,5 @@ import 'package:json_api/client.dart'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; @@ -6,6 +7,9 @@ import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/client/request.dart'; import 'package:json_api/src/client/response/collection_response.dart'; +import 'package:json_api/src/client/response/fetch_collection_response.dart'; +import 'package:json_api/src/client/response/fetch_primary_resource_response.dart'; +import 'package:json_api/src/client/response/fetch_resource_response.dart'; import 'package:json_api/src/client/response/new_resource_response.dart'; import 'package:json_api/src/client/response/relationship_response.dart'; import 'package:json_api/src/client/response/request_failure.dart'; @@ -14,9 +18,10 @@ import 'package:json_api/src/client/response/response.dart'; /// The JSON:API client class JsonApiClient { - JsonApiClient(this._uriFactory, - {Handler httpHandler}) - : _http = httpHandler ?? DartHttp(); + JsonApiClient( + this._http, + this._uriFactory, + ); final Handler _http; final UriFactory _uriFactory; @@ -26,7 +31,7 @@ class JsonApiClient { /// /// Optional arguments: /// - [headers] - any extra HTTP headers - Future> addMany( + Future> addMany( String type, String id, String relationship, @@ -34,8 +39,8 @@ class JsonApiClient { Map headers = const {}, }) async => RelationshipResponse.decodeMany(await send(Request( - 'post', RelationshipTarget(type, id, relationship), - document: OutboundDataDocument.many(Many(identifiers))) + 'post', RelationshipTarget(Ref(type, id), relationship), + document: OutboundDataDocument.many(ToMany(identifiers))) ..headers.addAll(headers))); /// Creates a new resource in the collection of type [type]. @@ -50,11 +55,11 @@ class JsonApiClient { /// - [headers] - any extra HTTP headers Future createNew( String type, { - Map attributes = const {}, + Map attributes = const {}, Map one = const {}, Map> many = const {}, - Map meta = const {}, - String /*?*/ resourceType, + Map meta = const {}, + String? resourceType, Map headers = const {}, }) async => NewResourceResponse.decode(await send(Request( @@ -63,8 +68,8 @@ class JsonApiClient { OutboundDataDocument.newResource(NewResource(resourceType ?? type) ..attributes.addAll(attributes) ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), }) ..meta.addAll(meta))) ..headers.addAll(headers))); @@ -74,7 +79,7 @@ class JsonApiClient { /// /// Optional arguments: /// - [headers] - any extra HTTP headers - Future> deleteMany( + Future> deleteFromToMany( String type, String id, String relationship, @@ -82,8 +87,8 @@ class JsonApiClient { Map headers = const {}, }) async => RelationshipResponse.decode(await send(Request( - 'delete', RelationshipTarget(type, id, relationship), - document: OutboundDataDocument.many(Many(identifiers))) + 'delete', RelationshipTarget(Ref(type, id), relationship), + document: OutboundDataDocument.many(ToMany(identifiers))) ..headers.addAll(headers))); /// Fetches a primary collection of type [type]. @@ -127,7 +132,7 @@ class JsonApiClient { /// - [include] - request to include related resources /// - [sort] - collection sorting options /// - [fields] - sparse fields options - Future fetchRelatedCollection( + Future fetchRelatedCollection( String type, String id, String relationship, { @@ -139,8 +144,8 @@ class JsonApiClient { Map> fields = const {}, Map query = const {}, }) async => - CollectionResponse.decode(await send( - Request('get', RelatedTarget(type, id, relationship)) + FetchCollectionResponse.decode(await send( + Request('get', RelatedTarget(Ref(type, id), relationship)) ..headers.addAll(headers) ..query.addAll(query) ..page.addAll(page) @@ -149,7 +154,7 @@ class JsonApiClient { ..sort.addAll(sort) ..fields.addAll(fields))); - Future> fetchOne( + Future> fetchToOne( String type, String id, String relationship, { @@ -157,11 +162,11 @@ class JsonApiClient { Map query = const {}, }) async => RelationshipResponse.decodeOne(await send( - Request('get', RelationshipTarget(type, id, relationship)) + Request('get', RelationshipTarget(Ref(type, id), relationship)) ..headers.addAll(headers) ..query.addAll(query))); - Future> fetchMany( + Future> fetchToMany( String type, String id, String relationship, { @@ -169,11 +174,11 @@ class JsonApiClient { Map query = const {}, }) async => RelationshipResponse.decodeMany(await send( - Request('get', RelationshipTarget(type, id, relationship)) + Request('get', RelationshipTarget(Ref(type, id), relationship)) ..headers.addAll(headers) ..query.addAll(query))); - Future fetchRelatedResource( + Future fetchRelatedResource( String type, String id, String relationship, { @@ -183,15 +188,15 @@ class JsonApiClient { Iterable include = const [], Map> fields = const {}, }) async => - ResourceResponse.decode(await send( - Request('get', RelatedTarget(type, id, relationship)) + FetchRelatedResourceResponse.decode(await send( + Request('get', RelatedTarget(Ref(type, id), relationship)) ..headers.addAll(headers) ..query.addAll(query) ..filter.addAll(filter) ..include.addAll(include) ..fields.addAll(fields))); - Future fetchResource( + Future fetchResource( String type, String id, { Map headers = const {}, @@ -200,8 +205,8 @@ class JsonApiClient { Map> fields = const {}, Map query = const {}, }) async => - ResourceResponse.decode(await send( - Request('get', ResourceTarget(type, id)) + FetchPrimaryResourceResponse.decode(await send( + Request('get', ResourceTarget(Ref(type, id))) ..headers.addAll(headers) ..query.addAll(query) ..filter.addAll(filter) @@ -215,12 +220,12 @@ class JsonApiClient { Map meta = const {}, Map headers = const {}}) async => ResourceResponse.decode( - await send(Request('patch', ResourceTarget(type, id), - document: OutboundDataDocument.resource(Resource(type, id) + await send(Request('patch', ResourceTarget(Ref(type, id)), + document: OutboundDataDocument.resource(Resource(Ref(type, id)) ..attributes.addAll(attributes) ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, One(value))), - ...many.map((key, value) => MapEntry(key, Many(value))), + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), }) ..meta.addAll(meta))) ..headers.addAll(headers))); @@ -236,16 +241,16 @@ class JsonApiClient { Map headers = const {}, }) async => ResourceResponse.decode(await send(Request('post', CollectionTarget(type), - document: OutboundDataDocument.resource(Resource(type, id) + document: OutboundDataDocument.resource(Resource(Ref(type, id)) ..attributes.addAll(attributes) ..relationships.addAll({ - ...one.map((k, v) => MapEntry(k, One(v))), - ...many.map((k, v) => MapEntry(k, Many(v))), + ...one.map((k, v) => MapEntry(k, ToOne(v))), + ...many.map((k, v) => MapEntry(k, ToMany(v))), }) ..meta.addAll(meta))) ..headers.addAll(headers))); - Future> replaceOne( + Future> replaceToOne( String type, String id, String relationship, @@ -253,11 +258,11 @@ class JsonApiClient { Map headers = const {}, }) async => RelationshipResponse.decodeOne(await send(Request( - 'patch', RelationshipTarget(type, id, relationship), - document: OutboundDataDocument.one(One(identifier))) + 'patch', RelationshipTarget(Ref(type, id), relationship), + document: OutboundDataDocument.one(ToOne(identifier))) ..headers.addAll(headers))); - Future> replaceMany( + Future> replaceToMany( String type, String id, String relationship, @@ -265,20 +270,21 @@ class JsonApiClient { Map headers = const {}, }) async => RelationshipResponse.decodeMany(await send(Request( - 'patch', RelationshipTarget(type, id, relationship), - document: OutboundDataDocument.many(Many(identifiers))) + 'patch', RelationshipTarget(Ref(type, id), relationship), + document: OutboundDataDocument.many(ToMany(identifiers))) ..headers.addAll(headers))); - Future> deleteOne( + Future> deleteToOne( String type, String id, String relationship, {Map headers = const {}}) async => RelationshipResponse.decodeOne(await send(Request( - 'patch', RelationshipTarget(type, id, relationship), - document: OutboundDataDocument.one(One.empty())) + 'patch', RelationshipTarget(Ref(type, id), relationship), + document: OutboundDataDocument.one(ToOne.empty())) ..headers.addAll(headers))); Future deleteResource(String type, String id) async => - Response.decode(await send(Request('delete', ResourceTarget(type, id)))); + Response.decode( + await send(Request('delete', ResourceTarget(Ref(type, id))))); /// Sends the [request] to the server. /// Throws a [RequestFailure] if the server responds with an error. diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 10b51b2b..73b85986 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -5,7 +5,7 @@ import 'package:json_api/src/nullable.dart'; /// JSON:API request consumed by the client class Request { - Request(this.method, this.target, {Object document}) + Request(this.method, this.target, {Object? document}) : body = nullable(jsonEncode)(document) ?? ''; /// HTTP method diff --git a/lib/src/client/response/collection_response.dart b/lib/src/client/response/collection_response.dart index 947a855a..05b9727a 100644 --- a/lib/src/client/response/collection_response.dart +++ b/lib/src/client/response/collection_response.dart @@ -1,6 +1,6 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/client/identity_collection.dart'; +import 'package:json_api/src/client/collection.dart'; /// A response to a fetch collection request. /// @@ -9,9 +9,9 @@ class CollectionResponse { CollectionResponse(this.http, {Iterable collection = const [], Iterable included = const [], - Map links = const {}}) - : collection = IdentityCollection(collection), - included = IdentityCollection(included) { + Map links = const {}}) { + this.collection.addAll(collection); + this.included.addAll(included); this.links.addAll(links); } @@ -28,10 +28,10 @@ class CollectionResponse { final HttpResponse http; /// The resource collection fetched from the server - final IdentityCollection collection; + final collection = ResourceCollection(); /// Included resources - final IdentityCollection included; + final included = ResourceCollection(); /// Links to iterate the collection final links = {}; diff --git a/lib/src/client/response/fetch_collection_response.dart b/lib/src/client/response/fetch_collection_response.dart new file mode 100644 index 00000000..e61e229b --- /dev/null +++ b/lib/src/client/response/fetch_collection_response.dart @@ -0,0 +1,22 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/collection.dart'; +import 'package:json_api/src/client/response/fetch_response.dart'; + +class FetchCollectionResponse extends FetchResponse { + FetchCollectionResponse(HttpResponse http, Iterable collection, + {Iterable included = const [], + Map links = const {}}) + : super(http, included: included, links: links) { + this.collection.addAll(collection); + } + + static FetchCollectionResponse decode(HttpResponse response) { + final doc = InboundDocument.decode(response.body); + return FetchCollectionResponse(response, doc.resourceCollection(), + links: doc.links, included: doc.included); + } + + /// Fetched collection + final collection = ResourceCollection(); +} diff --git a/lib/src/client/response/fetch_primary_resource_response.dart b/lib/src/client/response/fetch_primary_resource_response.dart new file mode 100644 index 00000000..237f9ed6 --- /dev/null +++ b/lib/src/client/response/fetch_primary_resource_response.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/response/fetch_response.dart'; + +class FetchPrimaryResourceResponse extends FetchResponse { + FetchPrimaryResourceResponse(HttpResponse http, this.resource, + {Iterable included = const [], + Map links = const {}}) + : super(http, included: included, links: links); + + static FetchPrimaryResourceResponse decode(HttpResponse response) { + final doc = InboundDocument.decode(response.body); + return FetchPrimaryResourceResponse(response, doc.resource(), + links: doc.links, included: doc.included); + } + + /// Fetched resource + final Resource resource; +} diff --git a/lib/src/client/response/fetch_resource_response.dart b/lib/src/client/response/fetch_resource_response.dart new file mode 100644 index 00000000..440d2083 --- /dev/null +++ b/lib/src/client/response/fetch_resource_response.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/response/fetch_response.dart'; + +class FetchRelatedResourceResponse extends FetchResponse { + FetchRelatedResourceResponse(HttpResponse http, this.resource, + {Iterable included = const [], + Map links = const {}}) + : super(http, included: included, links: links); + + static FetchRelatedResourceResponse decode(HttpResponse response) { + final doc = InboundDocument.decode(response.body); + return FetchRelatedResourceResponse(response, doc.nullableResource(), + links: doc.links, included: doc.included); + } + + /// Fetched resource + final Resource? resource; +} diff --git a/lib/src/client/response/fetch_response.dart b/lib/src/client/response/fetch_response.dart new file mode 100644 index 00000000..67c89f79 --- /dev/null +++ b/lib/src/client/response/fetch_response.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/collection.dart'; +import 'package:json_api/src/client/response/response.dart'; + +class FetchResponse extends Response { + FetchResponse(HttpResponse http, + {Iterable included = const [], + Map links = const {}}) + : super(http) { + this.included.addAll(included); + this.links.addAll(links); + } + + /// Included resources + final included = ResourceCollection(); + + /// Links to iterate the collection + final links = {}; +} diff --git a/lib/src/client/response/new_resource_response.dart b/lib/src/client/response/new_resource_response.dart index dcad8d61..b3031385 100644 --- a/lib/src/client/response/new_resource_response.dart +++ b/lib/src/client/response/new_resource_response.dart @@ -1,13 +1,13 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/client/identity_collection.dart'; +import 'package:json_api/src/client/collection.dart'; /// A response to a new resource creation request. class NewResourceResponse { NewResourceResponse(this.http, this.resource, {Map links = const {}, - Iterable included = const []}) - : included = IdentityCollection(included) { + Iterable included = const []}) { + this.included.addAll(included); this.links.addAll(links); } @@ -20,11 +20,11 @@ class NewResourceResponse { /// Original HTTP response. final HttpResponse http; - /// Nullable. Created resource. - final Resource /*?*/ resource; + /// Created resource. + final Resource resource; /// Included resources. - final IdentityCollection included; + final included = ResourceCollection(); /// Document links. final links = {}; diff --git a/lib/src/client/response/relationship_response.dart b/lib/src/client/response/relationship_response.dart index 773b4a55..503d91a5 100644 --- a/lib/src/client/response/relationship_response.dart +++ b/lib/src/client/response/relationship_response.dart @@ -1,18 +1,19 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/client/identity_collection.dart'; +import 'package:json_api/src/client/collection.dart'; /// A response to a relationship request. class RelationshipResponse { RelationshipResponse(this.http, this.relationship, - {Iterable included = const []}) - : included = IdentityCollection(included); + {Iterable included = const []}) { + this.included.addAll(included); + } - static RelationshipResponse decodeMany(HttpResponse response) => - decode(response); + static RelationshipResponse decodeMany(HttpResponse response) => + decode(response); - static RelationshipResponse decodeOne(HttpResponse response) => - decode(response); + static RelationshipResponse decodeOne(HttpResponse response) => + decode(response); static RelationshipResponse decode( HttpResponse response) { @@ -29,5 +30,5 @@ class RelationshipResponse { final T relationship; /// Included resources - final IdentityCollection included; + final included = ResourceCollection(); } diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart index d41baed6..17fb424c 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -3,7 +3,7 @@ import 'package:json_api/http.dart'; /// Thrown when the server returns a non-successful response. class RequestFailure implements Exception { - RequestFailure(this.http, {Iterable errors}) { + RequestFailure(this.http, {Iterable errors = const []}) { this.errors.addAll(errors); } @@ -15,5 +15,5 @@ class RequestFailure implements Exception { @override String toString() => - 'JSON:API request failed with HTTP status ${http.statusCode}'; + 'JSON:API request failed with HTTP status ${http.statusCode}. $errors'; } diff --git a/lib/src/client/response/resource_response.dart b/lib/src/client/response/resource_response.dart index 90baea33..04e958eb 100644 --- a/lib/src/client/response/resource_response.dart +++ b/lib/src/client/response/resource_response.dart @@ -1,18 +1,16 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/client/identity_collection.dart'; +import 'package:json_api/src/client/collection.dart'; class ResourceResponse { ResourceResponse(this.http, this.resource, {Map links = const {}, - Iterable included = const []}) - : included = IdentityCollection(included) { + Iterable included = const []}) { + this.included.addAll(included); this.links.addAll(links); } - ResourceResponse.noContent(this.http) - : resource = null, - included = IdentityCollection(const []); + ResourceResponse.noContent(this.http) : resource = null; static ResourceResponse decode(HttpResponse response) { if (response.isNoContent) return ResourceResponse.noContent(response); @@ -25,10 +23,10 @@ class ResourceResponse { final HttpResponse http; /// The created resource. Null for "204 No Content" responses. - final Resource /*?*/ resource; + final Resource? resource; /// Included resources - final IdentityCollection included; + final included = ResourceCollection(); /// Document links final links = {}; diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 6698ef00..ab8f5e04 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -41,7 +41,7 @@ class ErrorObject { final links = {}; /// Meta data. - final meta = {}; + final meta = {}; Map toJson() => { if (id.isNotEmpty) 'id': id, @@ -53,4 +53,7 @@ class ErrorObject { if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; + + @override + String toString() => toJson().toString(); } diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 368c7c35..3b7edda5 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,24 +1,14 @@ -import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/core.dart'; /// A Resource Identifier object -class Identifier with Identity { - Identifier(this.type, this.id); +class Identifier { + Identifier(this.ref); - /// Created a new [Identifier] from an [Identity] key. - static Identifier fromKey(String key) { - final p = Identity.split(key); - return Identifier(p.first, p.last); - } - - @override - final String type; - - @override - final String id; + final Ref ref; /// Identifier meta-data. - final meta = {}; + final meta = {}; Map toJson() => - {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; + {'type': ref.type, 'id': ref.id, if (meta.isNotEmpty) 'meta': meta}; } diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart deleted file mode 100644 index 172b98cc..00000000 --- a/lib/src/document/identity.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// Resource identity. -mixin Identity { - static const separator = ':'; - - /// Makes a string key from [type] and [id] - static String makeKey(String type, String id) => '$type$separator$id'; - - /// Splits the key into the type and id. Returns a list of 2 elements. - static List split(String key) => key.split(separator); - - /// Resource type - String get type; - - /// Resource id - String get id; - - /// Compound key, uniquely identifying the resource - String get key => makeKey(type, id); -} diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index a2b8c500..3cdc499a 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:json_api/src/document/error_source.dart'; import 'package:json_api/src/document/many.dart'; @@ -47,14 +48,13 @@ class InboundDocument { Iterable resourceCollection() => _json.get('data').whereType().map(_resource); - Resource resource() => - _resource(_json.get>('data')); + Resource resource() => _resource(_json.get>('data')); NewResource newResource() => - _newResource(_json.get>('data')); + _newResource(_json.get>('data')); - Resource /*?*/ nullableResource() { - return nullable(_resource)(_json.getNullable('data')); + Resource? nullableResource() { + return nullable(_resource)(_json.get('data')); } R dataAsRelationship() { @@ -73,13 +73,13 @@ class InboundDocument { if (json.containsKey('data')) { final data = json['data']; if (data == null) { - return One.empty()..links.addAll(links)..meta.addAll(meta); + return ToOne.empty()..links.addAll(links)..meta.addAll(meta); } if (data is Map) { - return One(_identifier(data))..links.addAll(links)..meta.addAll(meta); + return ToOne(_identifier(data))..links.addAll(links)..meta.addAll(meta); } if (data is List) { - return Many(data.whereType().map(_identifier)) + return ToMany(data.whereType().map(_identifier)) ..links.addAll(links) ..meta.addAll(meta); } @@ -92,7 +92,7 @@ class InboundDocument { json.get>('meta', orGet: () => {}); static Resource _resource(Map json) => - Resource(json.get('type'), json.get('id')) + Resource(Ref(json.get('type'), json.get('id'))) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getRelationships(json)) ..links.addAll(_links(json)) @@ -100,7 +100,7 @@ class InboundDocument { static NewResource _newResource(Map json) => NewResource( json.get('type'), - json.get('id', orGet: () => null)) + json.containsKey('id') ? json.get('id') : null) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getRelationships(json)) ..meta.addAll(_meta(json)); @@ -108,7 +108,7 @@ class InboundDocument { /// Decodes Identifier from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. static Identifier _identifier(Map json) => - Identifier(json.get('type'), json.get('id')) + Identifier(Ref(json.get('type'), json.get('id'))) ..meta.addAll(_meta(json)); static ErrorObject _errorObject(Map json) => ErrorObject( @@ -146,7 +146,7 @@ class InboundDocument { } extension _TypedGetter on Map { - T get(String key, {T Function() /*?*/ orGet}) { + T get(String key, {T Function()? orGet}) { if (containsKey(key)) { final val = this[key]; if (val is T) return val; @@ -156,14 +156,4 @@ extension _TypedGetter on Map { if (orGet != null) return orGet(); throw FormatException('Key "$key" does not exist'); } - - T /*?*/ getNullable(String key) { - if (containsKey(key)) { - final val = this[key]; - if (val is T || val == null) return val; - throw FormatException( - 'Key "$key": expected $T, found ${val.runtimeType}'); - } - throw FormatException('Key "$key" does not exist'); - } } diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart index 5e94be26..aac2d0ed 100644 --- a/lib/src/document/many.dart +++ b/lib/src/document/many.dart @@ -1,12 +1,15 @@ +import 'package:json_api/core.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/src/client/collection.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/relationship.dart'; -class Many extends Relationship { - Many(Iterable identifiers) { - identifiers.forEach((_) => _map[_.key] = _); +class ToMany extends Relationship { + ToMany(Iterable identifiers) { + identifiers.forEach((_) => _map[_.ref] = _); } - final _map = {}; + final _map = {}; @override Map toJson() => @@ -14,4 +17,11 @@ class Many extends Relationship { @override Iterator get iterator => _map.values.iterator; + + /// Finds the referenced elements which are found in the [collection]. + /// The resulting [Iterable] may contain fewer elements than referred by the + /// relationship if the [collection] does not have all of them. + Iterable findIn(ResourceCollection collection) { + return _map.keys.map((key) => collection[key]).whereType(); + } } diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart index 8d6dcceb..b8ee227c 100644 --- a/lib/src/document/new_resource.dart +++ b/lib/src/document/new_resource.dart @@ -10,11 +10,11 @@ class NewResource with ResourceProperties { final String type; /// Nullable. Resource id. - final String /*?*/ id; + final String? id; Map toJson() => { 'type': type, - if (id != null) 'id': id, + if (id != null) 'id': id!, if (attributes.isNotEmpty) 'attributes': attributes, if (relationships.isNotEmpty) 'relationships': relationships, if (meta.isNotEmpty) 'meta': meta, diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart index 2d816c0e..e7872fe8 100644 --- a/lib/src/document/one.dart +++ b/lib/src/document/one.dart @@ -1,22 +1,23 @@ +import 'package:json_api/src/client/collection.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; -class One extends Relationship { - One(Identifier /*!*/ identifier) : identifier = identifier; +class ToOne extends Relationship { + ToOne(this.identifier); - One.empty() : identifier = null; - - /// Returns the key of the relationship identifier. - /// If the identifier is null, returns an empty string. - String get key => identifier?.key ?? ''; + ToOne.empty() : this(null); @override Map toJson() => {'data': identifier, ...super.toJson()}; - /// Nullable - final Identifier /*?*/ identifier; + final Identifier? identifier; @override - Iterator get iterator => - identifier == null ? [].iterator : [identifier].iterator; + Iterator get iterator => + identifier == null ? [].iterator : [identifier!].iterator; + + /// Finds the referenced resource in the [collection]. + Resource? findIn(ResourceCollection collection) => + collection[identifier?.ref]; } diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index f08c3777..fc85a54c 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -7,7 +7,7 @@ class OutboundDocument { /// The document "meta" object. final meta = {}; - Map toJson() => {'meta': meta}; + Map toJson() => {'meta': meta}; } /// An outbound error document. @@ -29,7 +29,7 @@ class OutboundErrorDocument extends OutboundDocument { /// An outbound data document. class OutboundDataDocument extends OutboundDocument { /// Creates an instance of a document containing a single resource as the primary data. - OutboundDataDocument.resource(Resource resource) : _data = resource; + OutboundDataDocument.resource(Resource? resource) : _data = resource; /// Creates an instance of a document containing a single to-be-created resource as the primary data. Used only in client-to-server requests. OutboundDataDocument.newResource(NewResource resource) : _data = resource; @@ -39,18 +39,18 @@ class OutboundDataDocument extends OutboundDocument { : _data = collection.toList(); /// Creates an instance of a document containing a to-one relationship. - OutboundDataDocument.one(One one) : _data = one.identifier { + OutboundDataDocument.one(ToOne one) : _data = one.identifier { meta.addAll(one.meta); links.addAll(one.links); } /// Creates an instance of a document containing a to-many relationship. - OutboundDataDocument.many(Many many) : _data = many.toList() { + OutboundDataDocument.many(ToMany many) : _data = many.toList() { meta.addAll(many.meta); links.addAll(many.links); } - final Object _data; + final Object? _data; /// Links related to the primary data. final links = {}; @@ -59,7 +59,7 @@ class OutboundDataDocument extends OutboundDocument { final included = []; @override - Map toJson() => { + Map toJson() => { 'data': _data, if (links.isNotEmpty) 'links': links, if (included.isNotEmpty) 'included': included, diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 824ff26c..5c3d526b 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,53 +1,22 @@ +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/identity.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/resource_properties.dart'; -class Resource with ResourceProperties, Identity { - Resource(this.type, this.id) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - } +class Resource with ResourceProperties { + Resource(this.ref); - @override - final String type; - - @override - final String id; + final Ref ref; /// Resource links final links = {}; - /// Converts the resource to its identifier - Identifier toIdentifier() => Identifier(type, id); - - /// Returns a to-one relationship by its [name]. - /// Throws [StateError] if the relationship does not exist. - /// Throws [StateError] if the relationship is not a to-one. - One one(String name) => _rel(name); - - /// Returns a to-many relationship by its [name]. - /// Throws [StateError] if the relationship does not exist. - /// Throws [StateError] if the relationship is not a to-many. - Many many(String name) => _rel(name); - Map toJson() => { - 'type': type, - 'id': id, + 'type': ref.type, + 'id': ref.id, if (attributes.isNotEmpty) 'attributes': attributes, if (relationships.isNotEmpty) 'relationships': relationships, if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; - - /// Returns a typed relationship by its [name]. - /// Throws [StateError] if the relationship does not exist. - /// Throws [StateError] if the relationship is not of the given type. - R _rel(String name) { - final r = relationships[name]; - if (r is R) return r; - throw StateError( - 'Relationship $name (${r.runtimeType}) is not of type ${R}'); - } } diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart index dd8c8a27..1d746025 100644 --- a/lib/src/document/resource_properties.dart +++ b/lib/src/document/resource_properties.dart @@ -1,19 +1,53 @@ +import 'package:json_api/core.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/src/document/many.dart'; import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/document/relationship.dart'; mixin ResourceProperties { /// Resource meta data. - final meta = {}; + final meta = {}; /// Resource attributes. /// /// See https://jsonapi.org/format/#document-resource-object-attributes - final attributes = {}; + final attributes = {}; /// Resource relationships. /// /// See https://jsonapi.org/format/#document-resource-object-relationships final relationships = {}; + ModelProps toModelProps() { + final props = ModelProps(); + attributes.forEach((key, value) { + props.attributes[key] = value; + }); + relationships.forEach((key, value) { + if (value is ToOne) { + props.one[key] = value.identifier?.ref; + return; + } + if (value is ToMany) { + props.many[key] = Set.from(value.map((_) => _.ref)); + return; + } + throw IncompleteRelationship(); + }); + return props; + } + + /// Returns a to-one relationship by its [name]. + ToOne? one(String name) => _rel(name); + + /// Returns a to-many relationship by its [name]. + ToMany? many(String name) => _rel(name); + + /// Returns a typed relationship by its [name]. + R? _rel(String name) { + final r = relationships[name]; + if (r is R) return r; + } } + +class IncompleteRelationship implements Exception {} diff --git a/lib/src/http/headers.dart b/lib/src/http/headers.dart deleted file mode 100644 index c9cab96d..00000000 --- a/lib/src/http/headers.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:collection'; - -/// HTTP headers. All keys are converted to lowercase on the fly. -class Headers with MapMixin { - Headers([Map headers = const {}]) { - addAll(headers); - } - - final _ = {}; - - @override - String /*?*/ operator [](Object /*?*/ key) => - key is String ? _[_convert(key)] : null; - - @override - void operator []=(String key, String value) => _[_convert(key)] = value; - - @override - void clear() => _.clear(); - - @override - Iterable get keys => _.keys; - - @override - String /*?*/ remove(Object /*?*/ key) => - _.remove(key is String ? _convert(key) : key); - - String _convert(String s) => s.toLowerCase(); -} diff --git a/lib/src/http/http_message.dart b/lib/src/http/http_message.dart new file mode 100644 index 00000000..29e1203f --- /dev/null +++ b/lib/src/http/http_message.dart @@ -0,0 +1,13 @@ +import 'dart:collection'; + +class HttpMessage { + HttpMessage(this.body); + + /// Message body + final String body; + + /// Message headers. Case-insensitive. + final headers = LinkedHashMap( + equals: (a, b) => a.toLowerCase() == b.toLowerCase(), + hashCode: (s) => s.toLowerCase().hashCode); +} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 36af87f8..2eef17c0 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,9 +1,10 @@ -import 'package:json_api/src/http/headers.dart'; +import 'package:json_api/src/http/http_message.dart'; /// The request which is sent by the client and received by the server -class HttpRequest { - HttpRequest(String method, this.uri, {this.body = ''}) - : method = method.toLowerCase(); +class HttpRequest extends HttpMessage { + HttpRequest(String method, this.uri, {String body = ''}) + : method = method.toLowerCase(), + super(body); /// Requested URI final Uri uri; @@ -11,12 +12,6 @@ class HttpRequest { /// Request method, lowercase final String method; - /// Request body - final String body; - - /// Request headers. Lowercase keys - final headers = Headers(); - bool get isGet => method == 'get'; bool get isPost => method == 'post'; diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index 66cfb14b..e49b0753 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -1,19 +1,13 @@ -import 'package:json_api/src/http/headers.dart'; +import 'package:json_api/src/http/http_message.dart'; import 'package:json_api/src/http/media_type.dart'; /// The response sent by the server and received by the client -class HttpResponse { - HttpResponse(this.statusCode, {this.body = ''}); +class HttpResponse extends HttpMessage { + HttpResponse(this.statusCode, {String body = ''}) : super(body); /// Response status code final int statusCode; - /// Response body - final String body; - - /// Response headers. Lowercase keys - final headers = Headers(); - /// True for the requests processed asynchronously. /// @see https://jsonapi.org/recommendations/#asynchronous-processing). bool get isPending => statusCode == 202; diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart index 1476f3ab..f6dfb1e4 100644 --- a/lib/src/nullable.dart +++ b/lib/src/nullable.dart @@ -1,2 +1,2 @@ -U /*?*/ Function(V /*?*/ v) nullable(U Function(V v) f) => +U? Function(V? v) nullable(U Function(V v) f) => (v) => v == null ? null : f(v); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index 843b001e..05d99b17 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -22,7 +22,7 @@ class Fields with MapMixin> { static final _regex = RegExp(r'^fields\[(.+)\]$'); - final _map = >{}; + final _map = >{}; /// Converts to a map of query parameters Map get asQueryParameters => @@ -38,8 +38,8 @@ class Fields with MapMixin> { Iterable get keys => _map.keys; @override - List /*?*/ remove(Object /*?*/ key) => _map.remove(key); + Iterable? remove(Object? key) => _map.remove(key); @override - List /*?*/ operator [](Object /*?*/ key) => _map[key]; + Iterable? operator [](Object? key) => _map[key]; } diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart index 4870a3db..ed8d8a46 100644 --- a/lib/src/query/filter.dart +++ b/lib/src/query/filter.dart @@ -26,7 +26,7 @@ class Filter with MapMixin { _.map((k, v) => MapEntry('filter[${k}]', v)); @override - String /*?*/ operator [](Object /*?*/ key) => _[key]; + String? operator [](Object? key) => _[key]; @override void operator []=(String key, String value) => _[key] = value; @@ -38,5 +38,5 @@ class Filter with MapMixin { Iterable get keys => _.keys; @override - String /*?*/ remove(Object /*?*/ key) => _.remove(key); + String? remove(Object? key) => _.remove(key); } diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index 1a730401..a5cf2f88 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -28,7 +28,7 @@ class Page with MapMixin { _.map((k, v) => MapEntry('page[${k}]', v)); @override - String /*?*/ operator [](Object /*?*/ key) => _[key]; + String? operator [](Object? key) => _[key]; @override void operator []=(String key, String value) => _[key] = value; @@ -40,5 +40,5 @@ class Page with MapMixin { Iterable get keys => _.keys; @override - String /*?*/ remove(Object /*?*/ key) => _.remove(key); + String? remove(Object? key) => _.remove(key); } diff --git a/lib/src/routing/recommended_url_design.dart b/lib/src/routing/recommended_url_design.dart index 66d74c4c..aa254cdc 100644 --- a/lib/src/routing/recommended_url_design.dart +++ b/lib/src/routing/recommended_url_design.dart @@ -1,3 +1,4 @@ +import 'package:json_api/core.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/routing/target_matcher.dart'; @@ -22,15 +23,16 @@ class RecommendedUrlDesign implements UriFactory, TargetMatcher { /// Returns a URL for the primary resource of type [type] with id [id]. /// E.g. `/books/123`. @override - Uri resource(ResourceTarget target) => _resolve([target.type, target.id]); + Uri resource(ResourceTarget target) => + _resolve([target.ref.type, target.ref.id]); /// Returns a URL for the relationship itself. /// The [type] and [id] identify the primary resource and the [relationship] /// is the relationship name. /// E.g. `/books/123/relationships/authors`. @override - Uri relationship(RelationshipTarget target) => - _resolve([target.type, target.id, 'relationships', target.relationship]); + Uri relationship(RelationshipTarget target) => _resolve( + [target.ref.type, target.ref.id, 'relationships', target.relationship]); /// Returns a URL for the related resource or collection. /// The [type] and [id] identify the primary resource and the [relationship] @@ -38,22 +40,22 @@ class RecommendedUrlDesign implements UriFactory, TargetMatcher { /// E.g. `/books/123/authors`. @override Uri related(RelatedTarget target) => - _resolve([target.type, target.id, target.relationship]); + _resolve([target.ref.type, target.ref.id, target.relationship]); @override - Target /*?*/ match(Uri uri) { + Target? match(Uri uri) { final s = uri.pathSegments; if (s.length == 1) { return CollectionTarget(s.first); } if (s.length == 2) { - return ResourceTarget(s.first, s.last); + return ResourceTarget(Ref(s.first, s.last)); } if (s.length == 3) { - return RelatedTarget(s.first, s[1], s.last); + return RelatedTarget(Ref(s.first, s[1]), s.last); } if (s.length == 4 && s[2] == 'relationships') { - return RelationshipTarget(s.first, s[1], s.last); + return RelationshipTarget(Ref(s.first, s[1]), s.last); } return null; } diff --git a/lib/src/routing/reference.dart b/lib/src/routing/reference.dart deleted file mode 100644 index 396f5e2c..00000000 --- a/lib/src/routing/reference.dart +++ /dev/null @@ -1,32 +0,0 @@ -/// A reference to a resource collection -class CollectionReference { - const CollectionReference(this.type); - - /// Resource type - final String type; -} - -/// A reference to a resource -class ResourceReference implements CollectionReference { - const ResourceReference(this.type, this.id); - - @override - final String type; - - /// Resource id - final String id; -} - -/// A reference to a resource relationship -class RelationshipReference implements ResourceReference { - const RelationshipReference(this.type, this.id, this.relationship); - - @override - final String type; - - @override - final String id; - - /// Relationship name - final String relationship; -} diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index 76a4b967..c9d1c777 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,10 +1,7 @@ -import 'package:json_api/src/routing/reference.dart'; +import 'package:json_api/core.dart'; /// A request target abstract class Target { - /// Targeted resource type - String get type; - T map(TargetMapper mapper); } @@ -18,31 +15,41 @@ abstract class TargetMapper { T relationship(RelationshipTarget target); } -class CollectionTarget extends CollectionReference implements Target { - const CollectionTarget(String type) : super(type); +class CollectionTarget implements Target { + const CollectionTarget(this.type); + + final String type; @override T map(TargetMapper mapper) => mapper.collection(this); } -class ResourceTarget extends ResourceReference implements Target { - const ResourceTarget(String type, String id) : super(type, id); +class ResourceTarget implements Target { + const ResourceTarget(this.ref); + + final Ref ref; @override T map(TargetMapper mapper) => mapper.resource(this); } -class RelatedTarget extends RelationshipReference implements Target { - const RelatedTarget(String type, String id, String relationship) - : super(type, id, relationship); +class RelatedTarget implements Target { + const RelatedTarget(this.ref, this.relationship); + + final Ref ref; + + final String relationship; @override T map(TargetMapper mapper) => mapper.related(this); } -class RelationshipTarget extends RelationshipReference implements Target { - const RelationshipTarget(String type, String id, String relationship) - : super(type, id, relationship); +class RelationshipTarget implements Target { + const RelationshipTarget(this.ref, this.relationship); + + final Ref ref; + + final String relationship; @override T map(TargetMapper mapper) => mapper.relationship(this); diff --git a/lib/src/routing/target_matcher.dart b/lib/src/routing/target_matcher.dart index d5822c57..709e1d71 100644 --- a/lib/src/routing/target_matcher.dart +++ b/lib/src/routing/target_matcher.dart @@ -2,5 +2,5 @@ import 'package:json_api/src/routing/target.dart'; abstract class TargetMatcher { /// Nullable. Returns the URI target. - Target /*?*/ match(Uri uri); + Target? match(Uri uri); } diff --git a/lib/src/server/_internal/cors_http_handler.dart b/lib/src/server/_internal/cors_http_handler.dart index 03323ace..7e4fe829 100644 --- a/lib/src/server/_internal/cors_http_handler.dart +++ b/lib/src/server/_internal/cors_http_handler.dart @@ -3,25 +3,31 @@ import 'package:json_api/http.dart'; /// An [HttpHandler] wrapper. Adds CORS headers and handles pre-flight requests. class CorsHttpHandler implements Handler { - CorsHttpHandler(this._wrapped); + CorsHttpHandler(this._handler); - final Handler _wrapped; + final Handler _handler; @override Future call(HttpRequest request) async { + final headers = { + 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', + 'Access-Control-Expose-Headers': 'Location', + }; + if (request.isOptions) { + const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; return HttpResponse(204) ..headers.addAll({ - 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', + ...headers, 'Access-Control-Allow-Methods': // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? - request.headers['Access-Control-Request-Method'].toUpperCase(), + request.headers['Access-Control-Request-Method']?.toUpperCase() ?? + methods.join(', '), 'Access-Control-Allow-Headers': request.headers['Access-Control-Request-Headers'] ?? '*', }); } - return await _wrapped(request) - ..headers['Access-Control-Allow-Origin'] = - request.headers['origin'] ?? '*'; + return await _handler(request) + ..headers.addAll(headers); } } diff --git a/lib/src/server/_internal/entity.dart b/lib/src/server/_internal/entity.dart deleted file mode 100644 index 8c606013..00000000 --- a/lib/src/server/_internal/entity.dart +++ /dev/null @@ -1,7 +0,0 @@ -class Entity { - Entity(this.id, this.model); - - final String id; - - final M model; -} diff --git a/lib/src/server/_internal/in_memory_repo.dart b/lib/src/server/_internal/in_memory_repo.dart index 8503eb7b..a44f3166 100644 --- a/lib/src/server/_internal/in_memory_repo.dart +++ b/lib/src/server/_internal/in_memory_repo.dart @@ -1,3 +1,5 @@ +import 'package:json_api/core.dart'; + import 'repo.dart'; class InMemoryRepo implements Repo { @@ -10,67 +12,62 @@ class InMemoryRepo implements Repo { final _storage = >{}; @override - Stream> fetchCollection(String type) async* { - if (!_storage.containsKey(type)) { - throw CollectionNotFound(); - } - for (final e in _storage[type].entries) { - yield Entity(e.key, e.value); - } - } - - @override - Future fetch(String type, String id) async { - return _storage[type][id]; + Stream fetchCollection(String type) { + return Stream.fromIterable(_collection(type).values); } @override - Future persist(String type, String id, Model model) async { - _storage[type][id] = model; + Future fetch(Ref ref) async { + return _model(ref); } @override - Stream addMany( - String type, String id, String rel, Iterable refs) { - final model = _storage[type][id]; - model.addMany(rel, refs); - return Stream.fromIterable(model.many[rel]); + Future persist(Model model) async { + _collection(model.ref.type)[model.ref.id] = model; } @override - Future delete(String type, String id) async { - _storage[type].remove(id); + Stream addMany(Ref ref, String rel, Iterable refs) { + final model = _model(ref); + final many = model.many[rel]; + if (many == null) throw RelationshipNotFound(rel); + many.addAll(refs); + return Stream.fromIterable(many); } @override - Future update(String type, String id, Model model) async { - _storage[type][id].setFrom(model); + Future delete(Ref ref) async { + _collection(ref.type).remove(ref.id); } @override - Future replaceOne( - String type, String id, String relationship, String key) async { - _storage[type][id].one[relationship] = key; + Future update(Ref ref, ModelProps props) async { + _model(ref).setFrom(props); } @override - Future deleteOne(String type, String id, String relationship) async { - _storage[type][id].one[relationship] = null; + Future replaceOne(Ref ref, String rel, Ref? one) async { + _model(ref).one[rel] = one; } @override - Stream deleteMany( - String type, String id, String relationship, Iterable refs) { - _storage[type][id].many[relationship].removeAll(refs); - return Stream.fromIterable(_storage[type][id].many[relationship]); + Stream deleteMany(Ref ref, String rel, Iterable refs) { + return Stream.fromIterable(_many(ref, rel)..removeAll(refs)); } @override - Stream replaceMany( - String type, String id, String relationship, Iterable refs) { - _storage[type][id].many[relationship] + Stream replaceMany(Ref ref, String rel, Iterable refs) { + return Stream.fromIterable(_many(ref, rel) ..clear() - ..addAll(refs); - return Stream.fromIterable(_storage[type][id].many[relationship]); + ..addAll(refs)); } + + Map _collection(String type) => + (_storage[type] ?? (throw CollectionNotFound())); + + Model _model(Ref ref) => + _collection(ref.type)[ref.id] ?? (throw ResourceNotFound()); + + Set _many(Ref ref, String rel) => + _model(ref).many[rel] ?? (throw RelationshipNotFound(rel)); } diff --git a/lib/src/server/_internal/relationship_node.dart b/lib/src/server/_internal/relationship_node.dart index ffaa4e8f..44bf41f4 100644 --- a/lib/src/server/_internal/relationship_node.dart +++ b/lib/src/server/_internal/relationship_node.dart @@ -21,7 +21,6 @@ class RelationshipNode { void add(Iterable chain) { if (chain.isEmpty) return; final key = chain.first; - _map[key] ??= RelationshipNode(key); - _map[key].add(chain.skip(1)); + _map[key] = (_map[key] ?? RelationshipNode(key))..add(chain.skip(1)); } } diff --git a/lib/src/server/_internal/repo.dart b/lib/src/server/_internal/repo.dart index d13db28f..cda7715d 100644 --- a/lib/src/server/_internal/repo.dart +++ b/lib/src/server/_internal/repo.dart @@ -1,67 +1,48 @@ +import 'package:json_api/core.dart'; + abstract class Repo { /// Fetches a collection. /// Throws [CollectionNotFound]. - Stream> fetchCollection(String type); + Stream fetchCollection(String type); - Future fetch(String type, String id); + /// Throws [ResourceNotFound] + Future fetch(Ref ref); - Future persist(String type, String id, Model model); + /// Throws [CollectionNotFound]. + Future persist(Model model); /// Add refs to a to-many relationship - Stream addMany( - String type, String id, String rel, Iterable refs); + /// Throws [CollectionNotFound]. + /// Throws [ResourceNotFound]. + /// Throws [RelationshipNotFound]. + Stream addMany(Ref ref, String rel, Iterable refs); - /// Delete the model - Future delete(String type, String id); + /// Delete the resource + Future delete(Ref ref); /// Updates the model - Future update(String type, String id, Model model); - - Future replaceOne( - String type, String id, String relationship, String key); + Future update(Ref ref, ModelProps props); - Future deleteOne(String type, String id, String relationship); + Future replaceOne(Ref ref, String rel, Ref? one); /// Deletes refs from the to-many relationship. /// Returns the new actual refs. - Stream deleteMany( - String type, String id, String relationship, Iterable refs); + Stream deleteMany(Ref ref, String rel, Iterable refs); /// Replaces refs in the to-many relationship. /// Returns the new actual refs. - Stream replaceMany( - String type, String id, String relationship, Iterable refs); + Stream replaceMany(Ref ref, String rel, Iterable refs); } class CollectionNotFound implements Exception {} -class Entity { - const Entity(this.id, this.model); - - final String id; - - final M model; -} +class ResourceNotFound implements Exception {} -class Model { - final attributes = {}; - final one = {}; - final many = >{}; +class RelationshipNotFound implements Exception { + RelationshipNotFound(this.message); - void addMany(String relationship, Iterable refs) { - many[relationship] ??= {}; - many[relationship].addAll(refs); - } + final String message; - void setFrom(Model other) { - other.attributes.forEach((key, value) { - attributes[key] = value; - }); - other.one.forEach((key, value) { - one[key] = value; - }); - other.many.forEach((key, value) { - many[key] = {...value}; - }); - } + @override + String toString() => message; } diff --git a/lib/src/server/_internal/repository_controller.dart b/lib/src/server/_internal/repository_controller.dart index 4c34234b..0cff41c2 100644 --- a/lib/src/server/_internal/repository_controller.dart +++ b/lib/src/server/_internal/repository_controller.dart @@ -1,3 +1,4 @@ +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; @@ -35,8 +36,7 @@ class RepositoryController implements Controller { @override Future fetchResource( HttpRequest request, ResourceTarget target) async { - final resource = await _fetchLinkedResource(target.type, target.id); - if (resource == null) return JsonApiResponse.notFound(); + final resource = await _fetchLinkedResource(target.ref); final doc = OutboundDataDocument.resource(resource) ..links['self'] = Link(target.map(urlDesign)); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); @@ -50,14 +50,13 @@ class RepositoryController implements Controller { Future createResource( HttpRequest request, CollectionTarget target) async { final res = _decode(request).newResource(); - final id = res.id ?? getId(); - await repo.persist(res.type, id, _toModel(res)); + final ref = Ref(res.type, res.id ?? getId()); + await repo.persist(Model(ref)..setFrom(res.toModelProps())); if (res.id != null) { return JsonApiResponse.noContent(); } - final self = Link(ResourceTarget(target.type, id).map(urlDesign)); - final resource = await _fetchResource(target.type, id) - ..links['self'] = self; + final self = Link(ResourceTarget(ref).map(urlDesign)); + final resource = (await _fetchResource(ref))..links['self'] = self; return JsonApiResponse.created( OutboundDataDocument.resource(resource)..links['self'] = self, self.uri.toString()); @@ -66,27 +65,25 @@ class RepositoryController implements Controller { @override Future addMany( HttpRequest request, RelationshipTarget target) async { - final many = _decode(request).dataAsRelationship(); + final many = _decode(request).dataAsRelationship(); final refs = await repo - .addMany( - target.type, target.id, target.relationship, many.map((_) => _.key)) + .addMany(target.ref, target.relationship, many.map((_) => _.ref)) .toList(); return JsonApiResponse.ok( - OutboundDataDocument.many(Many(refs.map(Identifier.fromKey)))); + OutboundDataDocument.many(ToMany(refs.map(_toIdentifier)))); } @override Future deleteResource( HttpRequest request, ResourceTarget target) async { - await repo.delete(target.type, target.id); + await repo.delete(target.ref); return JsonApiResponse.noContent(); } @override Future updateResource( HttpRequest request, ResourceTarget target) async { - await repo.update( - target.type, target.id, _toModel(_decode(request).resource())); + await repo.update(target.ref, _decode(request).resource().toModelProps()); return JsonApiResponse.noContent(); } @@ -94,23 +91,18 @@ class RepositoryController implements Controller { Future replaceRelationship( HttpRequest request, RelationshipTarget target) async { final rel = _decode(request).dataAsRelationship(); - if (rel is One) { - final id = rel.identifier; - if (id == null) { - await repo.deleteOne(target.type, target.id, target.relationship); - } else { - await repo.replaceOne( - target.type, target.id, target.relationship, id.key); - } - return JsonApiResponse.ok(OutboundDataDocument.one(One(id))); + if (rel is ToOne) { + final ref = rel.identifier?.ref; + await repo.replaceOne(target.ref, target.relationship, ref); + return JsonApiResponse.ok(OutboundDataDocument.one( + ref == null ? ToOne.empty() : ToOne(Identifier(ref)))); } - if (rel is Many) { + if (rel is ToMany) { final ids = await repo - .replaceMany(target.type, target.id, target.relationship, - rel.map((_) => _.key)) - .map(Identifier.fromKey) + .replaceMany(target.ref, target.relationship, rel.map((_) => _.ref)) + .map(_toIdentifier) .toList(); - return JsonApiResponse.ok(OutboundDataDocument.many(Many(ids))); + return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); } throw FormatException('Incomplete relationship'); } @@ -118,27 +110,26 @@ class RepositoryController implements Controller { @override Future deleteMany( HttpRequest request, RelationshipTarget target) async { - final rel = _decode(request).dataAsRelationship(); + final rel = _decode(request).dataAsRelationship(); final ids = await repo - .deleteMany( - target.type, target.id, target.relationship, rel.map((_) => _.key)) - .map(Identifier.fromKey) + .deleteMany(target.ref, target.relationship, rel.map((_) => _.ref)) + .map(_toIdentifier) .toList(); - return JsonApiResponse.ok(OutboundDataDocument.many(Many(ids))); + return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); } @override Future fetchRelationship( HttpRequest request, RelationshipTarget target) async { - final model = await repo.fetch(target.type, target.id); + final model = (await repo.fetch(target.ref)); + if (model.one.containsKey(target.relationship)) { - final doc = OutboundDataDocument.one( - One(nullable(Identifier.fromKey)(model.one[target.relationship]))); - return JsonApiResponse.ok(doc); + return JsonApiResponse.ok(OutboundDataDocument.one( + ToOne(nullable(_toIdentifier)(model.one[target.relationship])))); } - if (model.many.containsKey(target.relationship)) { - final doc = OutboundDataDocument.many( - Many(model.many[target.relationship].map(Identifier.fromKey))); + final many = model.many[target.relationship]; + if (many != null) { + final doc = OutboundDataDocument.many(ToMany(many.map(_toIdentifier))); return JsonApiResponse.ok(doc); } // TODO: implement fetchRelationship @@ -148,23 +139,25 @@ class RepositoryController implements Controller { @override Future fetchRelated( HttpRequest request, RelatedTarget target) async { - final model = await repo.fetch(target.type, target.id); + final model = await repo.fetch(target.ref); if (model.one.containsKey(target.relationship)) { final related = - await _fetchRelatedResource(model.one[target.relationship]); + await nullable(_fetchRelatedResource)(model.one[target.relationship]); final doc = OutboundDataDocument.resource(related); return JsonApiResponse.ok(doc); } - if (model.many.containsKey(target.relationship)) { + final many = model.many[target.relationship]; + if (many != null) { final doc = OutboundDataDocument.collection( - await _fetchRelatedCollection(model.many[target.relationship]) - .toList()); + await _fetchRelatedCollection(many).toList()); return JsonApiResponse.ok(doc); } // TODO: implement fetchRelated throw UnimplementedError(); } + Identifier _toIdentifier(Ref ref) => Identifier(ref); + /// Returns a stream of related resources recursively Stream _getAllRelated( Resource resource, Iterable forest) async* { @@ -178,70 +171,53 @@ class RepositoryController implements Controller { /// Returns a stream of related resources Stream _getRelated(Resource resource, String relationship) async* { - for (final _ in resource.relationships[relationship]) { - final r = await _fetchLinkedResource(_.type, _.id); - if (r != null) yield r; + for (final _ in resource.relationships[relationship] ?? + (throw RelationshipNotFound(relationship))) { + yield await _fetchLinkedResource(_.ref); } } /// Fetches and builds a resource object with a "self" link - Future _fetchLinkedResource(String type, String id) async { - final r = await _fetchResource(type, id); - if (r == null) return null; - return r..links['self'] = Link(ResourceTarget(type, id).map(urlDesign)); + Future _fetchLinkedResource(Ref ref) async { + return (await _fetchResource(ref)) + ..links['self'] = Link(ResourceTarget(ref).map(urlDesign)); } Stream _fetchAll(String type) => - repo.fetchCollection(type).map((e) => _toResource(e.id, type, e.model)); + repo.fetchCollection(type).map(_toResource); /// Fetches and builds a resource object - Future _fetchResource(String type, String id) async { - final model = await repo.fetch(type, id); - if (model == null) return null; - return _toResource(id, type, model); + Future _fetchResource(ref) async { + return _toResource(await repo.fetch(ref)); } - Future _fetchRelatedResource(String key) { - final id = Identifier.fromKey(key); - return _fetchLinkedResource(id.type, id.id); + Future _fetchRelatedResource(Ref ref) { + final id = Identifier(ref); + return _fetchLinkedResource(ref); } - Stream _fetchRelatedCollection(Iterable keys) async* { - for (final key in keys) { - yield await _fetchRelatedResource(key); + Stream _fetchRelatedCollection(Iterable refs) async* { + for (final ref in refs) { + final r = await _fetchRelatedResource(ref); + if (r != null) yield r; } } - Resource _toResource(String id, String type, Model model) { - final res = Resource(type, id); + Resource _toResource(Model model) { + final res = Resource(model.ref); model.attributes.forEach((key, value) { res.attributes[key] = value; }); model.one.forEach((key, value) { - res.relationships[key] = One(nullable(Identifier.fromKey)(value)); + res.relationships[key] = + (value == null ? ToOne.empty() : ToOne(Identifier(value))); }); model.many.forEach((key, value) { - res.relationships[key] = Many(value.map((Identifier.fromKey))); + res.relationships[key] = ToMany(value.map(_toIdentifier)); }); return res; } - Model _toModel(ResourceProperties r) { - final model = Model(); - r.attributes.forEach((key, value) { - model.attributes[key] = value; - }); - r.relationships.forEach((key, value) { - if (value is One) { - model.one[key] = value?.identifier?.key; - } - if (value is Many) { - model.many[key] = Set.from(value.map((_) => _.key)); - } - }); - return model; - } - InboundDocument _decode(HttpRequest r) => InboundDocument.decode(r.body); } diff --git a/lib/src/server/_internal/repository_error_converter.dart b/lib/src/server/_internal/repository_error_converter.dart deleted file mode 100644 index 4c070a3c..00000000 --- a/lib/src/server/_internal/repository_error_converter.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:json_api/src/server/_internal/repo.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -class RepositoryErrorConverter - implements Handler { - @override - Future call(Object error) async { - if (error is CollectionNotFound) { - return JsonApiResponse.notFound(); - } - return null; - } -} diff --git a/lib/src/server/chain_error_converter.dart b/lib/src/server/chain_error_converter.dart deleted file mode 100644 index 634fe9d4..00000000 --- a/lib/src/server/chain_error_converter.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/handler.dart'; - -class ChainErrorConverter implements Handler { - ChainErrorConverter( - Iterable> chain, this._defaultResponse) { - _chain.addAll(chain); - } - - final _chain = >[]; - final Future Function() _defaultResponse; - - @override - Future call(E error) async { - for (final h in _chain) { - final r = await h.call(error); - if (r != null) return r; - } - return await _defaultResponse(); - } -} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index aba0a175..11070a3f 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -1,13 +1,21 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/nullable.dart'; /// JSON:API response -class JsonApiResponse { - JsonApiResponse(this.statusCode, {this.document}); +class JsonApiResponse extends HttpResponse { + JsonApiResponse(int statusCode, {this.document}) : super(statusCode) { + if (document != null) { + headers['Content-Type'] = MediaType.jsonApi; + } + } + + final D? document; - final D /*?*/ document; - final int statusCode; - final headers = Headers(); + @override + String get body => nullable(jsonEncode)(document) ?? ''; static JsonApiResponse ok(OutboundDocument document) => JsonApiResponse(200, document: document); @@ -17,17 +25,16 @@ class JsonApiResponse { static JsonApiResponse created(OutboundDocument document, String location) => JsonApiResponse(201, document: document)..headers['location'] = location; - static JsonApiResponse notFound({OutboundErrorDocument /*?*/ document}) => + static JsonApiResponse notFound([OutboundErrorDocument? document]) => JsonApiResponse(404, document: document); - static JsonApiResponse methodNotAllowed( - {OutboundErrorDocument /*?*/ document}) => + static JsonApiResponse methodNotAllowed([OutboundErrorDocument? document]) => JsonApiResponse(405, document: document); - static JsonApiResponse badRequest({OutboundErrorDocument /*?*/ document}) => + static JsonApiResponse badRequest([OutboundErrorDocument? document]) => JsonApiResponse(400, document: document); static JsonApiResponse internalServerError( - {OutboundErrorDocument /*?*/ document}) => + [OutboundErrorDocument? document]) => JsonApiResponse(500, document: document); } diff --git a/lib/src/server/response_encoder.dart b/lib/src/server/response_encoder.dart deleted file mode 100644 index 193e0ca7..00000000 --- a/lib/src/server/response_encoder.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -/// Converts [JsonApiResponse] to [HttpResponse] -class JsonApiResponseEncoder implements Handler { - JsonApiResponseEncoder(this._handler); - - final Handler _handler; - - @override - Future call(Rq request) async { - final r = await _handler.call(request); - final body = nullable(jsonEncode)(r.document) ?? ''; - final headers = { - ...r.headers, - if (body.isNotEmpty) 'Content-Type': MediaType.jsonApi - }; - return HttpResponse(r.statusCode, body: body)..headers.addAll(headers); - } -} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index ad34e5d2..502145bc 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,42 +1,59 @@ import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; +import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/method_not_allowed.dart'; import 'package:json_api/src/server/unmatched_target.dart'; -class Router implements Handler { - Router(this.controller, {TargetMatcher matcher}) - : matcher = matcher ?? RecommendedUrlDesign.pathOnly; +class Router implements Handler { + Router(this.controller, this.matchTarget); - final Controller controller; - final TargetMatcher matcher; + final Controller controller; + final Target? Function(Uri uri) matchTarget; @override - Future call(HttpRequest rq) async { - final target = matcher.match(rq.uri); + Future call(HttpRequest request) async { + final target = matchTarget(request.uri); if (target is CollectionTarget) { - if (rq.isGet) return controller.fetchCollection(rq, target); - if (rq.isPost) return controller.createResource(rq, target); - throw MethodNotAllowed(rq.method); + if (request.isGet) { + return await controller.fetchCollection(request, target); + } + if (request.isPost) { + return await controller.createResource(request, target); + } + throw MethodNotAllowed(request.method); } if (target is ResourceTarget) { - if (rq.isGet) return controller.fetchResource(rq, target); - if (rq.isDelete) return controller.deleteResource(rq, target); - if (rq.isPatch) return controller.updateResource(rq, target); - throw MethodNotAllowed(rq.method); + if (request.isGet) { + return await controller.fetchResource(request, target); + } + if (request.isPatch) { + return await controller.updateResource(request, target); + } + if (request.isDelete) { + return await controller.deleteResource(request, target); + } + throw MethodNotAllowed(request.method); } if (target is RelationshipTarget) { - if (rq.isGet) return controller.fetchRelationship(rq, target); - if (rq.isPost) return controller.addMany(rq, target); - if (rq.isPatch) return controller.replaceRelationship(rq, target); - if (rq.isDelete) return controller.deleteMany(rq, target); - throw MethodNotAllowed(rq.method); + if (request.isGet) { + return await controller.fetchRelationship(request, target); + } + if (request.isPost) return await controller.addMany(request, target); + if (request.isPatch) { + return await controller.replaceRelationship(request, target); + } + if (request.isDelete) { + return await controller.deleteMany(request, target); + } + throw MethodNotAllowed(request.method); } if (target is RelatedTarget) { - if (rq.isGet) return controller.fetchRelated(rq, target); - throw MethodNotAllowed(rq.method); + if (request.isGet) { + return await controller.fetchRelated(request, target); + } + throw MethodNotAllowed(request.method); } - throw UnmatchedTarget(rq.uri); + throw UnmatchedTarget(request.uri); } } diff --git a/lib/src/server/routing_error_converter.dart b/lib/src/server/routing_error_converter.dart index 9e423e6a..8b92e9a1 100644 --- a/lib/src/server/routing_error_converter.dart +++ b/lib/src/server/routing_error_converter.dart @@ -1,19 +1,12 @@ -import 'package:json_api/handler.dart'; import 'package:json_api/src/server/json_api_response.dart'; import 'package:json_api/src/server/method_not_allowed.dart'; import 'package:json_api/src/server/unmatched_target.dart'; -class RoutingErrorConverter implements Handler { - const RoutingErrorConverter(); - - @override - Future call(Object error) async { - if (error is MethodNotAllowed) { - return JsonApiResponse.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return JsonApiResponse.badRequest(); - } - return null; +Future routingErrorConverter(dynamic error) async { + if (error is MethodNotAllowed) { + return JsonApiResponse.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return JsonApiResponse.badRequest(); } } diff --git a/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart deleted file mode 100644 index c7e158f3..00000000 --- a/lib/src/server/try_catch_handler.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:json_api/src/server/json_api_response.dart'; - -/// Calls the wrapped handler within a try-catch block. -/// When a [JsonApiResponse] is thrown, returns it. -/// When any other error is thrown, ties to convert it using [ErrorConverter], -/// or returns an HTTP 500. -class TryCatchHandler implements Handler { - TryCatchHandler(this._handler, this._onError); - - final Handler _handler; - final Handler _onError; - - /// Handles the request by calling the appropriate method of the controller - @override - Future call(Rq request) async { - try { - return await _handler(request); - } on Rs catch (response) { - return response; - } catch (error) { - return await _onError.call(error); - } - } -} diff --git a/lib/src/test/mock_handler.dart b/lib/src/test/mock_handler.dart index 3382349f..f952fc83 100644 --- a/lib/src/test/mock_handler.dart +++ b/lib/src/test/mock_handler.dart @@ -1,11 +1,11 @@ import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; -class MockHandler implements Handler { - Rq /*?*/ request; - Rs /*?*/ response; +class MockHandler implements Handler { + late HttpRequest request; + late HttpResponse response; - @override - Future call(Rq request) async { + Future call(HttpRequest request) async { this.request = request; return response; } diff --git a/pubspec.yaml b/pubspec.yaml index ffb1b463..dfc05f81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,13 @@ name: json_api -version: 5.0.0-dev.5 +version: 5.0.0-nullsafety.6 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: - sdk: '>=2.10.0 <3.0.0' + sdk: '>=2.12.0-29 <3.0.0' dependencies: http: ^0.12.2 dev_dependencies: - json_api_server: ^0.1.0-dev - uuid: ^2.2.2 - pedantic: ^1.9.2 - test: ^1.15.4 + pedantic: ^1.10.0-nullsafety + test: ^1.16.0-nullsafety test_coverage: ^0.5.0 stream_channel: ^2.0.0 diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index c30a316a..9a7b1732 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,27 +1,23 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import 'shared.dart'; +import '../src/demo_handler.dart'; void main() { - Handler server; - JsonApiClient client; + late JsonApiClient client; setUp(() async { - server = initServer(); - client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); }); group('CRUD', () { - Resource alice; - Resource bob; - Resource post; - Resource comment; - Resource secretComment; + late Resource alice; + late Resource bob; + late Resource post; + late Resource comment; + late Resource secretComment; setUp(() async { alice = (await client.createNew('users', attributes: {'name': 'Alice'})) @@ -30,18 +26,19 @@ void main() { .resource; post = (await client.createNew('posts', attributes: {'title': 'Hello world'}, - one: {'author': alice.toIdentifier()})) + one: {'author': Identifier(alice.ref)}, + many: {'comments': []})) .resource; comment = (await client.createNew('comments', attributes: {'text': 'Hi Alice'}, - one: {'author': bob.toIdentifier()})) + one: {'author': Identifier(bob.ref)})) .resource; secretComment = (await client.createNew('comments', attributes: {'text': 'Secret comment'}, - one: {'author': bob.toIdentifier()})) + one: {'author': Identifier(bob.ref)})) .resource; - await client - .addMany(post.type, post.id, 'comments', [comment.toIdentifier()]); + await client.addMany( + post.ref.type, post.ref.id, 'comments', [Identifier(comment.ref)]); }); test('Fetch a complex resource', () async { @@ -55,23 +52,24 @@ void main() { final fetchedPost = response.collection.first; expect(fetchedPost.attributes['title'], 'Hello world'); - final fetchedAuthor = response.included[fetchedPost.one('author').key]; - expect(fetchedAuthor.attributes['name'], 'Alice'); + final fetchedAuthor = + fetchedPost.one('author')!.findIn(response.included); + expect(fetchedAuthor?.attributes['name'], 'Alice'); final fetchedComment = - response.included[fetchedPost.many('comments').single.key]; + fetchedPost.many('comments')!.findIn(response.included).single; expect(fetchedComment.attributes['text'], 'Hi Alice'); }); test('Delete a resource', () async { - await client.deleteResource(post.type, post.id); + await client.deleteResource(post.ref.type, post.ref.id); await client.fetchCollection('posts').then((r) { expect(r.collection, isEmpty); }); }); test('Update a resource', () async { - await client.updateResource(post.type, post.id, + await client.updateResource(post.ref.type, post.ref.id, attributes: {'title': 'Bob was here'}); await client.fetchCollection('posts').then((r) { expect(r.collection.single.attributes['title'], 'Bob was here'); @@ -79,63 +77,78 @@ void main() { }); test('Fetch a related resource', () async { - await client.fetchRelatedResource(post.type, post.id, 'author').then((r) { - expect(r.resource.attributes['name'], 'Alice'); + await client + .fetchRelatedResource(post.ref.type, post.ref.id, 'author') + .then((r) { + expect(r.resource?.attributes['name'], 'Alice'); }); }); test('Fetch a related collection', () async { await client - .fetchRelatedCollection(post.type, post.id, 'comments') + .fetchRelatedCollection(post.ref.type, post.ref.id, 'comments') .then((r) { expect(r.collection.single.attributes['text'], 'Hi Alice'); }); }); test('Fetch a to-one relationship', () async { - await client.fetchOne(post.type, post.id, 'author').then((r) { - expect(r.relationship.identifier.id, alice.id); + await client.fetchToOne(post.ref.type, post.ref.id, 'author').then((r) { + expect(r.relationship.identifier?.ref, alice.ref); }); }); test('Fetch a to-many relationship', () async { - await client.fetchMany(post.type, post.id, 'comments').then((r) { - expect(r.relationship.single.id, comment.id); + await client + .fetchToMany(post.ref.type, post.ref.id, 'comments') + .then((r) { + expect(r.relationship.single.ref, comment.ref); }); }); test('Delete a to-one relationship', () async { - await client.deleteOne(post.type, post.id, 'author'); - await client - .fetchResource(post.type, post.id, include: ['author']).then((r) { + await client.deleteToOne(post.ref.type, post.ref.id, 'author'); + await client.fetchResource(post.ref.type, post.ref.id, + include: ['author']).then((r) { expect(r.resource.one('author'), isEmpty); }); }); test('Replace a to-one relationship', () async { - await client.replaceOne(post.type, post.id, 'author', bob.toIdentifier()); - await client - .fetchResource(post.type, post.id, include: ['author']).then((r) { - expect( - r.included[r.resource.one('author').key].attributes['name'], 'Bob'); + await client.replaceToOne( + post.ref.type, post.ref.id, 'author', Identifier(bob.ref)); + await client.fetchResource(post.ref.type, post.ref.id, + include: ['author']).then((r) { + expect(r.resource.one('author')?.findIn(r.included)?.attributes['name'], + 'Bob'); }); }); test('Delete from a to-many relationship', () async { - await client - .deleteMany(post.type, post.id, 'comments', [comment.toIdentifier()]); - await client.fetchResource(post.type, post.id).then((r) { + await client.deleteFromToMany( + post.ref.type, post.ref.id, 'comments', [Identifier(comment.ref)]); + await client.fetchResource(post.ref.type, post.ref.id).then((r) { expect(r.resource.many('comments'), isEmpty); }); }); test('Replace a to-many relationship', () async { - await client.replaceMany( - post.type, post.id, 'comments', [secretComment.toIdentifier()]); - await client - .fetchResource(post.type, post.id, include: ['comments']).then((r) { + await client.replaceToMany(post.ref.type, post.ref.id, 'comments', + [Identifier(secretComment.ref)]); + await client.fetchResource(post.ref.type, post.ref.id, + include: ['comments']).then((r) { + expect( + r.resource + .many('comments')! + .findIn(r.included) + .single + .attributes['text'], + 'Secret comment'); expect( - r.included[r.resource.many('comments').single.key] + r.resource + .many('comments')! + .findIn(r.included) + .single .attributes['text'], 'Secret comment'); }); diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 7e4b9b8d..afc98a0d 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,27 +1,26 @@ import 'package:json_api/client.dart'; -import 'package:json_api/handler.dart'; +import 'package:json_api/core.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import 'shared.dart'; +import '../src/demo_handler.dart'; void main() { - Handler server; - JsonApiClient client; + late JsonApiClient client; setUp(() async { - server = initServer(); - client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); }); group('Errors', () { test('Method not allowed', () async { + final ref = Ref('posts', '1'); final badRequests = [ Request('delete', CollectionTarget('posts')), - Request('post', ResourceTarget('posts', '1')), - Request('post', RelatedTarget('posts', '1', 'author')), - Request('head', RelationshipTarget('posts', '1', 'author')), + Request('post', ResourceTarget(ref)), + Request('post', RelatedTarget(ref, 'author')), + Request('head', RelationshipTarget(ref, 'author')), ]; for (final request in badRequests) { try { @@ -33,14 +32,9 @@ void main() { } }); test('Bad request when target can not be matched', () async { - try { - await JsonApiClient(RecommendedUrlDesign(Uri.parse('/a/long/prefix/')), - httpHandler: server) - .fetchCollection('posts'); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 400); - } + final r = await DemoHandler() + .call(HttpRequest('get', Uri.parse('/a/long/prefix/'))); + expect(r.statusCode, 400); }); test('404', () async { final actions = [ diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 21f90827..359c3b05 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,19 +1,14 @@ import 'package:json_api/client.dart'; -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; -import 'shared.dart'; +import '../src/demo_handler.dart'; void main() { - Handler server; - JsonApiClient client; + late JsonApiClient client; setUp(() async { - server = initServer(); - client = JsonApiClient(RecommendedUrlDesign.pathOnly, httpHandler: server); + client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); }); group('Resource creation', () { @@ -22,18 +17,17 @@ void main() { .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { expect(r.http.statusCode, 201); // TODO: Why does "Location" header not work in browsers? - expect(r.http.headers['location'], '/posts/${r.resource.id}'); - expect(r.links['self'].toString(), '/posts/${r.resource.id}'); - expect(r.resource.type, 'posts'); - expect(r.resource.id, isNotEmpty); + expect(r.http.headers['location'], '/posts/${r.resource.ref.id}'); + expect(r.links['self'].toString(), '/posts/${r.resource.ref.id}'); + expect(r.resource.ref.type, 'posts'); expect(r.resource.attributes['title'], 'Hello world'); - expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); + expect( + r.resource.links['self'].toString(), '/posts/${r.resource.ref.id}'); }); }); test('Resource id assigned on the client', () async { - final id = Uuid().v4(); - await client - .create('posts', id, attributes: {'title': 'Hello world'}).then((r) { + await client.create('posts', '12345', + attributes: {'title': 'Hello world'}).then((r) { expect(r.http.statusCode, 204); expect(r.resource, isNull); expect(r.http.headers['location'], isNull); diff --git a/test/contract/shared.dart b/test/contract/shared.dart deleted file mode 100644 index 74639c90..00000000 --- a/test/contract/shared.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_http_handler.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:json_api/src/server/response_encoder.dart'; -import 'package:json_api/src/server/router.dart'; -import 'package:json_api/src/server/routing_error_converter.dart'; -import 'package:uuid/uuid.dart'; - -Handler initServer() { - final repo = InMemoryRepo(['users', 'posts', 'comments']); - final controller = RepositoryController(repo, Uuid().v4); - final errorConverter = ChainErrorConverter([ - RepositoryErrorConverter(), - RoutingErrorConverter(), - ], () async => JsonApiResponse.internalServerError()); - return CorsHttpHandler(JsonApiResponseEncoder( - TryCatchHandler(Router(controller), errorConverter))); -} diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 1f1e952f..16780428 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,24 +1,22 @@ +// @dart=2.9 import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import 'package:stream_channel/stream_channel.dart'; import 'package:test/test.dart'; -import 'shared.dart'; - +import 'e2e_test_set.dart'; void main() { - StreamChannel channel; JsonApiClient client; setUp(() async { - channel = spawnHybridUri('hybrid_server.dart'); + final channel = spawnHybridUri('hybrid_server.dart'); final serverUrl = await channel.stream.first; // final serverUrl = 'http://localhost:8080'; - client = - JsonApiClient(RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); + client = JsonApiClient(DartHttpHandler(), + RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); }); - - test('All possible HTTP methods are usable in browsers', - () => expectAllHttpMethodsToWork(client)); + test('On Browser', () { + e2eTests(client); + }, testOn: 'browser'); } diff --git a/test/e2e/shared.dart b/test/e2e/e2e_test_set.dart similarity index 56% rename from test/e2e/shared.dart rename to test/e2e/e2e_test_set.dart index fb520cb1..c1366187 100644 --- a/test/e2e/shared.dart +++ b/test/e2e/e2e_test_set.dart @@ -1,9 +1,13 @@ import 'package:json_api/client.dart'; import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; -void expectAllHttpMethodsToWork(JsonApiClient client) async { - final id = Uuid().v4(); +void e2eTests(JsonApiClient client) async { + await _testAllHttpMethods(client); + await _testLocationIsSet(client); +} + +Future _testAllHttpMethods(JsonApiClient client) async { + final id = '12345'; // POST await client.create('posts', id, attributes: {'title': 'Hello world'}); // GET @@ -18,6 +22,13 @@ void expectAllHttpMethodsToWork(JsonApiClient client) async { // DELETE await client.deleteResource('posts', id); await client.fetchCollection('posts').then((r) { - expect(r.collection, isEmpty); + expect(r.collection.length, isEmpty); + }); +} + +Future _testLocationIsSet(JsonApiClient client) async { + await client + .createNew('posts', attributes: {'title': 'Location test'}).then((r) { + expect(r.http.headers['Location'], isNotEmpty); }); } diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index b2d3942a..d31742ad 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,9 +1,11 @@ +// @dart=2.10 import 'package:stream_channel/stream_channel.dart'; -import '../src/demo_server.dart'; +import '../src/demo_handler.dart'; +import '../src/json_api_server.dart'; void hybridMain(StreamChannel channel, Object message) async { - final server = demoServer(port: 8000); + final server = JsonApiServer(DemoHandler(), port: 8000); await server.start(); channel.sink.add(server.uri.toString()); } diff --git a/test/e2e/integration_test.dart b/test/e2e/integration_test.dart deleted file mode 100644 index 082d89b1..00000000 --- a/test/e2e/integration_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api_server/json_api_server.dart'; -import 'package:test/test.dart'; - -import '../src/demo_server.dart'; -import 'shared.dart'; - -void main() { - JsonApiClient client; - JsonApiServer server; - - setUp(() async { - server = demoServer(port: 8001); - await server.start(); - client = JsonApiClient(RecommendedUrlDesign(server.uri)); - }); - - tearDown(() async { - await server.stop(); - }); - - group('Integration', () { - test('Client and server can interact over HTTP', - () => expectAllHttpMethodsToWork(client)); - }, testOn: 'vm'); -} diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart new file mode 100644 index 00000000..666d0e07 --- /dev/null +++ b/test/e2e/vm_test.dart @@ -0,0 +1,27 @@ +// @dart=2.10 +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import '../src/demo_handler.dart'; +import '../src/json_api_server.dart'; +import 'e2e_test_set.dart'; + +void main() { + JsonApiClient client; + JsonApiServer server; + + setUp(() async { + server = JsonApiServer(DemoHandler(), port: 8001); + await server.start(); + client = JsonApiClient(DartHttpHandler(), RecommendedUrlDesign(server.uri)); + }); + + tearDown(() async { + await server.stop(); + }); + + test('On VM', () { + e2eTests(client); + }, testOn: 'vm'); +} diff --git a/test/handler/logging_handler_test.dart b/test/handler/logging_handler_test.dart index ae4aad67..0ffe746d 100644 --- a/test/handler/logging_handler_test.dart +++ b/test/handler/logging_handler_test.dart @@ -3,16 +3,17 @@ import 'package:test/test.dart'; void main() { test('Logging handler can log', () async { - String loggedRequest; - String loggedResponse; + String? loggedRequest; + String? loggedResponse; final handler = - LoggingHandler(FunHandler((String s) async => s.toUpperCase()), (rq) { + LoggingHandler(Handler.lambda((String s) async => s.toUpperCase()), + onRequest: (String rq) { loggedRequest = rq; - }, (rs) { + }, onResponse: (String rs) { loggedResponse = rs; }); - expect(await handler('foo'), 'FOO'); + expect(await handler.call('foo'), 'FOO'); expect(loggedRequest, 'foo'); expect(loggedResponse, 'FOO'); }); diff --git a/test/src/dart_io_http_handler.dart b/test/src/dart_io_http_handler.dart new file mode 100644 index 00000000..36635516 --- /dev/null +++ b/test/src/dart_io_http_handler.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; + +Future Function(io.HttpRequest ioRequest) dartIOHttpHandler( + Handler handler, +) => + (request) async { + final headers = {}; + request.headers.forEach((k, v) => headers[k] = v.join(',')); + final response = await handler(HttpRequest( + request.method, request.requestedUri, + body: await request.cast>().transform(utf8.decoder).join()) + ..headers.addAll(headers)); + response.headers.forEach(request.response.headers.add); + request.response.statusCode = response.statusCode; + request.response.write(response.body); + await request.response.close(); + }; diff --git a/test/src/demo_handler.dart b/test/src/demo_handler.dart new file mode 100644 index 00000000..39cb7af6 --- /dev/null +++ b/test/src/demo_handler.dart @@ -0,0 +1,61 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/server/_internal/cors_http_handler.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:json_api/src/server/method_not_allowed.dart'; +import 'package:json_api/src/server/unmatched_target.dart'; + +class DemoHandler implements Handler { + DemoHandler( + {void Function(HttpRequest request)? logRequest, + void Function(HttpResponse response)? logResponse}) { + final repo = InMemoryRepo(['users', 'posts', 'comments']); + + _handler = LoggingHandler( + CorsHttpHandler(TryCatchHandler( + Router(RepositoryController(repo, _nextId), + RecommendedUrlDesign.pathOnly.match), + _onError)), + onResponse: logResponse, + onRequest: logRequest); + } + + late Handler _handler; + + @override + Future call(HttpRequest request) => _handler.call(request); + + static Future _onError(dynamic error) async { + if (error is MethodNotAllowed) { + return JsonApiResponse.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return JsonApiResponse.badRequest(); + } + if (error is CollectionNotFound) { + return JsonApiResponse.notFound( + OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); + } + if (error is ResourceNotFound) { + return JsonApiResponse.notFound( + OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); + } + if (error is RelationshipNotFound) { + return JsonApiResponse.notFound( + OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); + } + return JsonApiResponse.internalServerError(OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); + } +} + +int _id = 0; + +String _nextId() => (_id++).toString(); diff --git a/test/src/demo_server.dart b/test/src/demo_server.dart deleted file mode 100644 index 084e0bb9..00000000 --- a/test/src/demo_server.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_http_handler.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/_internal/repository_error_converter.dart'; -import 'package:json_api/src/server/response_encoder.dart'; -import 'package:json_api_server/json_api_server.dart'; -import 'package:uuid/uuid.dart'; - -JsonApiServer demoServer({int port = 8080}) { - final repo = InMemoryRepo(['users', 'posts', 'comments']); - final controller = RepositoryController(repo, Uuid().v4); - final errorConverter = ChainErrorConverter([ - RepositoryErrorConverter(), - RoutingErrorConverter(), - ], () async => JsonApiResponse.internalServerError()); - final handler = CorsHttpHandler(JsonApiResponseEncoder( - TryCatchHandler(Router(controller), errorConverter))); - - return JsonApiServer(handler, port: port); -} diff --git a/test/src/json_api_server.dart b/test/src/json_api_server.dart new file mode 100644 index 00000000..91ac94b9 --- /dev/null +++ b/test/src/json_api_server.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_api/handler.dart'; +import 'package:json_api/http.dart'; + +class JsonApiServer { + JsonApiServer( + this._handler, { + this.host = 'localhost', + this.port = 8080, + }); + + /// Server host name + final String host; + + /// Server port + final int port; + + final Handler _handler; + HttpServer? _server; + + /// Server uri + Uri get uri => Uri(scheme: 'http', host: host, port: port); + + /// starts the server + Future start() async { + if (_server != null) return; + try { + _server = await _createServer(); + } on Exception { + await stop(); + rethrow; + } + } + + /// Stops the server + Future stop({bool force = false}) async { + await _server?.close(force: force); + _server = null; + } + + Future _createServer() async { + final server = await HttpServer.bind(host, port); + server.listen((request) async { + final headers = {}; + request.headers.forEach((k, v) => headers[k] = v.join(',')); + final response = await _handler.call(HttpRequest( + request.method, request.requestedUri, + body: await request.cast>().transform(utf8.decoder).join()) + ..headers.addAll(headers)); + response.headers.forEach(request.response.headers.add); + request.response.statusCode = response.statusCode; + request.response.write(response.body); + await request.response.close(); + }); + return server; + } +} diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 010ef349..b5aa6c49 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -1,17 +1,16 @@ import 'dart:convert'; import 'package:json_api/client.dart'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/test/mock_handler.dart'; import 'package:json_api/src/test/response.dart' as mock; import 'package:test/test.dart'; void main() { - final http = MockHandler(); - final client = - JsonApiClient(RecommendedUrlDesign(Uri(path: '/')), httpHandler: http); + final http = MockHandler(); + final client = JsonApiClient(http, RecommendedUrlDesign.pathOnly); group('Failure', () { test('RequestFailure', () async { @@ -40,11 +39,12 @@ void main() { test('Min', () async { http.response = mock.collectionMin; final response = await client.fetchCollection('articles'); - expect(response.collection.single.key, 'articles:1'); + expect(response.collection.single.ref.type, 'articles'); + expect(response.collection.single.ref.id, '1'); expect(response.included, isEmpty); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); }); @@ -77,7 +77,7 @@ void main() { 'foo': 'bar' }); expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); }); group('Fetch Related Collection', () { @@ -88,7 +88,7 @@ void main() { expect(response.collection.length, 1); expect(http.request.method, 'get'); expect(http.request.uri.path, '/people/1/articles'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); test('Full', () async { @@ -121,7 +121,7 @@ void main() { 'foo': 'bar' }); expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); }); }); @@ -129,10 +129,10 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.fetchResource('articles', '1'); - expect(response.resource.type, 'articles'); + expect(response.resource.ref.type, 'articles'); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); test('Full', () async { @@ -146,14 +146,14 @@ void main() { ], fields: { 'author': ['name'] }); - expect(response.resource.type, 'articles'); + expect(response.resource.ref.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1'); expect(http.request.uri.queryParameters, {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); }); }); @@ -162,11 +162,11 @@ void main() { http.response = mock.primaryResource; final response = await client.fetchRelatedResource('articles', '1', 'author'); - expect(response.resource?.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); test('Full', () async { @@ -181,14 +181,14 @@ void main() { ], fields: { 'author': ['name'] }); - expect(response.resource?.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1/author'); expect(http.request.uri.queryParameters, {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); }); test('Missing resource', () async { @@ -199,30 +199,30 @@ void main() { expect(response.included, isEmpty); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/author'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); }); group('Fetch Relationship', () { test('Min', () async { http.response = mock.one; - final response = await client.fetchOne('articles', '1', 'author'); + final response = await client.fetchToOne('articles', '1', 'author'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); - expect(http.request.headers, {'accept': 'application/vnd.api+json'}); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); }); test('Full', () async { http.response = mock.one; - final response = await client.fetchOne('articles', '1', 'author', + final response = await client.fetchToOne('articles', '1', 'author', headers: {'foo': 'bar'}, query: {'foo': 'bar'}); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1/relationships/author'); expect(http.request.uri.queryParameters, {'foo': 'bar'}); expect(http.request.headers, - {'accept': 'application/vnd.api+json', 'foo': 'bar'}); + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); }); }); @@ -230,15 +230,15 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.createNew('articles'); - expect(response.resource.type, 'articles'); + expect(response.resource.ref.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'articles'} @@ -250,23 +250,23 @@ void main() { final response = await client.createNew('articles', attributes: { 'cool': true }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource.type, 'articles'); + expect(response.resource.ref.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -298,12 +298,12 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.create('articles', '1'); - expect(response.resource.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'articles', 'id': '1'} @@ -317,8 +317,8 @@ void main() { expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'articles', 'id': '1'} @@ -330,20 +330,20 @@ void main() { final response = await client.create('articles', '1', attributes: { 'cool': true }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource?.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -376,12 +376,12 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.updateResource('articles', '1'); - expect(response.resource?.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'articles', 'id': '1'} @@ -395,8 +395,8 @@ void main() { expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'articles', 'id': '1'} @@ -409,20 +409,20 @@ void main() { await client.updateResource('articles', '1', attributes: { 'cool': true }, one: { - 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource?.type, 'articles'); + expect(response.resource?.ref.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -454,14 +454,14 @@ void main() { group('Replace One', () { test('Min', () async { http.response = mock.one; - final response = await client.replaceOne( - 'articles', '1', 'author', Identifier('people', '42')); - expect(response.relationship, isA()); + final response = await client.replaceToOne( + 'articles', '1', 'author', Identifier(Ref('people', '42'))); + expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': {'type': 'people', 'id': '42'} @@ -470,15 +470,15 @@ void main() { test('Full', () async { http.response = mock.one; - final response = await client.replaceOne( - 'articles', '1', 'author', Identifier('people', '42'), + final response = await client.replaceToOne( + 'articles', '1', 'author', Identifier(Ref('people', '42')), headers: {'foo': 'bar'}); - expect(response.relationship, isA()); + expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -489,8 +489,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client.replaceOne( - 'articles', '1', 'author', Identifier('people', '42')); + await client.replaceToOne( + 'articles', '1', 'author', Identifier(Ref('people', '42'))); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -501,8 +501,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; expect( - () => client.replaceOne( - 'articles', '1', 'author', Identifier('people', '42')), + () => client.replaceToOne( + 'articles', '1', 'author', Identifier(Ref('people', '42'))), throwsFormatException); }); }); @@ -510,14 +510,14 @@ void main() { group('Delete One', () { test('Min', () async { http.response = mock.oneEmpty; - final response = await client.deleteOne('articles', '1', 'author'); - expect(response.relationship, isA()); + final response = await client.deleteToOne('articles', '1', 'author'); + expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), {'data': null}); }); @@ -525,14 +525,14 @@ void main() { test('Full', () async { http.response = mock.oneEmpty; final response = await client - .deleteOne('articles', '1', 'author', headers: {'foo': 'bar'}); - expect(response.relationship, isA()); + .deleteToOne('articles', '1', 'author', headers: {'foo': 'bar'}); + expect(response.relationship, isA()); expect(response.relationship.identifier, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), {'data': null}); @@ -541,7 +541,7 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client.deleteOne('articles', '1', 'author'); + await client.deleteToOne('articles', '1', 'author'); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -551,7 +551,7 @@ void main() { test('Throws FormatException', () async { http.response = mock.many; - expect(() => client.deleteOne('articles', '1', 'author'), + expect(() => client.deleteToOne('articles', '1', 'author'), throwsFormatException); }); }); @@ -559,14 +559,14 @@ void main() { group('Delete Many', () { test('Min', () async { http.response = mock.many; - final response = await client - .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]); - expect(response.relationship, isA()); + final response = await client.deleteFromToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': [ @@ -577,15 +577,15 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client.deleteMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], + final response = await client.deleteFromToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], headers: {'foo': 'bar'}); - expect(response.relationship, isA()); + expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -598,8 +598,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client - .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]); + await client.deleteFromToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -610,8 +610,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () => client - .deleteMany('articles', '1', 'tags', [Identifier('tags', '1')]), + () => client.deleteFromToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), throwsFormatException); }); }); @@ -619,14 +619,14 @@ void main() { group('Replace Many', () { test('Min', () async { http.response = mock.many; - final response = await client - .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]); - expect(response.relationship, isA()); + final response = await client.replaceToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': [ @@ -637,15 +637,15 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client.replaceMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], + final response = await client.replaceToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], headers: {'foo': 'bar'}); - expect(response.relationship, isA()); + expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -658,8 +658,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client - .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]); + await client.replaceToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -670,8 +670,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () => client - .replaceMany('articles', '1', 'tags', [Identifier('tags', '1')]), + () => client.replaceToMany( + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), throwsFormatException); }); }); @@ -680,13 +680,13 @@ void main() { test('Min', () async { http.response = mock.many; final response = await client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); - expect(response.relationship, isA()); + .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json' + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' }); expect(jsonDecode(http.request.body), { 'data': [ @@ -698,14 +698,14 @@ void main() { test('Full', () async { http.response = mock.many; final response = await client.addMany( - 'articles', '1', 'tags', [Identifier('tags', '1')], + 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], headers: {'foo': 'bar'}); - expect(response.relationship, isA()); + expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); expect(http.request.headers, { - 'accept': 'application/vnd.api+json', - 'content-type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', 'foo': 'bar' }); expect(jsonDecode(http.request.body), { @@ -719,12 +719,12 @@ void main() { http.response = mock.error422; try { await client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); + .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); expect(e.errors.first.status, '422'); - expect(e.toString(), 'JSON:API request failed with HTTP status 422'); + expect(e.toString(), contains('422')); } }); @@ -732,7 +732,7 @@ void main() { http.response = mock.one; expect( () => client - .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), + .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), throwsFormatException); }); }); diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 3726e7e1..1d670f89 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -69,8 +69,8 @@ void main() { doc .resourceCollection() .first - .relationships['author'] - .links['self'] + .relationships['author']! + .links['self']! .uri .toString(), 'http://example.com/articles/1/relationships/author'); @@ -82,7 +82,7 @@ void main() { test('can parse primary resource', () { final doc = InboundDocument(payload.resource); final article = doc.resource(); - expect(article.id, '1'); + expect(article.ref.id, '1'); expect(article.attributes['title'], 'JSON:API paints my bikeshed!'); expect(article.relationships['author'], isA()); expect(doc.included, isEmpty); @@ -116,9 +116,9 @@ void main() { test('can parse to-one', () { final doc = InboundDocument(payload.one); - expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isA()); expect(doc.dataAsRelationship(), isNotEmpty); - expect(doc.dataAsRelationship().first.type, 'people'); + expect(doc.dataAsRelationship().first.ref.type, 'people'); expect(doc.included, isEmpty); expect( doc.links['self'].toString(), '/articles/1/relationships/author'); @@ -127,7 +127,7 @@ void main() { test('can parse empty to-one', () { final doc = InboundDocument(payload.oneEmpty); - expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isA()); expect(doc.dataAsRelationship(), isEmpty); expect(doc.included, isEmpty); expect( @@ -137,9 +137,9 @@ void main() { test('can parse to-many', () { final doc = InboundDocument(payload.many); - expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isA()); expect(doc.dataAsRelationship(), isNotEmpty); - expect(doc.dataAsRelationship().first.type, 'tags'); + expect(doc.dataAsRelationship().first.ref.type, 'tags'); expect(doc.included, isEmpty); expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); expect(doc.meta, isEmpty); @@ -147,7 +147,7 @@ void main() { test('can parse empty to-many', () { final doc = InboundDocument(payload.manyEmpty); - expect(doc.dataAsRelationship(), isA()); + expect(doc.dataAsRelationship(), isA()); expect(doc.dataAsRelationship(), isEmpty); expect(doc.included, isEmpty); expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); @@ -178,9 +178,9 @@ void main() { }); test('throws on invalid relationship kind', () { - expect(() => InboundDocument(payload.one).dataAsRelationship(), + expect(() => InboundDocument(payload.one).dataAsRelationship(), throwsFormatException); - expect(() => InboundDocument(payload.many).dataAsRelationship(), + expect(() => InboundDocument(payload.many).dataAsRelationship(), throwsFormatException); }); }); diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart index cb8d93b4..eb871293 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; @@ -14,9 +15,10 @@ void main() { ..meta['foo'] = [42] ..attributes['color'] = 'green' ..relationships['one'] = - (One(Identifier('rel', '1')..meta['rel'] = 1)..meta['one'] = 1) + (ToOne(Identifier(Ref('rel', '1'))..meta['rel'] = 1) + ..meta['one'] = 1) ..relationships['many'] = - (Many([Identifier('rel', '1')..meta['rel'] = 1]) + (ToMany([Identifier(Ref('rel', '1'))..meta['rel'] = 1]) ..meta['many'] = 1)), jsonEncode({ 'type': 'test_type', diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index 3c7aab78..4aecec8b 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; @@ -35,8 +36,8 @@ void main() { }); group('Data', () { - final book = Resource('books', '1'); - final author = Resource('people', '2'); + final book = Resource(Ref('books', '1')); + final author = Resource(Ref('people', '2')); group('Resource', () { test('minimal', () { expect(toObject(OutboundDataDocument.resource(book)), { @@ -85,11 +86,12 @@ void main() { group('One', () { test('minimal', () { - expect(toObject(OutboundDataDocument.one(One.empty())), {'data': null}); + expect( + toObject(OutboundDataDocument.one(ToOne.empty())), {'data': null}); }); test('full', () { expect( - toObject(OutboundDataDocument.one(One(book.toIdentifier()) + toObject(OutboundDataDocument.one(ToOne(Identifier(book.ref)) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -106,11 +108,11 @@ void main() { group('Many', () { test('minimal', () { - expect(toObject(OutboundDataDocument.many(Many([]))), {'data': []}); + expect(toObject(OutboundDataDocument.many(ToMany([]))), {'data': []}); }); test('full', () { expect( - toObject(OutboundDataDocument.many(Many([book.toIdentifier()]) + toObject(OutboundDataDocument.many(ToMany([Identifier(book.ref)]) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -129,4 +131,4 @@ void main() { }); } -Map toObject(v) => jsonDecode(jsonEncode(v)); +Map toObject(v) => jsonDecode(jsonEncode(v)); diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 601bf76d..0e99c8d5 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -1,38 +1,39 @@ +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - final a = Identifier('apples', 'a'); - final b = Identifier('apples', 'b'); + final a = Identifier(Ref('apples', 'a')); + final b = Identifier(Ref('apples', 'b')); group('Relationship', () { test('one', () { - expect(One(a).identifier, a); - expect([...One(a)].first, a); + expect(ToOne(a).identifier, a); + expect([...ToOne(a)].first, a); - expect(One.empty().identifier, isNull); - expect([...One.empty()], isEmpty); + expect(ToOne.empty().identifier, isNull); + expect([...ToOne.empty()], isEmpty); }); test('many', () { - expect(Many([]), isEmpty); - expect([...Many([])], isEmpty); + expect(ToMany([]), isEmpty); + expect([...ToMany([])], isEmpty); - expect(Many([a]), isNotEmpty); + expect(ToMany([a]), isNotEmpty); expect( [ - ...Many([a]) + ...ToMany([a]) ].first, a); - expect(Many([a, b]), isNotEmpty); + expect(ToMany([a, b]), isNotEmpty); expect( [ - ...Many([a, b]) + ...ToMany([a, b]) ].first, a); expect( [ - ...Many([a, b]) + ...ToMany([a, b]) ].last, b); }); diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 5b47d827..8d231d37 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -1,22 +1,24 @@ import 'dart:convert'; +import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { group('Resource', () { test('json encoding', () { - expect(jsonEncode(Resource('test_type', 'test_id')), + expect(jsonEncode(Resource(Ref('test_type', 'test_id'))), jsonEncode({'type': 'test_type', 'id': 'test_id'})); expect( - jsonEncode(Resource('test_type', 'test_id') + jsonEncode(Resource(Ref('test_type', 'test_id')) ..meta['foo'] = [42] ..attributes['color'] = 'green' ..relationships['one'] = - (One(Identifier('rel', '1')..meta['rel'] = 1)..meta['one'] = 1) + (ToOne(Identifier(Ref('rel', '1'))..meta['rel'] = 1) + ..meta['one'] = 1) ..relationships['many'] = - (Many([Identifier('rel', '1')..meta['rel'] = 1]) + (ToMany([Identifier(Ref('rel', '1'))..meta['rel'] = 1]) ..meta['many'] = 1) ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1)), jsonEncode({ @@ -54,11 +56,11 @@ void main() { } })); }); - test('one() throws StateError when relationship does not exist', () { - expect(() => Resource('books', '1').one('author'), throwsStateError); + test('one() return null when relationship does not exist', () { + expect(Resource(Ref('books', '1')).one('author'), isNull); }); - test('many() throws StateError when relationship does not exist', () { - expect(() => Resource('books', '1').many('tags'), throwsStateError); + test('many() returns null when relationship does not exist', () { + expect(Resource(Ref('books', '1')).many('tags'), isNull); }); }); } diff --git a/test/unit/http/headers_test.dart b/test/unit/http/headers_test.dart deleted file mode 100644 index 5c74be26..00000000 --- a/test/unit/http/headers_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - group('Headers', () { - test('add, read, clear', () { - final h = Headers({'Foo': 'Bar'}); - expect(h['Foo'], 'Bar'); - expect(h['foo'], 'Bar'); - expect(h['fOO'], 'Bar'); - expect(h.length, 1); - h['FOO'] = 'Baz'; - expect(h['Foo'], 'Baz'); - expect(h['foo'], 'Baz'); - expect(h['fOO'], 'Baz'); - expect(h.length, 1); - h['hello'] = 'world'; - expect(h.length, 2); - h.remove('foo'); - expect(h['foo'], isNull); - expect(h.length, 1); - h.clear(); - expect(h.length, 0); - expect(h.isEmpty, true); - expect(h.isNotEmpty, false); - }); - }); -} diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index 5526d8eb..ef91bcc9 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -28,5 +28,4 @@ void main() { expect(Include(['author', 'comments.author']).asQueryParameters, {'include': 'author,comments.author'}); }); - } diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index 87272a5e..969e5694 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -1,11 +1,13 @@ +import 'package:json_api/core.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; void main() { final collection = CollectionTarget('books'); - final resource = ResourceTarget('books', '42'); - final related = RelatedTarget('books', '42', 'author'); - final relationship = RelationshipTarget('books', '42', 'author'); + final ref = Ref('books', '42'); + final resource = ResourceTarget(ref); + final related = RelatedTarget(ref, 'author'); + final relationship = RelationshipTarget(ref, 'author'); test('uri generation', () { final url = RecommendedUrlDesign.pathOnly; diff --git a/test/unit/server/try_catch_http_handler_test.dart b/test/unit/server/try_catch_http_handler_test.dart deleted file mode 100644 index 0336cad1..00000000 --- a/test/unit/server/try_catch_http_handler_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/chain_error_converter.dart'; -import 'package:test/test.dart'; - -void main() { - test('HTTP 500 is returned', () async { - await TryCatchHandler( - Oops(), - ChainErrorConverter( - [], () async => JsonApiResponse.internalServerError())) - .call(HttpRequest('get', Uri.parse('/'))) - .then((r) { - expect(r.statusCode, 500); - }); - }); -} - -class Oops implements Handler { - @override - Future call(HttpRequest request) { - throw 'Oops'; - } -} From 388340d1db4d7d1a27bd133c5b913c718304fc24 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 14 Dec 2020 14:57:55 -0800 Subject: [PATCH 86/99] WIP --- .github/workflows/dart.yml | 6 +- README.md | 17 +- example/client.dart | 14 + .../dart_http_handler.dart | 15 +- lib/client.dart | 12 +- lib/codec.dart | 23 ++ lib/core.dart | 40 --- lib/document.dart | 2 + lib/http.dart | 3 + lib/routing.dart | 5 +- lib/server.dart | 1 - lib/src/client/basic_client.dart | 50 +++ lib/src/client/json_api_client.dart | 323 ------------------ lib/src/client/request.dart | 62 ++-- lib/src/client/response.dart | 11 + .../client/response/collection_fetched.dart | 26 ++ .../client/response/collection_response.dart | 38 --- .../response/fetch_collection_response.dart | 22 -- .../fetch_primary_resource_response.dart | 19 -- .../response/fetch_resource_response.dart | 19 -- lib/src/client/response/fetch_response.dart | 20 -- .../response/new_resource_response.dart | 31 -- .../response/related_resource_fetched.dart | 29 ++ .../client/response/relationship_fetched.dart | 19 ++ .../response/relationship_response.dart | 34 -- .../client/response/relationship_updated.dart | 20 ++ lib/src/client/response/request_failure.dart | 15 +- lib/src/client/response/resource_created.dart | 23 ++ lib/src/client/response/resource_fetched.dart | 22 ++ .../client/response/resource_response.dart | 33 -- lib/src/client/response/resource_updated.dart | 20 ++ lib/src/client/response/response.dart | 12 - lib/src/client/routing_client.dart | 318 +++++++++++++++++ lib/src/document/identifier.dart | 16 +- lib/src/document/identity.dart | 9 + lib/src/document/inbound_document.dart | 163 +++++---- lib/src/document/link.dart | 2 +- lib/src/document/many.dart | 7 +- lib/src/document/one.dart | 4 +- lib/src/document/outbound_document.dart | 2 +- lib/src/document/relationship.dart | 4 +- lib/src/document/resource.dart | 16 +- .../resource_collection.dart} | 7 +- lib/src/document/resource_properties.dart | 23 -- lib/{ => src/http}/handler.dart | 4 - lib/src/http/http_headers.dart | 8 + lib/src/http/http_message.dart | 9 +- lib/src/http/http_response.dart | 20 -- lib/src/http/status_code.dart | 23 ++ ...l_design.dart => standard_uri_design.dart} | 58 ++-- lib/src/routing/target.dart | 55 +-- lib/src/routing/target_matcher.dart | 6 - lib/src/routing/uri_design.dart | 9 + lib/src/routing/uri_factory.dart | 3 - .../server/_internal/cors_http_handler.dart | 33 -- lib/src/server/_internal/in_memory_repo.dart | 57 ++-- lib/src/server/_internal/repo.dart | 99 +++++- .../_internal/repository_controller.dart | 111 +++--- lib/src/server/controller.dart | 4 +- lib/src/server/json_api_response.dart | 16 +- lib/src/server/router.dart | 39 +-- lib/src/server/routing_error_converter.dart | 12 - lib/src/test/mock_handler.dart | 2 +- pubspec.yaml | 5 +- test/contract/crud_test.dart | 81 +++-- test/contract/errors_test.dart | 36 +- test/contract/resource_creation_test.dart | 15 +- test/e2e/browser_test.dart | 7 +- test/e2e/e2e_test_set.dart | 6 +- test/e2e/vm_test.dart | 6 +- test/handler/logging_handler_test.dart | 2 +- test/src/dart_io_http_handler.dart | 2 +- test/src/demo_handler.dart | 51 ++- test/src/json_api_server.dart | 1 - test/src/sequential_numbers.dart | 2 + test/unit/client/client_test.dart | 87 +++-- test/unit/document/inbound_document_test.dart | 112 +++--- test/unit/document/new_resource_test.dart | 5 +- .../unit/document/outbound_document_test.dart | 9 +- test/unit/document/relationship_test.dart | 5 +- test/unit/document/resource_test.dart | 13 +- test/unit/routing/url_test.dart | 43 ++- .../server/repository_controller_test.dart | 21 ++ 83 files changed, 1329 insertions(+), 1305 deletions(-) create mode 100644 example/client.dart rename lib/src/client/handlers.dart => legacy/dart_http_handler.dart (64%) create mode 100644 lib/codec.dart delete mode 100644 lib/core.dart create mode 100644 lib/src/client/basic_client.dart delete mode 100644 lib/src/client/json_api_client.dart create mode 100644 lib/src/client/response.dart create mode 100644 lib/src/client/response/collection_fetched.dart delete mode 100644 lib/src/client/response/collection_response.dart delete mode 100644 lib/src/client/response/fetch_collection_response.dart delete mode 100644 lib/src/client/response/fetch_primary_resource_response.dart delete mode 100644 lib/src/client/response/fetch_resource_response.dart delete mode 100644 lib/src/client/response/fetch_response.dart delete mode 100644 lib/src/client/response/new_resource_response.dart create mode 100644 lib/src/client/response/related_resource_fetched.dart create mode 100644 lib/src/client/response/relationship_fetched.dart delete mode 100644 lib/src/client/response/relationship_response.dart create mode 100644 lib/src/client/response/relationship_updated.dart create mode 100644 lib/src/client/response/resource_created.dart create mode 100644 lib/src/client/response/resource_fetched.dart delete mode 100644 lib/src/client/response/resource_response.dart create mode 100644 lib/src/client/response/resource_updated.dart delete mode 100644 lib/src/client/response/response.dart create mode 100644 lib/src/client/routing_client.dart create mode 100644 lib/src/document/identity.dart rename lib/src/{client/collection.dart => document/resource_collection.dart} (70%) rename lib/{ => src/http}/handler.dart (93%) create mode 100644 lib/src/http/http_headers.dart create mode 100644 lib/src/http/status_code.dart rename lib/src/routing/{recommended_url_design.dart => standard_uri_design.dart} (57%) delete mode 100644 lib/src/routing/target_matcher.dart create mode 100644 lib/src/routing/uri_design.dart delete mode 100644 lib/src/routing/uri_factory.dart delete mode 100644 lib/src/server/_internal/cors_http_handler.dart delete mode 100644 lib/src/server/routing_error_converter.dart create mode 100644 test/src/sequential_numbers.dart create mode 100644 test/unit/server/repository_controller_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 77a9d47b..47a25dc8 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -21,8 +21,6 @@ jobs: - name: Format run: dartfmt --dry-run --set-exit-if-changed lib test - name: Analyzer -# run: dart analyze --fatal-infos --fatal-warnings - run: dart analyze --fatal-warnings + run: dart analyze --fatal-infos --fatal-warnings - name: Tests -# run: dart pub run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* - run: dart --no-sound-null-safety test + run: dart run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* diff --git a/README.md b/README.md index 5ad3225a..4f03a121 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # JSON:API for Dart/Flutter -[JSON:API] is a specification for building APIs in JSON. +[JSON:API] is a specification for building JSON APIs. This package consists of several libraries: - The [Client library] is a JSON:API Client for Flutter, browsers and vm. @@ -10,11 +10,24 @@ This package consists of several libraries: - The [Query library] builds and parses the query parameters (page, sorting, filtering, etc). - The [Routing library] builds and matches URIs for resources, collections, and relationships. + + +# Client +## Making requests +### Fetching +#### Query parameters +### Manipulating resources +### Manipulating relationships +### Error management +### Asynchronous Processing +### Custom request headers + + [JSON:API]: https://jsonapi.org [Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html [Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html [Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html [Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html -[Routing library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html +[Routing library]: https://pub.dev/documentation/json_api/latest/routing/routing-library.html [HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html diff --git a/example/client.dart b/example/client.dart new file mode 100644 index 00000000..18dc57dc --- /dev/null +++ b/example/client.dart @@ -0,0 +1,14 @@ +// @dart=2.9 +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/basic_client.dart'; + +// The handler is not migrated to null safety yet. +import '../legacy/dart_http_handler.dart'; + +// START THE SERVER FIRST! +void main() async { + final uri = Uri(host: 'localhost', port: 8080); + final client = + RoutingClient(StandardUriDesign(uri), BasicClient(DartHttpHandler())); +} diff --git a/lib/src/client/handlers.dart b/legacy/dart_http_handler.dart similarity index 64% rename from lib/src/client/handlers.dart rename to legacy/dart_http_handler.dart index 2b38baa0..156c345c 100644 --- a/lib/src/client/handlers.dart +++ b/legacy/dart_http_handler.dart @@ -1,14 +1,15 @@ +// @dart=2.9 import 'package:http/http.dart'; -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; abstract class DartHttpHandler implements Handler { - factory DartHttpHandler([Client? client]) => - client != null ? _Persistent(client) : _OneOff(); + factory DartHttpHandler([Client /*?*/ client]) => client != null + ? _PersistentDartHttpHandler(client) + : _OneOffDartHttpHandler(); } -class _Persistent implements DartHttpHandler { - _Persistent(this.client); +class _PersistentDartHttpHandler implements DartHttpHandler { + _PersistentDartHttpHandler(this.client); final Client client; @@ -23,12 +24,12 @@ class _Persistent implements DartHttpHandler { } } -class _OneOff implements DartHttpHandler { +class _OneOffDartHttpHandler implements DartHttpHandler { @override Future call(HttpRequest request) async { final client = Client(); try { - return await _Persistent(client).call(request); + return await _PersistentDartHttpHandler(client).call(request); } finally { client.close(); } diff --git a/lib/client.dart b/lib/client.dart index 101fa014..12b0a38a 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,9 +1,9 @@ library json_api; -export 'package:json_api/src/client/request.dart'; -export 'package:json_api/src/client/handlers.dart'; -export 'package:json_api/src/client/json_api_client.dart'; +export 'package:json_api/src/client/basic_client.dart'; +export 'package:json_api/src/client/response/collection_fetched.dart'; +export 'package:json_api/src/client/response/relationship_updated.dart'; export 'package:json_api/src/client/response/request_failure.dart'; -export 'package:json_api/src/client/response/collection_response.dart'; -export 'package:json_api/src/client/response/relationship_response.dart'; -export 'package:json_api/src/client/response/resource_response.dart'; +export 'package:json_api/src/client/response/resource_updated.dart'; +export 'package:json_api/src/client/routing_client.dart'; +export 'package:json_api/src/client/request.dart'; diff --git a/lib/codec.dart b/lib/codec.dart new file mode 100644 index 00000000..77bfa088 --- /dev/null +++ b/lib/codec.dart @@ -0,0 +1,23 @@ +library codec; + +import 'dart:convert'; + +abstract class PayloadCodec { + Future decode(String body); + + Future encode(Object document); +} + +class DefaultCodec implements PayloadCodec { + const DefaultCodec(); + + @override + Future decode(String body) async { + final json = jsonDecode(body); + if (json is Map) return json; + throw FormatException('Invalid JSON payload: ${json.runtimeType}'); + } + + @override + Future encode(Object document) async => jsonEncode(document); +} diff --git a/lib/core.dart b/lib/core.dart deleted file mode 100644 index 945dc27d..00000000 --- a/lib/core.dart +++ /dev/null @@ -1,40 +0,0 @@ -/// A reference to a resource -class Ref { - const Ref(this.type, this.id); - - final String type; - - final String id; - - @override - final hashCode = 0; - - @override - bool operator ==(Object other) => - other is Ref && type == other.type && id == other.id; -} - -class ModelProps { - final attributes = {}; - final one = {}; - final many = >{}; - - void setFrom(ModelProps other) { - other.attributes.forEach((key, value) { - attributes[key] = value; - }); - other.one.forEach((key, value) { - one[key] = value; - }); - other.many.forEach((key, value) { - many[key] = {...value}; - }); - } -} - -/// A model of a resource. Essentially, this is the core of a resource object. -class Model extends ModelProps { - Model(this.ref); - - final Ref ref; -} diff --git a/lib/document.dart b/lib/document.dart index 1b1084ed..27e6aa04 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -2,6 +2,7 @@ library document; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/identifier.dart'; +export 'package:json_api/src/document/identity.dart'; export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/many.dart'; @@ -10,4 +11,5 @@ export 'package:json_api/src/document/one.dart'; export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; +export 'package:json_api/src/document/resource_collection.dart'; export 'package:json_api/src/document/resource_properties.dart'; diff --git a/lib/http.dart b/lib/http.dart index 447b3c50..ce12b453 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,6 +1,9 @@ /// This is a thin HTTP layer abstraction used by the client library http; +export 'package:json_api/src/http/handler.dart'; +export 'package:json_api/src/http/http_message.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; export 'package:json_api/src/http/media_type.dart'; +export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/routing.dart b/lib/routing.dart index 51803c72..4f267e51 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -2,7 +2,6 @@ /// See https://jsonapi.org/recommendations/#urls library routing; -export 'package:json_api/src/routing/recommended_url_design.dart'; +export 'package:json_api/src/routing/standard_uri_design.dart'; export 'package:json_api/src/routing/target.dart'; -export 'package:json_api/src/routing/target_matcher.dart'; -export 'package:json_api/src/routing/uri_factory.dart'; +export 'package:json_api/src/routing/uri_design.dart'; diff --git a/lib/server.dart b/lib/server.dart index 7d1c86e5..ad8ca8f8 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,4 +1,3 @@ export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/json_api_response.dart'; -export 'package:json_api/src/server/routing_error_converter.dart'; export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/client/basic_client.dart b/lib/src/client/basic_client.dart new file mode 100644 index 00000000..5344a499 --- /dev/null +++ b/lib/src/client/basic_client.dart @@ -0,0 +1,50 @@ +import 'package:json_api/codec.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/request.dart'; +import 'package:json_api/src/client/response/request_failure.dart'; +import 'package:json_api/src/client/response.dart'; + +/// A basic JSON:API client +class BasicClient { + BasicClient(this._http, {PayloadCodec? codec}) + : _codec = codec ?? const DefaultCodec(); + + final Handler _http; + final PayloadCodec _codec; + + /// Sends the [request] to the server. + /// Throws a [RequestFailure] if the server responds with an error. + Future send(Uri uri, Request request) async { + final body = await _encode(request.document); + final response = await _http.call(HttpRequest( + request.method, + request.query.isEmpty + ? uri + : uri.replace(queryParameters: request.query), + body: body) + ..headers.addAll({ + 'Accept': MediaType.jsonApi, + if (body.isNotEmpty) 'Content-Type': MediaType.jsonApi, + ...request.headers + })); + + final json = await _decode(response); + if (StatusCode(response.statusCode).isFailed) { + throw RequestFailure(response, json); + } + return Response(response, json); + } + + Future _encode(Object? doc) async => + doc == null ? '' : await _codec.encode(doc); + + Future _decode(HttpResponse response) async => + _isJsonApi(response) ? await _codec.decode(response.body) : null; + + /// True if body is not empty and Content-Type is application/vnd.api+json + bool _isJsonApi(HttpResponse response) => + response.body.isNotEmpty && + (response.headers['Content-Type'] ?? '') + .toLowerCase() + .startsWith(MediaType.jsonApi); +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart deleted file mode 100644 index 3f64692d..00000000 --- a/lib/src/client/json_api_client.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/core.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/request.dart'; -import 'package:json_api/src/client/response/collection_response.dart'; -import 'package:json_api/src/client/response/fetch_collection_response.dart'; -import 'package:json_api/src/client/response/fetch_primary_resource_response.dart'; -import 'package:json_api/src/client/response/fetch_resource_response.dart'; -import 'package:json_api/src/client/response/new_resource_response.dart'; -import 'package:json_api/src/client/response/relationship_response.dart'; -import 'package:json_api/src/client/response/request_failure.dart'; -import 'package:json_api/src/client/response/resource_response.dart'; -import 'package:json_api/src/client/response/response.dart'; - -/// The JSON:API client -class JsonApiClient { - JsonApiClient( - this._http, - this._uriFactory, - ); - - final Handler _http; - final UriFactory _uriFactory; - - /// Adds [identifiers] to a to-many relationship - /// identified by [type], [id], [relationship]. - /// - /// Optional arguments: - /// - [headers] - any extra HTTP headers - Future> addMany( - String type, - String id, - String relationship, - List identifiers, { - Map headers = const {}, - }) async => - RelationshipResponse.decodeMany(await send(Request( - 'post', RelationshipTarget(Ref(type, id), relationship), - document: OutboundDataDocument.many(ToMany(identifiers))) - ..headers.addAll(headers))); - - /// Creates a new resource in the collection of type [type]. - /// The server is responsible for assigning the resource id. - /// - /// Optional arguments: - /// - [attributes] - resource attributes - /// - [one] - resource to-one relationships - /// - [many] - resource to-many relationships - /// - [meta] - resource meta data - /// - [resourceType] - resource type (if different from collection [type]) - /// - [headers] - any extra HTTP headers - Future createNew( - String type, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - String? resourceType, - Map headers = const {}, - }) async => - NewResourceResponse.decode(await send(Request( - 'post', CollectionTarget(type), - document: - OutboundDataDocument.newResource(NewResource(resourceType ?? type) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, ToOne(value))), - ...many.map((key, value) => MapEntry(key, ToMany(value))), - }) - ..meta.addAll(meta))) - ..headers.addAll(headers))); - - /// Deletes [identifiers] from a to-many relationship - /// identified by [type], [id], [relationship]. - /// - /// Optional arguments: - /// - [headers] - any extra HTTP headers - Future> deleteFromToMany( - String type, - String id, - String relationship, - List identifiers, { - Map headers = const {}, - }) async => - RelationshipResponse.decode(await send(Request( - 'delete', RelationshipTarget(Ref(type, id), relationship), - document: OutboundDataDocument.many(ToMany(identifiers))) - ..headers.addAll(headers))); - - /// Fetches a primary collection of type [type]. - /// - /// Optional arguments: - /// - [headers] - any extra HTTP headers - /// - [query] - any extra query parameters - /// - [page] - pagination options - /// - [filter] - filtering options - /// - [include] - request to include related resources - /// - [sort] - collection sorting options - /// - [fields] - sparse fields options - Future fetchCollection( - String type, { - Map headers = const {}, - Map query = const {}, - Map page = const {}, - Map filter = const {}, - Iterable include = const [], - Iterable sort = const [], - Map> fields = const {}, - }) async => - CollectionResponse.decode(await send( - Request('get', CollectionTarget(type)) - ..headers.addAll(headers) - ..query.addAll(query) - ..page.addAll(page) - ..filter.addAll(filter) - ..include.addAll(include) - ..sort.addAll(sort) - ..fields.addAll(fields))); - - /// Fetches a related resource collection - /// identified by [type], [id], [relationship]. - /// - /// Optional arguments: - /// - [headers] - any extra HTTP headers - /// - [query] - any extra query parameters - /// - [page] - pagination options - /// - [filter] - filtering options - /// - [include] - request to include related resources - /// - [sort] - collection sorting options - /// - [fields] - sparse fields options - Future fetchRelatedCollection( - String type, - String id, - String relationship, { - Map headers = const {}, - Map page = const {}, - Map filter = const {}, - Iterable include = const [], - Iterable sort = const [], - Map> fields = const {}, - Map query = const {}, - }) async => - FetchCollectionResponse.decode(await send( - Request('get', RelatedTarget(Ref(type, id), relationship)) - ..headers.addAll(headers) - ..query.addAll(query) - ..page.addAll(page) - ..filter.addAll(filter) - ..include.addAll(include) - ..sort.addAll(sort) - ..fields.addAll(fields))); - - Future> fetchToOne( - String type, - String id, - String relationship, { - Map headers = const {}, - Map query = const {}, - }) async => - RelationshipResponse.decodeOne(await send( - Request('get', RelationshipTarget(Ref(type, id), relationship)) - ..headers.addAll(headers) - ..query.addAll(query))); - - Future> fetchToMany( - String type, - String id, - String relationship, { - Map headers = const {}, - Map query = const {}, - }) async => - RelationshipResponse.decodeMany(await send( - Request('get', RelationshipTarget(Ref(type, id), relationship)) - ..headers.addAll(headers) - ..query.addAll(query))); - - Future fetchRelatedResource( - String type, - String id, - String relationship, { - Map headers = const {}, - Map query = const {}, - Map filter = const {}, - Iterable include = const [], - Map> fields = const {}, - }) async => - FetchRelatedResourceResponse.decode(await send( - Request('get', RelatedTarget(Ref(type, id), relationship)) - ..headers.addAll(headers) - ..query.addAll(query) - ..filter.addAll(filter) - ..include.addAll(include) - ..fields.addAll(fields))); - - Future fetchResource( - String type, - String id, { - Map headers = const {}, - Map filter = const {}, - Iterable include = const [], - Map> fields = const {}, - Map query = const {}, - }) async => - FetchPrimaryResourceResponse.decode(await send( - Request('get', ResourceTarget(Ref(type, id))) - ..headers.addAll(headers) - ..query.addAll(query) - ..filter.addAll(filter) - ..include.addAll(include) - ..fields.addAll(fields))); - - Future updateResource(String type, String id, - {Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - Map headers = const {}}) async => - ResourceResponse.decode( - await send(Request('patch', ResourceTarget(Ref(type, id)), - document: OutboundDataDocument.resource(Resource(Ref(type, id)) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((key, value) => MapEntry(key, ToOne(value))), - ...many.map((key, value) => MapEntry(key, ToMany(value))), - }) - ..meta.addAll(meta))) - ..headers.addAll(headers))); - - /// Creates a new resource with the given id on the server. - Future create( - String type, - String id, { - Map attributes = const {}, - Map one = const {}, - Map> many = const {}, - Map meta = const {}, - Map headers = const {}, - }) async => - ResourceResponse.decode(await send(Request('post', CollectionTarget(type), - document: OutboundDataDocument.resource(Resource(Ref(type, id)) - ..attributes.addAll(attributes) - ..relationships.addAll({ - ...one.map((k, v) => MapEntry(k, ToOne(v))), - ...many.map((k, v) => MapEntry(k, ToMany(v))), - }) - ..meta.addAll(meta))) - ..headers.addAll(headers))); - - Future> replaceToOne( - String type, - String id, - String relationship, - Identifier identifier, { - Map headers = const {}, - }) async => - RelationshipResponse.decodeOne(await send(Request( - 'patch', RelationshipTarget(Ref(type, id), relationship), - document: OutboundDataDocument.one(ToOne(identifier))) - ..headers.addAll(headers))); - - Future> replaceToMany( - String type, - String id, - String relationship, - Iterable identifiers, { - Map headers = const {}, - }) async => - RelationshipResponse.decodeMany(await send(Request( - 'patch', RelationshipTarget(Ref(type, id), relationship), - document: OutboundDataDocument.many(ToMany(identifiers))) - ..headers.addAll(headers))); - - Future> deleteToOne( - String type, String id, String relationship, - {Map headers = const {}}) async => - RelationshipResponse.decodeOne(await send(Request( - 'patch', RelationshipTarget(Ref(type, id), relationship), - document: OutboundDataDocument.one(ToOne.empty())) - ..headers.addAll(headers))); - - Future deleteResource(String type, String id) async => - Response.decode( - await send(Request('delete', ResourceTarget(Ref(type, id))))); - - /// Sends the [request] to the server. - /// Throws a [RequestFailure] if the server responds with an error. - Future send(Request request) async { - final query = { - ...Include(request.include).asQueryParameters, - ...Sort(request.sort).asQueryParameters, - ...Fields(request.fields).asQueryParameters, - ...Page(request.page).asQueryParameters, - ...Filter(request.filter).asQueryParameters, - ...request.query - }; - - final baseUri = request.target.map(_uriFactory); - final uri = - query.isEmpty ? baseUri : baseUri.replace(queryParameters: query); - - final headers = { - 'Accept': MediaType.jsonApi, - if (request.body.isNotEmpty) 'Content-Type': MediaType.jsonApi, - ...request.headers - }; - - final response = await _http.call( - HttpRequest(request.method, uri, body: request.body) - ..headers.addAll(headers)); - - if (response.isFailed) { - throw RequestFailure(response, - errors: response.hasDocument - ? InboundDocument.decode(response.body).errors - : []); - } - return response; - } -} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 73b85986..492ddc5a 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,47 +1,53 @@ -import 'dart:convert'; - -import 'package:json_api/routing.dart'; -import 'package:json_api/src/nullable.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/src/http/http_headers.dart'; /// JSON:API request consumed by the client -class Request { - Request(this.method, this.target, {Object? document}) - : body = nullable(jsonEncode)(document) ?? ''; +class Request with HttpHeaders { + Request(this.method, [this.document]); + + Request.get() : this('get'); + + Request.post([Object? document]) : this('post', document); + + Request.delete([Object? document]) : this('delete', document); + + Request.patch([Object? document]) : this('patch', document); /// HTTP method final String method; - /// Request target - final Target target; + final Object? document; - /// Encoded document or an empty string. - final String body; - - /// Any extra HTTP headers. - final headers = {}; + /// Query parameters + final query = {}; - /// A list of dot-separated relationships to include. + /// Requests inclusion of related resources. /// See https://jsonapi.org/format/#fetching-includes - final include = []; + void include(Iterable include) { + query.addAll(Include(include).asQueryParameters); + } - /// Sorting parameters. + /// Sets sorting parameters. /// See https://jsonapi.org/format/#fetching-sorting - final sort = []; + void sort(Iterable sort) { + query.addAll(Sort(sort).asQueryParameters); + } - /// Sparse fieldsets. + /// Requests sparse fieldsets. /// See https://jsonapi.org/format/#fetching-sparse-fieldsets - final fields = >{}; + void fields(Map> fields) { + query.addAll(Fields(fields).asQueryParameters); + } - /// Pagination parameters. + /// Sets pagination parameters. /// See https://jsonapi.org/format/#fetching-pagination - final page = {}; + void page(Map page) { + query.addAll(Page(page).asQueryParameters); + } /// Response filtering. /// https://jsonapi.org/format/#fetching-filtering - final filter = {}; - - /// Any general query parameters. - /// If passed, this parameter will override other parameters set through - /// [include], [sort], [fields], [page], and [filter]. - final query = {}; + void filter(Map filter) { + query.addAll(Filter(filter).asQueryParameters); + } } diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart new file mode 100644 index 00000000..d38926a2 --- /dev/null +++ b/lib/src/client/response.dart @@ -0,0 +1,11 @@ +import 'package:json_api/http.dart'; + +class Response { + Response(this.http, this.json); + + /// HTTP response + final HttpResponse http; + + /// Raw JSON response + final Map? json; +} diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart new file mode 100644 index 00000000..99c94e4e --- /dev/null +++ b/lib/src/client/response/collection_fetched.dart @@ -0,0 +1,26 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +class CollectionFetched { + CollectionFetched(this.http, Map json) { + final document = InboundDocument(json); + collection.addAll(document.dataAsCollection()); + included.addAll(document.included()); + meta.addAll(document.meta()); + links.addAll(document.links()); + } + + final HttpResponse http; + + /// The resource collection fetched from the server + final collection = ResourceCollection(); + + /// Included resources + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/collection_response.dart b/lib/src/client/response/collection_response.dart deleted file mode 100644 index 05b9727a..00000000 --- a/lib/src/client/response/collection_response.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; - -/// A response to a fetch collection request. -/// -/// See https://jsonapi.org/format/#fetching-resources-responses -class CollectionResponse { - CollectionResponse(this.http, - {Iterable collection = const [], - Iterable included = const [], - Map links = const {}}) { - this.collection.addAll(collection); - this.included.addAll(included); - this.links.addAll(links); - } - - /// Decodes the response from [HttpResponse]. - static CollectionResponse decode(HttpResponse response) { - final doc = InboundDocument.decode(response.body); - return CollectionResponse(response, - collection: doc.resourceCollection(), - included: doc.included, - links: doc.links); - } - - /// Original HttpResponse - final HttpResponse http; - - /// The resource collection fetched from the server - final collection = ResourceCollection(); - - /// Included resources - final included = ResourceCollection(); - - /// Links to iterate the collection - final links = {}; -} diff --git a/lib/src/client/response/fetch_collection_response.dart b/lib/src/client/response/fetch_collection_response.dart deleted file mode 100644 index e61e229b..00000000 --- a/lib/src/client/response/fetch_collection_response.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; -import 'package:json_api/src/client/response/fetch_response.dart'; - -class FetchCollectionResponse extends FetchResponse { - FetchCollectionResponse(HttpResponse http, Iterable collection, - {Iterable included = const [], - Map links = const {}}) - : super(http, included: included, links: links) { - this.collection.addAll(collection); - } - - static FetchCollectionResponse decode(HttpResponse response) { - final doc = InboundDocument.decode(response.body); - return FetchCollectionResponse(response, doc.resourceCollection(), - links: doc.links, included: doc.included); - } - - /// Fetched collection - final collection = ResourceCollection(); -} diff --git a/lib/src/client/response/fetch_primary_resource_response.dart b/lib/src/client/response/fetch_primary_resource_response.dart deleted file mode 100644 index 237f9ed6..00000000 --- a/lib/src/client/response/fetch_primary_resource_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/response/fetch_response.dart'; - -class FetchPrimaryResourceResponse extends FetchResponse { - FetchPrimaryResourceResponse(HttpResponse http, this.resource, - {Iterable included = const [], - Map links = const {}}) - : super(http, included: included, links: links); - - static FetchPrimaryResourceResponse decode(HttpResponse response) { - final doc = InboundDocument.decode(response.body); - return FetchPrimaryResourceResponse(response, doc.resource(), - links: doc.links, included: doc.included); - } - - /// Fetched resource - final Resource resource; -} diff --git a/lib/src/client/response/fetch_resource_response.dart b/lib/src/client/response/fetch_resource_response.dart deleted file mode 100644 index 440d2083..00000000 --- a/lib/src/client/response/fetch_resource_response.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/response/fetch_response.dart'; - -class FetchRelatedResourceResponse extends FetchResponse { - FetchRelatedResourceResponse(HttpResponse http, this.resource, - {Iterable included = const [], - Map links = const {}}) - : super(http, included: included, links: links); - - static FetchRelatedResourceResponse decode(HttpResponse response) { - final doc = InboundDocument.decode(response.body); - return FetchRelatedResourceResponse(response, doc.nullableResource(), - links: doc.links, included: doc.included); - } - - /// Fetched resource - final Resource? resource; -} diff --git a/lib/src/client/response/fetch_response.dart b/lib/src/client/response/fetch_response.dart deleted file mode 100644 index 67c89f79..00000000 --- a/lib/src/client/response/fetch_response.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; -import 'package:json_api/src/client/response/response.dart'; - -class FetchResponse extends Response { - FetchResponse(HttpResponse http, - {Iterable included = const [], - Map links = const {}}) - : super(http) { - this.included.addAll(included); - this.links.addAll(links); - } - - /// Included resources - final included = ResourceCollection(); - - /// Links to iterate the collection - final links = {}; -} diff --git a/lib/src/client/response/new_resource_response.dart b/lib/src/client/response/new_resource_response.dart deleted file mode 100644 index b3031385..00000000 --- a/lib/src/client/response/new_resource_response.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; - -/// A response to a new resource creation request. -class NewResourceResponse { - NewResourceResponse(this.http, this.resource, - {Map links = const {}, - Iterable included = const []}) { - this.included.addAll(included); - this.links.addAll(links); - } - - static NewResourceResponse decode(HttpResponse response) { - final doc = InboundDocument.decode(response.body); - return NewResourceResponse(response, doc.resource(), - links: doc.links, included: doc.included); - } - - /// Original HTTP response. - final HttpResponse http; - - /// Created resource. - final Resource resource; - - /// Included resources. - final included = ResourceCollection(); - - /// Document links. - final links = {}; -} diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart new file mode 100644 index 00000000..a6fb4ad3 --- /dev/null +++ b/lib/src/client/response/related_resource_fetched.dart @@ -0,0 +1,29 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A related resource response. +/// +/// https://jsonapi.org/format/#fetching-resources-responses +class RelatedResourceFetched { + RelatedResourceFetched(this.http, Map json) + : resource = InboundDocument(json).dataAsResourceOrNull() { + final document = InboundDocument(json); + included.addAll(document.included()); + meta.addAll(document.meta()); + links.addAll(document.links()); + } + + final HttpResponse http; + + /// Related resource. May be null + final Resource? resource; + + /// Included resources + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart new file mode 100644 index 00000000..fa6ff0e4 --- /dev/null +++ b/lib/src/client/response/relationship_fetched.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a relationship fetch request. +class RelationshipFetched { + RelationshipFetched(this.http, this.relationship); + + static RelationshipFetched many(HttpResponse http, Map json) => + RelationshipFetched(http, InboundDocument(json).asToMany()) + ..included.addAll(InboundDocument(json).included()); + + static RelationshipFetched one(HttpResponse http, Map json) => + RelationshipFetched(http, InboundDocument(json).asToOne()) + ..included.addAll(InboundDocument(json).included()); + + final HttpResponse http; + final R relationship; + final included = ResourceCollection(); +} diff --git a/lib/src/client/response/relationship_response.dart b/lib/src/client/response/relationship_response.dart deleted file mode 100644 index 503d91a5..00000000 --- a/lib/src/client/response/relationship_response.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; - -/// A response to a relationship request. -class RelationshipResponse { - RelationshipResponse(this.http, this.relationship, - {Iterable included = const []}) { - this.included.addAll(included); - } - - static RelationshipResponse decodeMany(HttpResponse response) => - decode(response); - - static RelationshipResponse decodeOne(HttpResponse response) => - decode(response); - - static RelationshipResponse decode( - HttpResponse response) { - final doc = InboundDocument.decode(response.body); - final rel = doc.dataAsRelationship(); - if (rel is T) { - return RelationshipResponse(response, rel, included: doc.included); - } - throw FormatException(); - } - - /// Original HTTP response - final HttpResponse http; - final T relationship; - - /// Included resources - final included = ResourceCollection(); -} diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart new file mode 100644 index 00000000..e596d925 --- /dev/null +++ b/lib/src/client/response/relationship_updated.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a relationship request. +class RelationshipUpdated { + RelationshipUpdated(this.http, this.relationship); + + static RelationshipUpdated many(HttpResponse http, Map? json) => + RelationshipUpdated( + http, json == null ? null : InboundDocument(json).asToMany()); + + static RelationshipUpdated one(HttpResponse http, Map? json) => + RelationshipUpdated( + http, json == null ? null : InboundDocument(json).asToOne()); + + final HttpResponse http; + + /// Updated relationship. Null if "204 No Content" is returned. + final R? relationship; +} diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart index 17fb424c..72934a9a 100644 --- a/lib/src/client/response/request_failure.dart +++ b/lib/src/client/response/request_failure.dart @@ -3,17 +3,22 @@ import 'package:json_api/http.dart'; /// Thrown when the server returns a non-successful response. class RequestFailure implements Exception { - RequestFailure(this.http, {Iterable errors = const []}) { - this.errors.addAll(errors); + RequestFailure(this.http, Map? json) { + if (json != null) { + errors.addAll(InboundDocument(json).errors()); + meta.addAll(InboundDocument(json).meta()); + } } - /// The response itself. final HttpResponse http; - /// JSON:API errors (if any) + /// Error objects returned by the server final errors = []; + /// Top-level meta data + final meta = {}; + @override String toString() => - 'JSON:API request failed with HTTP status ${http.statusCode}. $errors'; + 'JSON:API request failed with HTTP status ${http.statusCode}'; } diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart new file mode 100644 index 00000000..ad3c88c8 --- /dev/null +++ b/lib/src/client/response/resource_created.dart @@ -0,0 +1,23 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a new resource creation request. +/// This is always a "201 Created" response. +/// +/// https://jsonapi.org/format/#crud-creating-responses-201 +class ResourceCreated { + ResourceCreated(this.http, Map json) + : resource = InboundDocument(json).dataAsResource() { + links.addAll(InboundDocument(json).links()); + included.addAll(InboundDocument(json).included()); + } + + final HttpResponse http; + + /// Created resource. + final Resource resource; + final links = {}; + + /// Included resources + final included = ResourceCollection(); +} diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart new file mode 100644 index 00000000..76d656ef --- /dev/null +++ b/lib/src/client/response/resource_fetched.dart @@ -0,0 +1,22 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to fetch a primary resource request +class ResourceFetched { + ResourceFetched(this.http, Map json) + : resource = InboundDocument(json).dataAsResource() { + included.addAll(InboundDocument(json).included()); + meta.addAll(InboundDocument(json).meta()); + links.addAll(InboundDocument(json).links()); + } + + final HttpResponse http; + final Resource resource; + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/resource_response.dart b/lib/src/client/response/resource_response.dart deleted file mode 100644 index 04e958eb..00000000 --- a/lib/src/client/response/resource_response.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/client/collection.dart'; - -class ResourceResponse { - ResourceResponse(this.http, this.resource, - {Map links = const {}, - Iterable included = const []}) { - this.included.addAll(included); - this.links.addAll(links); - } - - ResourceResponse.noContent(this.http) : resource = null; - - static ResourceResponse decode(HttpResponse response) { - if (response.isNoContent) return ResourceResponse.noContent(response); - final doc = InboundDocument.decode(response.body); - return ResourceResponse(response, doc.nullableResource(), - links: doc.links, included: doc.included); - } - - /// Original HTTP response - final HttpResponse http; - - /// The created resource. Null for "204 No Content" responses. - final Resource? resource; - - /// Included resources - final included = ResourceCollection(); - - /// Document links - final links = {}; -} diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart new file mode 100644 index 00000000..cfd6c95c --- /dev/null +++ b/lib/src/client/response/resource_updated.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +class ResourceUpdated { + ResourceUpdated(this.http, Map? json) : resource = _resource(json); + + static Resource? _resource(Map? json) { + if (json != null) { + final doc = InboundDocument(json); + if (doc.hasData) { + return doc.dataAsResource(); + } + } + } + + final HttpResponse http; + + /// The created resource. Null for "204 No Content" responses. + late final Resource? resource; +} diff --git a/lib/src/client/response/response.dart b/lib/src/client/response/response.dart deleted file mode 100644 index 0cb80821..00000000 --- a/lib/src/client/response/response.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/http.dart'; - -/// A response sent by the server -class Response { - Response(this.http); - - static Response decode(HttpResponse response) { - return Response(response); - } - - final HttpResponse http; -} diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart new file mode 100644 index 00000000..592882df --- /dev/null +++ b/lib/src/client/routing_client.dart @@ -0,0 +1,318 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/basic_client.dart'; +import 'package:json_api/src/client/request.dart'; +import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/client/response/collection_fetched.dart'; +import 'package:json_api/src/client/response/related_resource_fetched.dart'; +import 'package:json_api/src/client/response/relationship_fetched.dart'; +import 'package:json_api/src/client/response/relationship_updated.dart'; +import 'package:json_api/src/client/response/resource_created.dart'; +import 'package:json_api/src/client/response/resource_fetched.dart'; +import 'package:json_api/src/client/response/resource_updated.dart'; + +/// A routing JSON:API client +class RoutingClient { + RoutingClient(this._uri, this._client); + + final BasicClient _client; + final UriDesign _uri; + + /// Adds [identifiers] to a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future> addMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.post(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + return RelationshipUpdated.many(response.http, response.json); + } + + /// Creates a new resource in the collection of type [type]. + /// The server is responsible for assigning the resource id. + /// + /// Optional arguments: + /// - [attributes] - resource attributes + /// - [one] - resource to-one relationships + /// - [many] - resource to-many relationships + /// - [meta] - resource meta data + /// - [headers] - any extra HTTP headers + Future createNew( + String type, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.post(OutboundDataDocument.newResource(NewResource(type) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + + return ResourceCreated( + response.http, response.json ?? (throw FormatException())); + } + + /// Deletes [identifiers] from a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future deleteFromMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.delete(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + + return RelationshipUpdated.many(response.http, response.json); + } + + /// Fetches a primary collection of type [type]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchCollection( + String type, { + Map headers = const {}, + Map query = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..page(page) + ..filter(filter) + ..include(include) + ..sort(sort) + ..fields(fields)); + return CollectionFetched( + response.http, response.json ?? (throw FormatException())); + } + + /// Fetches a related resource collection + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchRelatedCollection( + String type, + String id, + String relationship, { + Map headers = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..page(page) + ..filter(filter) + ..include(include) + ..sort(sort) + ..fields(fields)); + return CollectionFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future> fetchToOne( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.get()..headers.addAll(headers)..query.addAll(query)); + return RelationshipFetched.one( + response.http, response.json ?? (throw FormatException())); + } + + Future> fetchToMany( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.get()..headers.addAll(headers)..query.addAll(query)); + return RelationshipFetched.many( + response.http, response.json ?? (throw FormatException())); + } + + Future fetchRelatedResource( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + }) async { + final response = await _client.send( + _uri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..filter(filter) + ..include(include) + ..fields(fields)); + return RelatedResourceFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future fetchResource( + String type, + String id, { + Map headers = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.resource(type, id), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..filter(filter) + ..include(include) + ..fields(fields)); + + return ResourceFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}}) async { + final response = await _client.send( + _uri.resource(type, id), + Request.patch(OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + return ResourceUpdated(response.http, response.json); + } + + /// Creates a new resource with the given id on the server. + Future create( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.post(OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + return ResourceUpdated(response.http, response.json); + } + + Future> replaceToOne( + String type, + String id, + String relationship, + Identifier identifier, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.one(ToOne(identifier))) + ..headers.addAll(headers)); + return RelationshipUpdated.one(response.http, response.json); + } + + Future> replaceToMany( + String type, + String id, + String relationship, + Iterable identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + return RelationshipUpdated.many(response.http, response.json); + } + + Future> deleteToOne( + String type, String id, String relationship, + {Map headers = const {}}) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.one(ToOne.empty())) + ..headers.addAll(headers)); + return RelationshipUpdated.one(response.http, response.json); + } + + Future deleteResource(String type, String id) => + _client.send(_uri.resource(type, id), Request.delete()); +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 3b7edda5..76a0c137 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,14 +1,20 @@ -import 'package:json_api/core.dart'; +import 'package:json_api/src/document/identity.dart'; /// A Resource Identifier object -class Identifier { - Identifier(this.ref); +class Identifier with Identity { + Identifier(this.type, this.id); - final Ref ref; + static Identifier of(Identity identity) => + Identifier(identity.type, identity.id); + + @override + final String type; + @override + final String id; /// Identifier meta-data. final meta = {}; Map toJson() => - {'type': ref.type, 'id': ref.id, if (meta.isNotEmpty) 'meta': meta}; + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; } diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart new file mode 100644 index 00000000..9a16a541 --- /dev/null +++ b/lib/src/document/identity.dart @@ -0,0 +1,9 @@ +mixin Identity { + static bool same(Identity a, Identity b) => a.type == b.type && a.id == b.id; + + String get type; + + String get id; + + String get key => '$type:$id'; +} diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart index 3cdc499a..1c169524 100644 --- a/lib/src/document/inbound_document.dart +++ b/lib/src/document/inbound_document.dart @@ -1,151 +1,144 @@ -import 'dart:convert'; - -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; +import 'package:json_api/src/document/error_object.dart'; import 'package:json_api/src/document/error_source.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/new_resource.dart'; import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; import 'package:json_api/src/nullable.dart'; -/// A generic inbound JSON:API document +/// Inbound JSON:API document class InboundDocument { - InboundDocument(this._json) { - included.addAll(_json - .get('included', orGet: () => []) - .whereType() - .map(_resource)); + InboundDocument(this.json); - errors.addAll(_json - .get('errors', orGet: () => []) - .whereType() - .map(_errorObject)); + static const _parse = _Parser(); - meta.addAll(_meta(_json)); + /// Raw JSON object. + final Map json; - links.addAll(_links(_json)); - } + bool get hasData => json.containsKey('data'); - static InboundDocument decode(String body) { - final json = jsonDecode(body); - if (json is Map) return InboundDocument(json); - throw FormatException('Invalid JSON body'); - } + /// Included resources + Iterable included() => json + .get('included', orGet: () => []) + .whereType() + .map(_parse.resource); - final Map _json; + /// Top-level meta data. + Map meta() => _parse.meta(json); - /// Included resources - final included = []; + /// Top-level links object. + Map links() => _parse.links(json); - /// Error objects - final errors = []; + /// Errors (for an Error Document) + Iterable errors() => json + .get('errors', orGet: () => []) + .whereType() + .map(_parse.errorObject); - /// Document meta - final meta = {}; + Iterable dataAsCollection() => + _data().whereType().map(_parse.resource); - /// Document links - final links = {}; + Resource dataAsResource() => _parse.resource(_data()); - Iterable resourceCollection() => - _json.get('data').whereType().map(_resource); + NewResource dataAsNewResource() => _parse.newResource(_data()); - Resource resource() => _resource(_json.get>('data')); + Resource? dataAsResourceOrNull() => nullable(_parse.resource)(_data()); - NewResource newResource() => - _newResource(_json.get>('data')); + ToMany asToMany() => asRelationship(); - Resource? nullableResource() { - return nullable(_resource)(_json.get('data')); - } + ToOne asToOne() => asRelationship(); - R dataAsRelationship() { - final rel = _relationship(_json); + R asRelationship() { + final rel = _parse.relationship(json); if (rel is R) return rel; throw FormatException('Invalid relationship type'); } - static Map _links(Map json) => json + T _data() => json.get('data'); +} + +class _Parser { + const _Parser(); + + Map meta(Map json) => + json.get>('meta', orGet: () => {}); + + Map links(Map json) => json .get('links', orGet: () => {}) .map((k, v) => MapEntry(k.toString(), _link(v))); - static Relationship _relationship(Map json) { - final links = _links(json); - final meta = _meta(json); - if (json.containsKey('data')) { - final data = json['data']; - if (data == null) { - return ToOne.empty()..links.addAll(links)..meta.addAll(meta); - } - if (data is Map) { - return ToOne(_identifier(data))..links.addAll(links)..meta.addAll(meta); - } - if (data is List) { - return ToMany(data.whereType().map(_identifier)) - ..links.addAll(links) - ..meta.addAll(meta); - } - throw FormatException('Invalid relationship object'); - } - return Relationship()..links.addAll(links)..meta.addAll(meta); + Relationship relationship(Map json) { + final rel = json.containsKey('data') ? _rel(json['data']) : Relationship(); + rel.links.addAll(links(json)); + rel.meta.addAll(meta(json)); + return rel; } - static Map _meta(Map json) => - json.get>('meta', orGet: () => {}); - - static Resource _resource(Map json) => - Resource(Ref(json.get('type'), json.get('id'))) + Resource resource(Map json) => + Resource(json.get('type'), json.get('id')) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getRelationships(json)) - ..links.addAll(_links(json)) - ..meta.addAll(_meta(json)); + ..links.addAll(links(json)) + ..meta.addAll(meta(json)); - static NewResource _newResource(Map json) => NewResource( - json.get('type'), + NewResource newResource(Map json) => NewResource(json.get('type'), json.containsKey('id') ? json.get('id') : null) ..attributes.addAll(_getAttributes(json)) ..relationships.addAll(_getRelationships(json)) - ..meta.addAll(_meta(json)); + ..meta.addAll(meta(json)); /// Decodes Identifier from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. - static Identifier _identifier(Map json) => - Identifier(Ref(json.get('type'), json.get('id'))) - ..meta.addAll(_meta(json)); + Identifier identifier(Map json) => + Identifier(json.get('type'), json.get('id')) + ..meta.addAll(meta(json)); - static ErrorObject _errorObject(Map json) => ErrorObject( + ErrorObject errorObject(Map json) => ErrorObject( id: json.get('id', orGet: () => ''), status: json.get('status', orGet: () => ''), code: json.get('code', orGet: () => ''), title: json.get('title', orGet: () => ''), detail: json.get('detail', orGet: () => ''), - source: _errorSource(json.get('source', orGet: () => {}))) - ..meta.addAll(_meta(json)) - ..links.addAll(_links(json)); + source: errorSource(json.get('source', orGet: () => {}))) + ..meta.addAll(meta(json)) + ..links.addAll(links(json)); /// Decodes ErrorSource from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. - static ErrorSource _errorSource(Map json) => ErrorSource( + ErrorSource errorSource(Map json) => ErrorSource( pointer: json.get('pointer', orGet: () => ''), parameter: json.get('parameter', orGet: () => '')); /// Decodes Link from [json]. Returns the decoded object. /// If the [json] has incorrect format, throws [FormatException]. - static Link _link(Object json) { + Link _link(Object json) { if (json is String) return Link(Uri.parse(json)); if (json is Map) { - return Link(Uri.parse(json['href']))..meta.addAll(_meta(json)); + return Link(Uri.parse(json['href']))..meta.addAll(meta(json)); } throw FormatException('Invalid JSON'); } - static Map _getAttributes(Map json) => - json.get>('attributes', orGet: () => {}); + Map _getAttributes(Map json) => + json.get>('attributes', orGet: () => {}); - static Map _getRelationships(Map json) => json + Map _getRelationships(Map json) => json .get('relationships', orGet: () => {}) - .map((key, value) => MapEntry(key, _relationship(value))); + .map((key, value) => MapEntry(key, relationship(value))); + + Relationship _rel(data) { + if (data == null) return ToOne.empty(); + if (data is Map) return ToOne(identifier(data)); + if (data is List) return ToMany(data.whereType().map(identifier)); + throw FormatException('Invalid relationship object'); + } } -extension _TypedGetter on Map { +extension _TypedGeter on Map { T get(String key, {T Function()? orGet}) { if (containsKey(key)) { final val = this[key]; diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index 1655d6a0..6bd34807 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -7,7 +7,7 @@ class Link { final Uri uri; /// Link meta data - final meta = {}; + final meta = {}; @override String toString() => uri.toString(); diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart index aac2d0ed..0e79420d 100644 --- a/lib/src/document/many.dart +++ b/lib/src/document/many.dart @@ -1,15 +1,14 @@ -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/src/client/collection.dart'; +import 'package:json_api/src/document/resource_collection.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/relationship.dart'; class ToMany extends Relationship { ToMany(Iterable identifiers) { - identifiers.forEach((_) => _map[_.ref] = _); + identifiers.forEach((_) => _map[_.key] = _); } - final _map = {}; + final _map = {}; @override Map toJson() => diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart index e7872fe8..1af6663c 100644 --- a/lib/src/document/one.dart +++ b/lib/src/document/one.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/client/collection.dart'; +import 'package:json_api/src/document/resource_collection.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/document/resource.dart'; @@ -19,5 +19,5 @@ class ToOne extends Relationship { /// Finds the referenced resource in the [collection]. Resource? findIn(ResourceCollection collection) => - collection[identifier?.ref]; + collection[identifier?.key]; } diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart index fc85a54c..aded1a14 100644 --- a/lib/src/document/outbound_document.dart +++ b/lib/src/document/outbound_document.dart @@ -5,7 +5,7 @@ import 'package:json_api/src/document/resource.dart'; /// An empty outbound document. class OutboundDocument { /// The document "meta" object. - final meta = {}; + final meta = {}; Map toJson() => {'meta': meta}; } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 5e61f73d..59935672 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -5,7 +5,7 @@ import 'package:json_api/src/document/link.dart'; class Relationship with IterableMixin { final links = {}; - final meta = {}; + final meta = {}; Map toJson() => { if (links.isNotEmpty) 'links': links, @@ -13,5 +13,5 @@ class Relationship with IterableMixin { }; @override - Iterator get iterator => const [].iterator; + Iterator get iterator => const [].iterator; } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 5c3d526b..1b775dcb 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,19 +1,21 @@ -import 'package:json_api/core.dart'; -import 'package:json_api/document.dart'; +import 'package:json_api/src/document/identity.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/resource_properties.dart'; -class Resource with ResourceProperties { - Resource(this.ref); +class Resource with ResourceProperties, Identity { + Resource(this.type, this.id); - final Ref ref; + @override + final String type; + @override + final String id; /// Resource links final links = {}; Map toJson() => { - 'type': ref.type, - 'id': ref.id, + 'type': type, + 'id': id, if (attributes.isNotEmpty) 'attributes': attributes, if (relationships.isNotEmpty) 'relationships': relationships, if (links.isNotEmpty) 'links': links, diff --git a/lib/src/client/collection.dart b/lib/src/document/resource_collection.dart similarity index 70% rename from lib/src/client/collection.dart rename to lib/src/document/resource_collection.dart index 834f0415..95533d85 100644 --- a/lib/src/client/collection.dart +++ b/lib/src/document/resource_collection.dart @@ -1,16 +1,15 @@ import 'dart:collection'; -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; -/// A collection of objects indexed by ref. +/// A collection of resources indexed by key. class ResourceCollection with IterableMixin { - final _map = {}; + final _map = {}; Resource? operator [](Object? key) => _map[key]; void add(Resource resource) { - _map[resource.ref] = resource; + _map[resource.key] = resource; } void addAll(Iterable resources) { diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart index 1d746025..7e52cc85 100644 --- a/lib/src/document/resource_properties.dart +++ b/lib/src/document/resource_properties.dart @@ -1,5 +1,3 @@ -import 'package:json_api/core.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/src/document/many.dart'; import 'package:json_api/src/document/one.dart'; import 'package:json_api/src/document/relationship.dart'; @@ -18,25 +16,6 @@ mixin ResourceProperties { /// See https://jsonapi.org/format/#document-resource-object-relationships final relationships = {}; - ModelProps toModelProps() { - final props = ModelProps(); - attributes.forEach((key, value) { - props.attributes[key] = value; - }); - relationships.forEach((key, value) { - if (value is ToOne) { - props.one[key] = value.identifier?.ref; - return; - } - if (value is ToMany) { - props.many[key] = Set.from(value.map((_) => _.ref)); - return; - } - throw IncompleteRelationship(); - }); - return props; - } - /// Returns a to-one relationship by its [name]. ToOne? one(String name) => _rel(name); @@ -49,5 +28,3 @@ mixin ResourceProperties { if (r is R) return r; } } - -class IncompleteRelationship implements Exception {} diff --git a/lib/handler.dart b/lib/src/http/handler.dart similarity index 93% rename from lib/handler.dart rename to lib/src/http/handler.dart index 2610ba33..09e3803c 100644 --- a/lib/handler.dart +++ b/lib/src/http/handler.dart @@ -1,7 +1,3 @@ -/// This library defines the idea of a composable generic -/// async (request/response) handler. -library handler; - /// A generic async handler abstract class Handler { static Handler lambda(Future Function(Rq request) fun) => diff --git a/lib/src/http/http_headers.dart b/lib/src/http/http_headers.dart new file mode 100644 index 00000000..a7111041 --- /dev/null +++ b/lib/src/http/http_headers.dart @@ -0,0 +1,8 @@ +import 'dart:collection'; + +mixin HttpHeaders { + /// Message headers. Case-insensitive. + final headers = LinkedHashMap( + equals: (a, b) => a.toLowerCase() == b.toLowerCase(), + hashCode: (s) => s.toLowerCase().hashCode); +} diff --git a/lib/src/http/http_message.dart b/lib/src/http/http_message.dart index 29e1203f..43479cdf 100644 --- a/lib/src/http/http_message.dart +++ b/lib/src/http/http_message.dart @@ -1,13 +1,8 @@ -import 'dart:collection'; +import 'package:json_api/src/http/http_headers.dart'; -class HttpMessage { +class HttpMessage with HttpHeaders { HttpMessage(this.body); /// Message body final String body; - - /// Message headers. Case-insensitive. - final headers = LinkedHashMap( - equals: (a, b) => a.toLowerCase() == b.toLowerCase(), - hashCode: (s) => s.toLowerCase().hashCode); } diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index e49b0753..b0b23a32 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -1,5 +1,4 @@ import 'package:json_api/src/http/http_message.dart'; -import 'package:json_api/src/http/media_type.dart'; /// The response sent by the server and received by the client class HttpResponse extends HttpMessage { @@ -7,23 +6,4 @@ class HttpResponse extends HttpMessage { /// Response status code final int statusCode; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => statusCode == 202; - - /// True for successfully processed requests - bool get isSuccessful => statusCode >= 200 && statusCode < 300 && !isPending; - - /// True for failed requests (i.e. neither successful nor pending) - bool get isFailed => !isSuccessful && !isPending; - - /// True for 204 No Content responses - bool get isNoContent => statusCode == 204; - - bool get hasDocument => - body.isNotEmpty && - (headers['content-type'] ?? '') - .toLowerCase() - .startsWith(MediaType.jsonApi); } diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart new file mode 100644 index 00000000..c59ca8df --- /dev/null +++ b/lib/src/http/status_code.dart @@ -0,0 +1,23 @@ +class StatusCode { + const StatusCode(this.value); + + static const ok = 200; + static const created = 201; + static const accepted = 202; + static const noContent = 204; + static const badRequest = 400; + static const notFound = 404; + static const methodNotAllowed = 405; + + final int value; + + /// True for the requests processed asynchronously. + /// @see https://jsonapi.org/recommendations/#asynchronous-processing). + bool get isPending => value == accepted; + + /// True for successfully processed requests + bool get isSuccessful => value >= ok && value < 300 && !isPending; + + /// True for failed requests (i.e. neither successful nor pending) + bool get isFailed => !isSuccessful && !isPending; +} diff --git a/lib/src/routing/recommended_url_design.dart b/lib/src/routing/standard_uri_design.dart similarity index 57% rename from lib/src/routing/recommended_url_design.dart rename to lib/src/routing/standard_uri_design.dart index aa254cdc..27d13829 100644 --- a/lib/src/routing/recommended_url_design.dart +++ b/lib/src/routing/standard_uri_design.dart @@ -1,64 +1,60 @@ -import 'package:json_api/core.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/routing/target_matcher.dart'; /// URL Design recommended by the standard. /// See https://jsonapi.org/recommendations/#urls -class RecommendedUrlDesign implements UriFactory, TargetMatcher { - /// Creates an instance of RecommendedUrlDesign. +class StandardUriDesign implements UriDesign { + /// Creates an instance of [UriDesign] recommended by JSON:API standard. /// The [base] URI will be used as a prefix for the generated URIs. - const RecommendedUrlDesign(this.base); + const StandardUriDesign(this.base); /// A "path only" version of the recommended URL design, e.g. /// `/books`, `/books/42`, `/books/42/authors` - static final pathOnly = RecommendedUrlDesign(Uri(path: '/')); + static final pathOnly = StandardUriDesign(Uri(path: '/')); + + static Target? matchTarget(Uri uri) { + final s = uri.pathSegments; + if (s.length == 1) { + return Target(s.first); + } + if (s.length == 2) { + return ResourceTarget(s.first, s.last); + } + if (s.length == 3) { + return RelatedTarget(s.first, s[1], s.last); + } + if (s.length == 4 && s[2] == 'relationships') { + return RelationshipTarget(s.first, s[1], s.last); + } + return null; + } final Uri base; /// Returns a URL for the primary resource collection of type [type]. /// E.g. `/books`. @override - Uri collection(CollectionTarget target) => _resolve([target.type]); + Uri collection(String type) => _resolve([type]); /// Returns a URL for the primary resource of type [type] with id [id]. /// E.g. `/books/123`. @override - Uri resource(ResourceTarget target) => - _resolve([target.ref.type, target.ref.id]); + Uri resource(String type, String id) => _resolve([type, id]); /// Returns a URL for the relationship itself. /// The [type] and [id] identify the primary resource and the [relationship] /// is the relationship name. /// E.g. `/books/123/relationships/authors`. @override - Uri relationship(RelationshipTarget target) => _resolve( - [target.ref.type, target.ref.id, 'relationships', target.relationship]); + Uri relationship(String type, String id, String relationship) => + _resolve([type, id, 'relationships', relationship]); /// Returns a URL for the related resource or collection. /// The [type] and [id] identify the primary resource and the [relationship] /// is the relationship name. /// E.g. `/books/123/authors`. @override - Uri related(RelatedTarget target) => - _resolve([target.ref.type, target.ref.id, target.relationship]); - - @override - Target? match(Uri uri) { - final s = uri.pathSegments; - if (s.length == 1) { - return CollectionTarget(s.first); - } - if (s.length == 2) { - return ResourceTarget(Ref(s.first, s.last)); - } - if (s.length == 3) { - return RelatedTarget(Ref(s.first, s[1]), s.last); - } - if (s.length == 4 && s[2] == 'relationships') { - return RelationshipTarget(Ref(s.first, s[1]), s.last); - } - return null; - } + Uri related(String type, String id, String relationship) => + _resolve([type, id, relationship]); Uri _resolve(List pathSegments) => base.resolveUri(Uri(pathSegments: pathSegments)); diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart index c9d1c777..78a59c25 100644 --- a/lib/src/routing/target.dart +++ b/lib/src/routing/target.dart @@ -1,56 +1,35 @@ -import 'package:json_api/core.dart'; - -/// A request target -abstract class Target { - T map(TargetMapper mapper); -} - -abstract class TargetMapper { - T collection(CollectionTarget target); - - T resource(ResourceTarget target); - - T related(RelatedTarget target); - - T relationship(RelationshipTarget target); -} - -class CollectionTarget implements Target { - const CollectionTarget(this.type); +class Target { + const Target(this.type); final String type; - - @override - T map(TargetMapper mapper) => mapper.collection(this); } class ResourceTarget implements Target { - const ResourceTarget(this.ref); - - final Ref ref; + const ResourceTarget(this.type, this.id); @override - T map(TargetMapper mapper) => mapper.resource(this); + final String type; + final String id; } -class RelatedTarget implements Target { - const RelatedTarget(this.ref, this.relationship); +class RelatedTarget implements ResourceTarget { + const RelatedTarget(this.type, this.id, this.relationship); - final Ref ref; + @override + final String type; + @override + final String id; final String relationship; - - @override - T map(TargetMapper mapper) => mapper.related(this); } -class RelationshipTarget implements Target { - const RelationshipTarget(this.ref, this.relationship); +class RelationshipTarget implements ResourceTarget { + const RelationshipTarget(this.type, this.id, this.relationship); - final Ref ref; + @override + final String type; + @override + final String id; final String relationship; - - @override - T map(TargetMapper mapper) => mapper.relationship(this); } diff --git a/lib/src/routing/target_matcher.dart b/lib/src/routing/target_matcher.dart deleted file mode 100644 index 709e1d71..00000000 --- a/lib/src/routing/target_matcher.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:json_api/src/routing/target.dart'; - -abstract class TargetMatcher { - /// Nullable. Returns the URI target. - Target? match(Uri uri); -} diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart new file mode 100644 index 00000000..4bb1519b --- /dev/null +++ b/lib/src/routing/uri_design.dart @@ -0,0 +1,9 @@ +abstract class UriDesign { + Uri collection(String type); + + Uri resource(String type, String id); + + Uri related(String type, String id, String relationship); + + Uri relationship(String type, String id, String relationship); +} diff --git a/lib/src/routing/uri_factory.dart b/lib/src/routing/uri_factory.dart deleted file mode 100644 index 1040f73b..00000000 --- a/lib/src/routing/uri_factory.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:json_api/src/routing/target.dart'; - -abstract class UriFactory extends TargetMapper {} diff --git a/lib/src/server/_internal/cors_http_handler.dart b/lib/src/server/_internal/cors_http_handler.dart deleted file mode 100644 index 7e4fe829..00000000 --- a/lib/src/server/_internal/cors_http_handler.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; - -/// An [HttpHandler] wrapper. Adds CORS headers and handles pre-flight requests. -class CorsHttpHandler implements Handler { - CorsHttpHandler(this._handler); - - final Handler _handler; - - @override - Future call(HttpRequest request) async { - final headers = { - 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', - 'Access-Control-Expose-Headers': 'Location', - }; - - if (request.isOptions) { - const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; - return HttpResponse(204) - ..headers.addAll({ - ...headers, - 'Access-Control-Allow-Methods': - // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? - request.headers['Access-Control-Request-Method']?.toUpperCase() ?? - methods.join(', '), - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? '*', - }); - } - return await _handler(request) - ..headers.addAll(headers); - } -} diff --git a/lib/src/server/_internal/in_memory_repo.dart b/lib/src/server/_internal/in_memory_repo.dart index a44f3166..49e02b88 100644 --- a/lib/src/server/_internal/in_memory_repo.dart +++ b/lib/src/server/_internal/in_memory_repo.dart @@ -1,4 +1,5 @@ -import 'package:json_api/core.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/src/nullable.dart'; import 'repo.dart'; @@ -17,57 +18,57 @@ class InMemoryRepo implements Repo { } @override - Future fetch(Ref ref) async { - return _model(ref); + Future fetch(String type, String id) async { + return _model(type, id); } @override - Future persist(Model model) async { - _collection(model.ref.type)[model.ref.id] = model; + Future persist(String type, Model model) async { + _collection(type)[model.id] = model; } @override - Stream addMany(Ref ref, String rel, Iterable refs) { - final model = _model(ref); - final many = model.many[rel]; - if (many == null) throw RelationshipNotFound(rel); - many.addAll(refs); + Stream addMany( + String type, String id, String rel, Iterable ids) { + final many = _many(type, id, rel); + many.addAll(ids.map(Ref.of)); return Stream.fromIterable(many); } @override - Future delete(Ref ref) async { - _collection(ref.type).remove(ref.id); + Future delete(String type, String id) async { + _collection(type).remove(id); } @override - Future update(Ref ref, ModelProps props) async { - _model(ref).setFrom(props); + Future update(String type, String id, ModelProps props) async { + _model(type, id).setFrom(props); } @override - Future replaceOne(Ref ref, String rel, Ref? one) async { - _model(ref).one[rel] = one; + Future replaceOne( + String type, String id, String rel, Identity? one) async { + _model(type, id).one[rel] = nullable(Ref.of)(one); } @override - Stream deleteMany(Ref ref, String rel, Iterable refs) { - return Stream.fromIterable(_many(ref, rel)..removeAll(refs)); - } + Stream deleteMany( + String type, String id, String rel, Iterable many) => + Stream.fromIterable(_many(type, id, rel)..removeAll(many.map(Ref.of))); @override - Stream replaceMany(Ref ref, String rel, Iterable refs) { - return Stream.fromIterable(_many(ref, rel) - ..clear() - ..addAll(refs)); - } + Stream replaceMany( + String type, String id, String rel, Iterable many) => + Stream.fromIterable(_many(type, id, rel) + ..clear() + ..addAll(many.map(Ref.of))); Map _collection(String type) => (_storage[type] ?? (throw CollectionNotFound())); - Model _model(Ref ref) => - _collection(ref.type)[ref.id] ?? (throw ResourceNotFound()); + Model _model(String type, String id) => + _collection(type)[id] ?? (throw ResourceNotFound()); - Set _many(Ref ref, String rel) => - _model(ref).many[rel] ?? (throw RelationshipNotFound(rel)); + Set _many(String type, String id, String rel) => + _model(type, id).many[rel] ?? (throw RelationshipNotFound()); } diff --git a/lib/src/server/_internal/repo.dart b/lib/src/server/_internal/repo.dart index cda7715d..3a1e5801 100644 --- a/lib/src/server/_internal/repo.dart +++ b/lib/src/server/_internal/repo.dart @@ -1,4 +1,5 @@ -import 'package:json_api/core.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/src/nullable.dart'; abstract class Repo { /// Fetches a collection. @@ -6,43 +7,115 @@ abstract class Repo { Stream fetchCollection(String type); /// Throws [ResourceNotFound] - Future fetch(Ref ref); + Future fetch(String type, String id); /// Throws [CollectionNotFound]. - Future persist(Model model); + Future persist(String type, Model model); /// Add refs to a to-many relationship /// Throws [CollectionNotFound]. /// Throws [ResourceNotFound]. /// Throws [RelationshipNotFound]. - Stream addMany(Ref ref, String rel, Iterable refs); + Stream addMany( + String type, String id, String rel, Iterable refs); /// Delete the resource - Future delete(Ref ref); + Future delete(String type, String id); /// Updates the model - Future update(Ref ref, ModelProps props); + Future update(String type, String id, ModelProps props); - Future replaceOne(Ref ref, String rel, Ref? one); + Future replaceOne(String type, String id, String rel, Identity? ref); /// Deletes refs from the to-many relationship. /// Returns the new actual refs. - Stream deleteMany(Ref ref, String rel, Iterable refs); + Stream deleteMany( + String type, String id, String rel, Iterable refs); /// Replaces refs in the to-many relationship. /// Returns the new actual refs. - Stream replaceMany(Ref ref, String rel, Iterable refs); + Stream replaceMany( + String type, String id, String rel, Iterable refs); } class CollectionNotFound implements Exception {} class ResourceNotFound implements Exception {} -class RelationshipNotFound implements Exception { - RelationshipNotFound(this.message); +class RelationshipNotFound implements Exception {} - final String message; +class Ref with Identity { + Ref(this.type, this.id); + + static Ref of(Identity identity) => Ref(identity.type, identity.id); + + @override + final String type; + @override + final String id; @override - String toString() => message; + final hashCode = 0; + + @override + bool operator ==(Object other) => + other is Ref && type == other.type && id == other.id; +} + +class ModelProps { + static ModelProps fromResource(ResourceProperties res) { + final props = ModelProps(); + res.attributes.forEach((key, value) { + props.attributes[key] = value; + }); + res.relationships.forEach((key, value) { + if (value is ToOne) { + props.one[key] = nullable(Ref.of)(value.identifier); + return; + } + if (value is ToMany) { + props.many[key] = Set.of(value.map(Ref.of)); + return; + } + }); + return props; + } + + final attributes = {}; + final one = {}; + final many = >{}; + + void setFrom(ModelProps other) { + other.attributes.forEach((key, value) { + attributes[key] = value; + }); + other.one.forEach((key, value) { + one[key] = value; + }); + other.many.forEach((key, value) { + many[key] = {...value}; + }); + } +} + +/// A model of a resource. Essentially, this is the core of a resource object. +class Model extends ModelProps { + Model(this.id); + + final String id; + + Resource toResource(String type) { + final res = Resource(type, id); + attributes.forEach((key, value) { + res.attributes[key] = value; + }); + one.forEach((key, value) { + res.relationships[key] = + (value == null ? ToOne.empty() : ToOne(Identifier.of(value))); + }); + many.forEach((key, value) { + res.relationships[key] = ToMany(value.map(Identifier.of)); + }); + return res; + } } diff --git a/lib/src/server/_internal/repository_controller.dart b/lib/src/server/_internal/repository_controller.dart index 0cff41c2..777b1028 100644 --- a/lib/src/server/_internal/repository_controller.dart +++ b/lib/src/server/_internal/repository_controller.dart @@ -1,8 +1,9 @@ -import 'package:json_api/core.dart'; +import 'package:json_api/codec.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/document/inbound_document.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/_internal/relationship_node.dart'; import 'package:json_api/src/server/_internal/repo.dart'; @@ -16,14 +17,14 @@ class RepositoryController implements Controller { final IdGenerator getId; - final urlDesign = RecommendedUrlDesign.pathOnly; + final design = StandardUriDesign.pathOnly; @override Future fetchCollection( - HttpRequest request, CollectionTarget target) async { + HttpRequest request, Target target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) - ..links['self'] = Link(target.map(urlDesign)); + ..links['self'] = Link(design.collection(target.type)); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); for (final r in resources) { await for (final r in _getAllRelated(r, forest)) { @@ -36,9 +37,9 @@ class RepositoryController implements Controller { @override Future fetchResource( HttpRequest request, ResourceTarget target) async { - final resource = await _fetchLinkedResource(target.ref); + final resource = await _fetchLinkedResource(target.type, target.id); final doc = OutboundDataDocument.resource(resource) - ..links['self'] = Link(target.map(urlDesign)); + ..links['self'] = Link(design.resource(target.type, target.id)); final forest = RelationshipNode.forest(Include.fromUri(request.uri)); await for (final r in _getAllRelated(resource, forest)) { doc.included.add(r); @@ -48,15 +49,17 @@ class RepositoryController implements Controller { @override Future createResource( - HttpRequest request, CollectionTarget target) async { - final res = _decode(request).newResource(); + HttpRequest request, Target target) async { + final res = (await _decode(request)).dataAsNewResource(); final ref = Ref(res.type, res.id ?? getId()); - await repo.persist(Model(ref)..setFrom(res.toModelProps())); + await repo.persist( + res.type, Model(ref.id)..setFrom(ModelProps.fromResource(res))); if (res.id != null) { return JsonApiResponse.noContent(); } - final self = Link(ResourceTarget(ref).map(urlDesign)); - final resource = (await _fetchResource(ref))..links['self'] = self; + final self = Link(design.resource(ref.type, ref.id)); + final resource = (await _fetchResource(ref.type, ref.id)) + ..links['self'] = self; return JsonApiResponse.created( OutboundDataDocument.resource(resource)..links['self'] = self, self.uri.toString()); @@ -65,42 +68,43 @@ class RepositoryController implements Controller { @override Future addMany( HttpRequest request, RelationshipTarget target) async { - final many = _decode(request).dataAsRelationship(); + final many = (await _decode(request)).asRelationship(); final refs = await repo - .addMany(target.ref, target.relationship, many.map((_) => _.ref)) + .addMany(target.type, target.id, target.relationship, many) .toList(); return JsonApiResponse.ok( - OutboundDataDocument.many(ToMany(refs.map(_toIdentifier)))); + OutboundDataDocument.many(ToMany(refs.map(Identifier.of)))); } @override Future deleteResource( HttpRequest request, ResourceTarget target) async { - await repo.delete(target.ref); + await repo.delete(target.type, target.id); return JsonApiResponse.noContent(); } @override Future updateResource( HttpRequest request, ResourceTarget target) async { - await repo.update(target.ref, _decode(request).resource().toModelProps()); + await repo.update(target.type, target.id, + ModelProps.fromResource((await _decode(request)).dataAsResource())); return JsonApiResponse.noContent(); } @override Future replaceRelationship( HttpRequest request, RelationshipTarget target) async { - final rel = _decode(request).dataAsRelationship(); + final rel = (await _decode(request)).asRelationship(); if (rel is ToOne) { - final ref = rel.identifier?.ref; - await repo.replaceOne(target.ref, target.relationship, ref); + final ref = rel.identifier; + await repo.replaceOne(target.type, target.id, target.relationship, ref); return JsonApiResponse.ok(OutboundDataDocument.one( - ref == null ? ToOne.empty() : ToOne(Identifier(ref)))); + ref == null ? ToOne.empty() : ToOne(Identifier.of(ref)))); } if (rel is ToMany) { final ids = await repo - .replaceMany(target.ref, target.relationship, rel.map((_) => _.ref)) - .map(_toIdentifier) + .replaceMany(target.type, target.id, target.relationship, rel) + .map(Identifier.of) .toList(); return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); } @@ -110,10 +114,10 @@ class RepositoryController implements Controller { @override Future deleteMany( HttpRequest request, RelationshipTarget target) async { - final rel = _decode(request).dataAsRelationship(); + final rel = (await _decode(request)).asToMany(); final ids = await repo - .deleteMany(target.ref, target.relationship, rel.map((_) => _.ref)) - .map(_toIdentifier) + .deleteMany(target.type, target.id, target.relationship, rel) + .map(Identifier.of) .toList(); return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); } @@ -121,43 +125,39 @@ class RepositoryController implements Controller { @override Future fetchRelationship( HttpRequest request, RelationshipTarget target) async { - final model = (await repo.fetch(target.ref)); + final model = (await repo.fetch(target.type, target.id)); if (model.one.containsKey(target.relationship)) { return JsonApiResponse.ok(OutboundDataDocument.one( - ToOne(nullable(_toIdentifier)(model.one[target.relationship])))); + ToOne(nullable(Identifier.of)(model.one[target.relationship])))); } final many = model.many[target.relationship]; if (many != null) { - final doc = OutboundDataDocument.many(ToMany(many.map(_toIdentifier))); + final doc = OutboundDataDocument.many(ToMany(many.map(Identifier.of))); return JsonApiResponse.ok(doc); } - // TODO: implement fetchRelationship - throw UnimplementedError(); + throw RelationshipNotFound(); } @override Future fetchRelated( HttpRequest request, RelatedTarget target) async { - final model = await repo.fetch(target.ref); + final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final related = await nullable(_fetchRelatedResource)(model.one[target.relationship]); final doc = OutboundDataDocument.resource(related); return JsonApiResponse.ok(doc); } - final many = model.many[target.relationship]; - if (many != null) { + if (model.many.containsKey(target.relationship)) { + final many = model.many[target.relationship] ?? {}; final doc = OutboundDataDocument.collection( await _fetchRelatedCollection(many).toList()); return JsonApiResponse.ok(doc); } - // TODO: implement fetchRelated - throw UnimplementedError(); + throw RelationshipNotFound(); } - Identifier _toIdentifier(Ref ref) => Identifier(ref); - /// Returns a stream of related resources recursively Stream _getAllRelated( Resource resource, Iterable forest) async* { @@ -172,28 +172,27 @@ class RepositoryController implements Controller { /// Returns a stream of related resources Stream _getRelated(Resource resource, String relationship) async* { for (final _ in resource.relationships[relationship] ?? - (throw RelationshipNotFound(relationship))) { - yield await _fetchLinkedResource(_.ref); + (throw RelationshipNotFound())) { + yield await _fetchLinkedResource(_.type, _.id); } } /// Fetches and builds a resource object with a "self" link - Future _fetchLinkedResource(Ref ref) async { - return (await _fetchResource(ref)) - ..links['self'] = Link(ResourceTarget(ref).map(urlDesign)); + Future _fetchLinkedResource(String type, String id) async { + return (await _fetchResource(type, id)) + ..links['self'] = Link(design.resource(type, id)); } Stream _fetchAll(String type) => - repo.fetchCollection(type).map(_toResource); + repo.fetchCollection(type).map((_) => _.toResource(type)); /// Fetches and builds a resource object - Future _fetchResource(ref) async { - return _toResource(await repo.fetch(ref)); + Future _fetchResource(String type, String id) async { + return (await repo.fetch(type, id)).toResource(type); } Future _fetchRelatedResource(Ref ref) { - final id = Identifier(ref); - return _fetchLinkedResource(ref); + return _fetchLinkedResource(ref.type, ref.id); } Stream _fetchRelatedCollection(Iterable refs) async* { @@ -203,22 +202,8 @@ class RepositoryController implements Controller { } } - Resource _toResource(Model model) { - final res = Resource(model.ref); - model.attributes.forEach((key, value) { - res.attributes[key] = value; - }); - model.one.forEach((key, value) { - res.relationships[key] = - (value == null ? ToOne.empty() : ToOne(Identifier(value))); - }); - model.many.forEach((key, value) { - res.relationships[key] = ToMany(value.map(_toIdentifier)); - }); - return res; - } - - InboundDocument _decode(HttpRequest r) => InboundDocument.decode(r.body); + Future _decode(HttpRequest r) async => + InboundDocument(await DefaultCodec().decode(r.body)); } typedef IdGenerator = String Function(); diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 46fa9e60..8ad6be5e 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -3,10 +3,10 @@ import 'package:json_api/routing.dart'; abstract class Controller { /// Fetch a primary resource collection - Future fetchCollection(HttpRequest request, CollectionTarget target); + Future fetchCollection(HttpRequest request, Target target); /// Create resource - Future createResource(HttpRequest request, CollectionTarget target); + Future createResource(HttpRequest request, Target target); /// Fetch a single primary resource Future fetchResource(HttpRequest request, ResourceTarget target); diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart index 11070a3f..9d5845b2 100644 --- a/lib/src/server/json_api_response.dart +++ b/lib/src/server/json_api_response.dart @@ -18,23 +18,19 @@ class JsonApiResponse extends HttpResponse { String get body => nullable(jsonEncode)(document) ?? ''; static JsonApiResponse ok(OutboundDocument document) => - JsonApiResponse(200, document: document); + JsonApiResponse(StatusCode.ok, document: document); - static JsonApiResponse noContent() => JsonApiResponse(204); + static JsonApiResponse noContent() => JsonApiResponse(StatusCode.noContent); static JsonApiResponse created(OutboundDocument document, String location) => - JsonApiResponse(201, document: document)..headers['location'] = location; + JsonApiResponse(StatusCode.created, document: document)..headers['location'] = location; static JsonApiResponse notFound([OutboundErrorDocument? document]) => - JsonApiResponse(404, document: document); + JsonApiResponse(StatusCode.notFound, document: document); static JsonApiResponse methodNotAllowed([OutboundErrorDocument? document]) => - JsonApiResponse(405, document: document); + JsonApiResponse(StatusCode.methodNotAllowed, document: document); static JsonApiResponse badRequest([OutboundErrorDocument? document]) => - JsonApiResponse(400, document: document); - - static JsonApiResponse internalServerError( - [OutboundErrorDocument? document]) => - JsonApiResponse(500, document: document); + JsonApiResponse(StatusCode.badRequest, document: document); } diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 502145bc..bd465250 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,6 +1,6 @@ -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/http/handler.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/method_not_allowed.dart'; import 'package:json_api/src/server/unmatched_target.dart'; @@ -14,12 +14,22 @@ class Router implements Handler { @override Future call(HttpRequest request) async { final target = matchTarget(request.uri); - if (target is CollectionTarget) { + if (target is RelationshipTarget) { if (request.isGet) { - return await controller.fetchCollection(request, target); + return await controller.fetchRelationship(request, target); } - if (request.isPost) { - return await controller.createResource(request, target); + if (request.isPost) return await controller.addMany(request, target); + if (request.isPatch) { + return await controller.replaceRelationship(request, target); + } + if (request.isDelete) { + return await controller.deleteMany(request, target); + } + throw MethodNotAllowed(request.method); + } + if (target is RelatedTarget) { + if (request.isGet) { + return await controller.fetchRelated(request, target); } throw MethodNotAllowed(request.method); } @@ -35,25 +45,16 @@ class Router implements Handler { } throw MethodNotAllowed(request.method); } - if (target is RelationshipTarget) { + if (target is Target) { if (request.isGet) { - return await controller.fetchRelationship(request, target); - } - if (request.isPost) return await controller.addMany(request, target); - if (request.isPatch) { - return await controller.replaceRelationship(request, target); - } - if (request.isDelete) { - return await controller.deleteMany(request, target); + return await controller.fetchCollection(request, target); } - throw MethodNotAllowed(request.method); - } - if (target is RelatedTarget) { - if (request.isGet) { - return await controller.fetchRelated(request, target); + if (request.isPost) { + return await controller.createResource(request, target); } throw MethodNotAllowed(request.method); } + throw UnmatchedTarget(request.uri); } } diff --git a/lib/src/server/routing_error_converter.dart b/lib/src/server/routing_error_converter.dart deleted file mode 100644 index 8b92e9a1..00000000 --- a/lib/src/server/routing_error_converter.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/method_not_allowed.dart'; -import 'package:json_api/src/server/unmatched_target.dart'; - -Future routingErrorConverter(dynamic error) async { - if (error is MethodNotAllowed) { - return JsonApiResponse.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return JsonApiResponse.badRequest(); - } -} diff --git a/lib/src/test/mock_handler.dart b/lib/src/test/mock_handler.dart index f952fc83..2dcc7787 100644 --- a/lib/src/test/mock_handler.dart +++ b/lib/src/test/mock_handler.dart @@ -1,10 +1,10 @@ -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; class MockHandler implements Handler { late HttpRequest request; late HttpResponse response; + @override Future call(HttpRequest request) async { this.request = request; return response; diff --git a/pubspec.yaml b/pubspec.yaml index dfc05f81..6bbe334b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,12 @@ name: json_api -version: 5.0.0-nullsafety.6 +version: 5.0.0-nullsafety.7 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: sdk: '>=2.12.0-29 <3.0.0' -dependencies: - http: ^0.12.2 dev_dependencies: pedantic: ^1.10.0-nullsafety test: ^1.16.0-nullsafety + http: ^0.12.2 test_coverage: ^0.5.0 stream_channel: ^2.0.0 diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index 9a7b1732..ea8339d5 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -6,10 +6,11 @@ import 'package:test/test.dart'; import '../src/demo_handler.dart'; void main() { - late JsonApiClient client; + late RoutingClient client; setUp(() async { - client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); + client = + RoutingClient(StandardUriDesign.pathOnly, BasicClient(DemoHandler())); }); group('CRUD', () { @@ -26,19 +27,19 @@ void main() { .resource; post = (await client.createNew('posts', attributes: {'title': 'Hello world'}, - one: {'author': Identifier(alice.ref)}, + one: {'author': Identifier.of(alice)}, many: {'comments': []})) .resource; comment = (await client.createNew('comments', attributes: {'text': 'Hi Alice'}, - one: {'author': Identifier(bob.ref)})) + one: {'author': Identifier.of(bob)})) .resource; secretComment = (await client.createNew('comments', attributes: {'text': 'Secret comment'}, - one: {'author': Identifier(bob.ref)})) + one: {'author': Identifier.of(bob)})) .resource; - await client.addMany( - post.ref.type, post.ref.id, 'comments', [Identifier(comment.ref)]); + await client + .addMany(post.type, post.id, 'comments', [Identifier.of(comment)]); }); test('Fetch a complex resource', () async { @@ -62,14 +63,14 @@ void main() { }); test('Delete a resource', () async { - await client.deleteResource(post.ref.type, post.ref.id); + await client.deleteResource(post.type, post.id); await client.fetchCollection('posts').then((r) { expect(r.collection, isEmpty); }); }); test('Update a resource', () async { - await client.updateResource(post.ref.type, post.ref.id, + await client.updateResource(post.type, post.id, attributes: {'title': 'Bob was here'}); await client.fetchCollection('posts').then((r) { expect(r.collection.single.attributes['title'], 'Bob was here'); @@ -77,66 +78,62 @@ void main() { }); test('Fetch a related resource', () async { - await client - .fetchRelatedResource(post.ref.type, post.ref.id, 'author') - .then((r) { + await client.fetchRelatedResource(post.type, post.id, 'author').then((r) { expect(r.resource?.attributes['name'], 'Alice'); }); }); test('Fetch a related collection', () async { await client - .fetchRelatedCollection(post.ref.type, post.ref.id, 'comments') + .fetchRelatedCollection(post.type, post.id, 'comments') .then((r) { expect(r.collection.single.attributes['text'], 'Hi Alice'); }); }); test('Fetch a to-one relationship', () async { - await client.fetchToOne(post.ref.type, post.ref.id, 'author').then((r) { - expect(r.relationship.identifier?.ref, alice.ref); + await client.fetchToOne(post.type, post.id, 'author').then((r) { + expect(Identity.same(r.relationship.identifier!, alice), isTrue); }); }); test('Fetch a to-many relationship', () async { - await client - .fetchToMany(post.ref.type, post.ref.id, 'comments') - .then((r) { - expect(r.relationship.single.ref, comment.ref); + await client.fetchToMany(post.type, post.id, 'comments').then((r) { + expect(Identity.same(r.relationship.single, comment), isTrue); }); }); test('Delete a to-one relationship', () async { - await client.deleteToOne(post.ref.type, post.ref.id, 'author'); - await client.fetchResource(post.ref.type, post.ref.id, - include: ['author']).then((r) { + await client.deleteToOne(post.type, post.id, 'author'); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { expect(r.resource.one('author'), isEmpty); }); }); test('Replace a to-one relationship', () async { await client.replaceToOne( - post.ref.type, post.ref.id, 'author', Identifier(bob.ref)); - await client.fetchResource(post.ref.type, post.ref.id, - include: ['author']).then((r) { + post.type, post.id, 'author', Identifier.of(bob)); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { expect(r.resource.one('author')?.findIn(r.included)?.attributes['name'], 'Bob'); }); }); test('Delete from a to-many relationship', () async { - await client.deleteFromToMany( - post.ref.type, post.ref.id, 'comments', [Identifier(comment.ref)]); - await client.fetchResource(post.ref.type, post.ref.id).then((r) { + await client.deleteFromMany( + post.type, post.id, 'comments', [Identifier.of(comment)]); + await client.fetchResource(post.type, post.id).then((r) { expect(r.resource.many('comments'), isEmpty); }); }); test('Replace a to-many relationship', () async { - await client.replaceToMany(post.ref.type, post.ref.id, 'comments', - [Identifier(secretComment.ref)]); - await client.fetchResource(post.ref.type, post.ref.id, - include: ['comments']).then((r) { + await client.replaceToMany( + post.type, post.id, 'comments', [Identifier.of(secretComment)]); + await client + .fetchResource(post.type, post.id, include: ['comments']).then((r) { expect( r.resource .many('comments')! @@ -153,5 +150,25 @@ void main() { 'Secret comment'); }); }); + + test('Incomplete relationship', () async {}); + + test('404', () async { + final actions = [ + () => client.fetchCollection('unicorns'), + () => client.fetchResource('posts', 'zzz'), + () => client.fetchRelatedResource(post.type, post.id, 'zzz'), + () => client.fetchToOne(post.type, post.id, 'zzz'), + () => client.fetchToMany(post.type, post.id, 'zzz'), + ]; + for (final action in actions) { + try { + await action(); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + } + } + }); }); } diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index afc98a0d..06b8400d 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,30 +1,28 @@ import 'package:json_api/client.dart'; -import 'package:json_api/core.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import '../src/demo_handler.dart'; void main() { - late JsonApiClient client; + late BasicClient client; setUp(() async { - client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); + client = BasicClient(DemoHandler()); }); group('Errors', () { test('Method not allowed', () async { - final ref = Ref('posts', '1'); - final badRequests = [ - Request('delete', CollectionTarget('posts')), - Request('post', ResourceTarget(ref)), - Request('post', RelatedTarget(ref, 'author')), - Request('head', RelationshipTarget(ref, 'author')), + final actions = [ + () => client.send(Uri.parse('/posts'), Request('delete')), + () => client.send(Uri.parse('/posts/1'), Request('post')), + () => client.send(Uri.parse('/posts/1/author'), Request('post')), + () => client.send( + Uri.parse('/posts/1/relationships/author'), Request('head')), ]; - for (final request in badRequests) { + for (final action in actions) { try { - await client.send(request); + await action(); fail('Exception expected'); } on RequestFailure catch (response) { expect(response.http.statusCode, 405); @@ -36,19 +34,5 @@ void main() { .call(HttpRequest('get', Uri.parse('/a/long/prefix/'))); expect(r.statusCode, 400); }); - test('404', () async { - final actions = [ - () => client.fetchCollection('unicorns'), - () => client.fetchResource('posts', '1'), - ]; - for (final action in actions) { - try { - await action(); - fail('Exception expected'); - } on RequestFailure catch (e) { - expect(e.http.statusCode, 404); - } - } - }); }); } diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 359c3b05..9351e51c 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -5,10 +5,11 @@ import 'package:test/test.dart'; import '../src/demo_handler.dart'; void main() { - late JsonApiClient client; + late RoutingClient client; setUp(() async { - client = JsonApiClient(DemoHandler(), RecommendedUrlDesign.pathOnly); + client = + RoutingClient(StandardUriDesign.pathOnly, BasicClient(DemoHandler())); }); group('Resource creation', () { @@ -16,13 +17,11 @@ void main() { await client .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { expect(r.http.statusCode, 201); - // TODO: Why does "Location" header not work in browsers? - expect(r.http.headers['location'], '/posts/${r.resource.ref.id}'); - expect(r.links['self'].toString(), '/posts/${r.resource.ref.id}'); - expect(r.resource.ref.type, 'posts'); + expect(r.http.headers['location'], '/posts/${r.resource.id}'); + expect(r.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); expect(r.resource.attributes['title'], 'Hello world'); - expect( - r.resource.links['self'].toString(), '/posts/${r.resource.ref.id}'); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); }); }); test('Resource id assigned on the client', () async { diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 16780428..507e73da 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -3,18 +3,19 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; +import '../../legacy/dart_http_handler.dart'; import 'e2e_test_set.dart'; void main() { - JsonApiClient client; + RoutingClient client; setUp(() async { final channel = spawnHybridUri('hybrid_server.dart'); final serverUrl = await channel.stream.first; // final serverUrl = 'http://localhost:8080'; - client = JsonApiClient(DartHttpHandler(), - RecommendedUrlDesign(Uri.parse(serverUrl.toString()))); + client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())), + BasicClient(DartHttpHandler())); }); test('On Browser', () { e2eTests(client); diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index c1366187..d8d94766 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -1,12 +1,12 @@ import 'package:json_api/client.dart'; import 'package:test/test.dart'; -void e2eTests(JsonApiClient client) async { +void e2eTests(RoutingClient client) async { await _testAllHttpMethods(client); await _testLocationIsSet(client); } -Future _testAllHttpMethods(JsonApiClient client) async { +Future _testAllHttpMethods(RoutingClient client) async { final id = '12345'; // POST await client.create('posts', id, attributes: {'title': 'Hello world'}); @@ -26,7 +26,7 @@ Future _testAllHttpMethods(JsonApiClient client) async { }); } -Future _testLocationIsSet(JsonApiClient client) async { +Future _testLocationIsSet(RoutingClient client) async { await client .createNew('posts', attributes: {'title': 'Location test'}).then((r) { expect(r.http.headers['Location'], isNotEmpty); diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 666d0e07..4803b3ea 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -3,18 +3,20 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; +import '../../legacy/dart_http_handler.dart'; import '../src/demo_handler.dart'; import '../src/json_api_server.dart'; import 'e2e_test_set.dart'; void main() { - JsonApiClient client; + RoutingClient client; JsonApiServer server; setUp(() async { server = JsonApiServer(DemoHandler(), port: 8001); await server.start(); - client = JsonApiClient(DartHttpHandler(), RecommendedUrlDesign(server.uri)); + client = RoutingClient( + StandardUriDesign(server.uri), BasicClient(DartHttpHandler())); }); tearDown(() async { diff --git a/test/handler/logging_handler_test.dart b/test/handler/logging_handler_test.dart index 0ffe746d..c4a60c12 100644 --- a/test/handler/logging_handler_test.dart +++ b/test/handler/logging_handler_test.dart @@ -1,4 +1,4 @@ -import 'package:json_api/handler.dart'; +import 'package:json_api/src/http/handler.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/src/dart_io_http_handler.dart b/test/src/dart_io_http_handler.dart index 36635516..d3dba744 100644 --- a/test/src/dart_io_http_handler.dart +++ b/test/src/dart_io_http_handler.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io' as io; -import 'package:json_api/handler.dart'; +import 'package:json_api/src/http/handler.dart'; import 'package:json_api/http.dart'; Future Function(io.HttpRequest ioRequest) dartIOHttpHandler( diff --git a/test/src/demo_handler.dart b/test/src/demo_handler.dart index 39cb7af6..96e5cf1a 100644 --- a/test/src/demo_handler.dart +++ b/test/src/demo_handler.dart @@ -1,15 +1,16 @@ import 'package:json_api/document.dart'; -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/server/_internal/cors_http_handler.dart'; +import 'package:json_api/src/http/handler.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; import 'package:json_api/src/server/_internal/repo.dart'; import 'package:json_api/src/server/_internal/repository_controller.dart'; import 'package:json_api/src/server/method_not_allowed.dart'; import 'package:json_api/src/server/unmatched_target.dart'; +import 'sequential_numbers.dart'; + class DemoHandler implements Handler { DemoHandler( {void Function(HttpRequest request)? logRequest, @@ -17,9 +18,9 @@ class DemoHandler implements Handler { final repo = InMemoryRepo(['users', 'posts', 'comments']); _handler = LoggingHandler( - CorsHttpHandler(TryCatchHandler( - Router(RepositoryController(repo, _nextId), - RecommendedUrlDesign.pathOnly.match), + _Cors(TryCatchHandler( + Router(RepositoryController(repo, sequentialNumbers), + StandardUriDesign.matchTarget), _onError)), onResponse: logResponse, onRequest: logRequest); @@ -49,13 +50,41 @@ class DemoHandler implements Handler { return JsonApiResponse.notFound( OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); } - return JsonApiResponse.internalServerError(OutboundErrorDocument([ - ErrorObject( - title: 'Error: ${error.runtimeType}', detail: error.toString()) - ])); + return JsonApiResponse(500, + document: OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); } } -int _id = 0; +/// Adds CORS headers and handles pre-flight requests. +class _Cors implements Handler { + _Cors(this._handler); + + final Handler _handler; + + @override + Future call(HttpRequest request) async { + final headers = { + 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', + 'Access-Control-Expose-Headers': 'Location', + }; -String _nextId() => (_id++).toString(); + if (request.isOptions) { + const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; + return HttpResponse(204) + ..headers.addAll({ + ...headers, + 'Access-Control-Allow-Methods': + // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? + request.headers['Access-Control-Request-Method']?.toUpperCase() ?? + methods.join(', '), + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? '*', + }); + } + return await _handler(request) + ..headers.addAll(headers); + } +} diff --git a/test/src/json_api_server.dart b/test/src/json_api_server.dart index 91ac94b9..bf310ccc 100644 --- a/test/src/json_api_server.dart +++ b/test/src/json_api_server.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; class JsonApiServer { diff --git a/test/src/sequential_numbers.dart b/test/src/sequential_numbers.dart new file mode 100644 index 00000000..2fdef157 --- /dev/null +++ b/test/src/sequential_numbers.dart @@ -0,0 +1,2 @@ +String sequentialNumbers() => (_num++).toString(); +int _num = 0; diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index b5aa6c49..bb3559ca 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:json_api/client.dart'; -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/test/mock_handler.dart'; @@ -10,7 +9,7 @@ import 'package:test/test.dart'; void main() { final http = MockHandler(); - final client = JsonApiClient(http, RecommendedUrlDesign.pathOnly); + final client = RoutingClient(StandardUriDesign.pathOnly, BasicClient(http)); group('Failure', () { test('RequestFailure', () async { @@ -39,8 +38,8 @@ void main() { test('Min', () async { http.response = mock.collectionMin; final response = await client.fetchCollection('articles'); - expect(response.collection.single.ref.type, 'articles'); - expect(response.collection.single.ref.id, '1'); + expect(response.collection.single.type, 'articles'); + expect(response.collection.single.id, '1'); expect(response.included, isEmpty); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles'); @@ -129,7 +128,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.fetchResource('articles', '1'); - expect(response.resource.ref.type, 'articles'); + expect(response.resource.type, 'articles'); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); @@ -146,7 +145,7 @@ void main() { ], fields: { 'author': ['name'] }); - expect(response.resource.ref.type, 'articles'); + expect(response.resource.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1'); @@ -162,7 +161,7 @@ void main() { http.response = mock.primaryResource; final response = await client.fetchRelatedResource('articles', '1', 'author'); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.toString(), '/articles/1/author'); @@ -181,7 +180,7 @@ void main() { ], fields: { 'author': ['name'] }); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(response.included.length, 3); expect(http.request.method, 'get'); expect(http.request.uri.path, '/articles/1/author'); @@ -230,7 +229,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.createNew('articles'); - expect(response.resource.ref.type, 'articles'); + expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); @@ -250,15 +249,15 @@ void main() { final response = await client.createNew('articles', attributes: { 'cool': true }, one: { - 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource.ref.type, 'articles'); + expect(response.resource.type, 'articles'); expect( response.links['self'].toString(), 'http://example.com/articles/1'); expect(response.included.length, 3); @@ -298,7 +297,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.create('articles', '1'); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { @@ -330,15 +329,15 @@ void main() { final response = await client.create('articles', '1', attributes: { 'cool': true }, one: { - 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles'); expect(http.request.headers, { @@ -376,7 +375,7 @@ void main() { test('Min', () async { http.response = mock.primaryResource; final response = await client.updateResource('articles', '1'); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { @@ -409,15 +408,15 @@ void main() { await client.updateResource('articles', '1', attributes: { 'cool': true }, one: { - 'author': Identifier(Ref('people', '42'))..meta.addAll({'hey': 'yos'}) + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) }, many: { - 'tags': [Identifier(Ref('tags', '1')), Identifier(Ref('tags', '2'))] + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] }, meta: { 'answer': 42 }, headers: { 'foo': 'bar' }); - expect(response.resource?.ref.type, 'articles'); + expect(response.resource?.type, 'articles'); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1'); expect(http.request.headers, { @@ -455,7 +454,7 @@ void main() { test('Min', () async { http.response = mock.one; final response = await client.replaceToOne( - 'articles', '1', 'author', Identifier(Ref('people', '42'))); + 'articles', '1', 'author', Identifier('people', '42')); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); @@ -471,7 +470,7 @@ void main() { test('Full', () async { http.response = mock.one; final response = await client.replaceToOne( - 'articles', '1', 'author', Identifier(Ref('people', '42')), + 'articles', '1', 'author', Identifier('people', '42'), headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'patch'); @@ -490,7 +489,7 @@ void main() { http.response = mock.error422; try { await client.replaceToOne( - 'articles', '1', 'author', Identifier(Ref('people', '42'))); + 'articles', '1', 'author', Identifier('people', '42')); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -502,7 +501,7 @@ void main() { http.response = mock.many; expect( () => client.replaceToOne( - 'articles', '1', 'author', Identifier(Ref('people', '42'))), + 'articles', '1', 'author', Identifier('people', '42')), throwsFormatException); }); }); @@ -512,7 +511,7 @@ void main() { http.response = mock.oneEmpty; final response = await client.deleteToOne('articles', '1', 'author'); expect(response.relationship, isA()); - expect(response.relationship.identifier, isNull); + expect(response.relationship!.identifier, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { @@ -527,7 +526,7 @@ void main() { final response = await client .deleteToOne('articles', '1', 'author', headers: {'foo': 'bar'}); expect(response.relationship, isA()); - expect(response.relationship.identifier, isNull); + expect(response.relationship!.identifier, isNull); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/author'); expect(http.request.headers, { @@ -559,8 +558,8 @@ void main() { group('Delete Many', () { test('Min', () async { http.response = mock.many; - final response = await client.deleteFromToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + final response = await client + .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'delete'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -577,8 +576,8 @@ void main() { test('Full', () async { http.response = mock.many; - final response = await client.deleteFromToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], + final response = await client.deleteFromMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'delete'); @@ -598,8 +597,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client.deleteFromToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + await client + .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -610,8 +609,8 @@ void main() { test('Throws FormatException', () async { http.response = mock.one; expect( - () => client.deleteFromToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), + () => client.deleteFromMany( + 'articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); @@ -619,8 +618,8 @@ void main() { group('Replace Many', () { test('Min', () async { http.response = mock.many; - final response = await client.replaceToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + final response = await client + .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'patch'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -638,7 +637,7 @@ void main() { test('Full', () async { http.response = mock.many; final response = await client.replaceToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], + 'articles', '1', 'tags', [Identifier('tags', '1')], headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'patch'); @@ -658,8 +657,8 @@ void main() { test('Throws RequestFailure', () async { http.response = mock.error422; try { - await client.replaceToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + await client + .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -671,7 +670,7 @@ void main() { http.response = mock.one; expect( () => client.replaceToMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), + 'articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); @@ -680,7 +679,7 @@ void main() { test('Min', () async { http.response = mock.many; final response = await client - .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); expect(response.relationship, isA()); expect(http.request.method, 'post'); expect(http.request.uri.toString(), '/articles/1/relationships/tags'); @@ -698,7 +697,7 @@ void main() { test('Full', () async { http.response = mock.many; final response = await client.addMany( - 'articles', '1', 'tags', [Identifier(Ref('tags', '1'))], + 'articles', '1', 'tags', [Identifier('tags', '1')], headers: {'foo': 'bar'}); expect(response.relationship, isA()); expect(http.request.method, 'post'); @@ -719,7 +718,7 @@ void main() { http.response = mock.error422; try { await client - .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]); + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); fail('Exception expected'); } on RequestFailure catch (e) { expect(e.http.statusCode, 422); @@ -732,7 +731,7 @@ void main() { http.response = mock.one; expect( () => client - .addMany('articles', '1', 'tags', [Identifier(Ref('tags', '1'))]), + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), throwsFormatException); }); }); diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 1d670f89..6c4ae351 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:json_api/document.dart'; import 'package:json_api/src/test/payload.dart' as payload; import 'package:test/test.dart'; @@ -10,7 +8,7 @@ void main() { test('Minimal', () { final e = InboundDocument({ 'errors': [{}] - }).errors.first; + }).errors().first; expect(e.id, ''); expect(e.status, ''); expect(e.code, ''); @@ -36,7 +34,7 @@ void main() { }; final e = InboundDocument({ 'errors': [error] - }).errors.first; + }).errors().first; expect(e.id, 'test_id'); expect(e.status, 'test_status'); @@ -57,7 +55,7 @@ void main() { 'errors': [ {'id': []} ] - }).errors.first, + }).errors().first, throwsFormatException); }); }); @@ -67,120 +65,118 @@ void main() { final doc = InboundDocument(payload.example); expect( doc - .resourceCollection() + .dataAsCollection() .first .relationships['author']! .links['self']! .uri .toString(), 'http://example.com/articles/1/relationships/author'); - expect(doc.included.first.attributes['firstName'], 'Dan'); - expect(doc.links['self'].toString(), 'http://example.com/articles'); - expect(doc.meta, isEmpty); + expect(doc.included().first.attributes['firstName'], 'Dan'); + expect(doc.links()['self'].toString(), 'http://example.com/articles'); + expect(doc.meta(), isEmpty); }); test('can parse primary resource', () { final doc = InboundDocument(payload.resource); - final article = doc.resource(); - expect(article.ref.id, '1'); + final article = doc.dataAsResource(); + expect(article.id, '1'); expect(article.attributes['title'], 'JSON:API paints my bikeshed!'); expect(article.relationships['author'], isA()); - expect(doc.included, isEmpty); - expect(doc.links['self'].toString(), 'http://example.com/articles/1'); - expect(doc.meta, isEmpty); + expect(doc.included(), isEmpty); + expect(doc.links()['self'].toString(), 'http://example.com/articles/1'); + expect(doc.meta(), isEmpty); }); test('can parse a new resource', () { final doc = InboundDocument(payload.newResource); - final article = doc.newResource(); + final article = doc.dataAsNewResource(); expect(article.attributes['title'], 'A new article'); - expect(doc.included, isEmpty); - expect(doc.links, isEmpty); - expect(doc.meta, isEmpty); + expect(doc.included(), isEmpty); + expect(doc.links(), isEmpty); + expect(doc.meta(), isEmpty); }); test('newResource() has id if data is sufficient', () { final doc = InboundDocument(payload.resource); - final article = doc.newResource(); + final article = doc.dataAsNewResource(); expect(article.id, isNotEmpty); }); test('can parse related resource', () { final doc = InboundDocument(payload.relatedEmpty); - expect(doc.nullableResource(), isNull); - expect(doc.included, isEmpty); - expect(doc.links['self'].toString(), + expect(doc.dataAsResourceOrNull(), isNull); + expect(doc.included(), isEmpty); + expect(doc.links()['self'].toString(), 'http://example.com/articles/1/author'); - expect(doc.meta, isEmpty); + expect(doc.meta(), isEmpty); }); test('can parse to-one', () { final doc = InboundDocument(payload.one); - expect(doc.dataAsRelationship(), isA()); - expect(doc.dataAsRelationship(), isNotEmpty); - expect(doc.dataAsRelationship().first.ref.type, 'people'); - expect(doc.included, isEmpty); + expect(doc.asToOne(), isA()); + expect(doc.asToOne(), isNotEmpty); + expect(doc.asToOne().first.type, 'people'); + expect(doc.included(), isEmpty); expect( - doc.links['self'].toString(), '/articles/1/relationships/author'); - expect(doc.meta, isEmpty); + doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta(), isEmpty); }); test('can parse empty to-one', () { final doc = InboundDocument(payload.oneEmpty); - expect(doc.dataAsRelationship(), isA()); - expect(doc.dataAsRelationship(), isEmpty); - expect(doc.included, isEmpty); + expect(doc.asToOne(), isA()); + expect(doc.asToOne(), isEmpty); + expect(doc.included(), isEmpty); expect( - doc.links['self'].toString(), '/articles/1/relationships/author'); - expect(doc.meta, isEmpty); + doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta(), isEmpty); }); test('can parse to-many', () { final doc = InboundDocument(payload.many); - expect(doc.dataAsRelationship(), isA()); - expect(doc.dataAsRelationship(), isNotEmpty); - expect(doc.dataAsRelationship().first.ref.type, 'tags'); - expect(doc.included, isEmpty); - expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); - expect(doc.meta, isEmpty); + expect(doc.asToMany(), isA()); + expect(doc.asToMany(), isNotEmpty); + expect(doc.asToMany().first.type, 'tags'); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta(), isEmpty); }); test('can parse empty to-many', () { final doc = InboundDocument(payload.manyEmpty); - expect(doc.dataAsRelationship(), isA()); - expect(doc.dataAsRelationship(), isEmpty); - expect(doc.included, isEmpty); - expect(doc.links['self'].toString(), '/articles/1/relationships/tags'); - expect(doc.meta, isEmpty); + expect(doc.asToMany(), isA()); + expect(doc.asToMany(), isEmpty); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta(), isEmpty); }); test('throws on invalid doc', () { - expect(() => InboundDocument(payload.manyEmpty).nullableResource(), + expect(() => InboundDocument(payload.manyEmpty).dataAsResourceOrNull(), throwsFormatException); - expect(() => InboundDocument(payload.newResource).resource(), + expect(() => InboundDocument(payload.newResource).dataAsResource(), throwsFormatException); - expect(() => InboundDocument(payload.newResource).nullableResource(), + expect( + () => InboundDocument(payload.newResource).dataAsResourceOrNull(), throwsFormatException); - expect(() => InboundDocument({}).nullableResource(), + expect(() => InboundDocument({}).dataAsResourceOrNull(), throwsFormatException); - expect(() => InboundDocument({'data': 42}).dataAsRelationship(), + expect(() => InboundDocument({'data': 42}).asToMany(), throwsFormatException); expect( () => InboundDocument({ 'links': {'self': 42} - }).dataAsRelationship(), - throwsFormatException); - }); - - test('throws on invalid JSON', () { - expect(() => InboundDocument.decode(jsonEncode('oops')), + }).asToOne(), throwsFormatException); }); test('throws on invalid relationship kind', () { - expect(() => InboundDocument(payload.one).dataAsRelationship(), + expect(() => InboundDocument(payload.one).asToMany(), throwsFormatException); - expect(() => InboundDocument(payload.many).dataAsRelationship(), + expect(() => InboundDocument(payload.many).asToOne(), throwsFormatException); }); }); diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart index eb871293..f3d826aa 100644 --- a/test/unit/document/new_resource_test.dart +++ b/test/unit/document/new_resource_test.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; @@ -15,10 +14,10 @@ void main() { ..meta['foo'] = [42] ..attributes['color'] = 'green' ..relationships['one'] = - (ToOne(Identifier(Ref('rel', '1'))..meta['rel'] = 1) + (ToOne(Identifier('rel', '1')..meta['rel'] = 1) ..meta['one'] = 1) ..relationships['many'] = - (ToMany([Identifier(Ref('rel', '1'))..meta['rel'] = 1]) + (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) ..meta['many'] = 1)), jsonEncode({ 'type': 'test_type', diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart index 4aecec8b..5631f3a2 100644 --- a/test/unit/document/outbound_document_test.dart +++ b/test/unit/document/outbound_document_test.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; @@ -36,8 +35,8 @@ void main() { }); group('Data', () { - final book = Resource(Ref('books', '1')); - final author = Resource(Ref('people', '2')); + final book = Resource('books', '1'); + final author = Resource('people', '2'); group('Resource', () { test('minimal', () { expect(toObject(OutboundDataDocument.resource(book)), { @@ -91,7 +90,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.one(ToOne(Identifier(book.ref)) + toObject(OutboundDataDocument.one(ToOne(Identifier.of(book)) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), @@ -112,7 +111,7 @@ void main() { }); test('full', () { expect( - toObject(OutboundDataDocument.many(ToMany([Identifier(book.ref)]) + toObject(OutboundDataDocument.many(ToMany([Identifier.of(book)]) ..meta['foo'] = 42 ..links['self'] = Link(Uri.parse('/books/1'))) ..included.add(author)), diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 0e99c8d5..934af982 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -1,10 +1,9 @@ -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - final a = Identifier(Ref('apples', 'a')); - final b = Identifier(Ref('apples', 'b')); + final a = Identifier('apples', 'a'); + final b = Identifier('apples', 'b'); group('Relationship', () { test('one', () { expect(ToOne(a).identifier, a); diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 8d231d37..94aafc56 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -1,24 +1,23 @@ import 'dart:convert'; -import 'package:json_api/core.dart'; import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { group('Resource', () { test('json encoding', () { - expect(jsonEncode(Resource(Ref('test_type', 'test_id'))), + expect(jsonEncode(Resource('test_type', 'test_id')), jsonEncode({'type': 'test_type', 'id': 'test_id'})); expect( - jsonEncode(Resource(Ref('test_type', 'test_id')) + jsonEncode(Resource('test_type', 'test_id') ..meta['foo'] = [42] ..attributes['color'] = 'green' ..relationships['one'] = - (ToOne(Identifier(Ref('rel', '1'))..meta['rel'] = 1) + (ToOne(Identifier('rel', '1')..meta['rel'] = 1) ..meta['one'] = 1) ..relationships['many'] = - (ToMany([Identifier(Ref('rel', '1'))..meta['rel'] = 1]) + (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) ..meta['many'] = 1) ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1)), jsonEncode({ @@ -57,10 +56,10 @@ void main() { })); }); test('one() return null when relationship does not exist', () { - expect(Resource(Ref('books', '1')).one('author'), isNull); + expect(Resource('books', '1').one('author'), isNull); }); test('many() returns null when relationship does not exist', () { - expect(Resource(Ref('books', '1')).many('tags'), isNull); + expect(Resource('books', '1').many('tags'), isNull); }); }); } diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart index 969e5694..e3781eb5 100644 --- a/test/unit/routing/url_test.dart +++ b/test/unit/routing/url_test.dart @@ -1,38 +1,35 @@ -import 'package:json_api/core.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; void main() { - final collection = CollectionTarget('books'); - final ref = Ref('books', '42'); - final resource = ResourceTarget(ref); - final related = RelatedTarget(ref, 'author'); - final relationship = RelationshipTarget(ref, 'author'); - test('uri generation', () { - final url = RecommendedUrlDesign.pathOnly; - expect(collection.map(url).toString(), '/books'); - expect(resource.map(url).toString(), '/books/42'); - expect(related.map(url).toString(), '/books/42/author'); - expect(relationship.map(url).toString(), '/books/42/relationships/author'); + final url = StandardUriDesign.pathOnly; + expect(url.collection('books').toString(), '/books'); + expect(url.resource('books', '42').toString(), '/books/42'); + expect(url.related('books', '42', 'author').toString(), '/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), + '/books/42/relationships/author'); }); test('Authority is retained if exists in base', () { - final url = RecommendedUrlDesign(Uri.parse('https://example.com')); - expect(collection.map(url).toString(), 'https://example.com/books'); - expect(resource.map(url).toString(), 'https://example.com/books/42'); - expect(related.map(url).toString(), 'https://example.com/books/42/author'); - expect(relationship.map(url).toString(), + final url = StandardUriDesign(Uri.parse('https://example.com')); + expect(url.collection('books').toString(), 'https://example.com/books'); + expect( + url.resource('books', '42').toString(), 'https://example.com/books/42'); + expect(url.related('books', '42', 'author').toString(), + 'https://example.com/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), 'https://example.com/books/42/relationships/author'); }); test('Authority and path is retained if exists in base (directory path)', () { - final url = RecommendedUrlDesign(Uri.parse('https://example.com/foo/')); - expect(collection.map(url).toString(), 'https://example.com/foo/books'); - expect(resource.map(url).toString(), 'https://example.com/foo/books/42'); - expect( - related.map(url).toString(), 'https://example.com/foo/books/42/author'); - expect(relationship.map(url).toString(), + final url = StandardUriDesign(Uri.parse('https://example.com/foo/')); + expect(url.collection('books').toString(), 'https://example.com/foo/books'); + expect(url.resource('books', '42').toString(), + 'https://example.com/foo/books/42'); + expect(url.related('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), 'https://example.com/foo/books/42/relationships/author'); }); } diff --git a/test/unit/server/repository_controller_test.dart b/test/unit/server/repository_controller_test.dart new file mode 100644 index 00000000..d24e4e31 --- /dev/null +++ b/test/unit/server/repository_controller_test.dart @@ -0,0 +1,21 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/_internal/in_memory_repo.dart'; +import 'package:json_api/src/server/_internal/repository_controller.dart'; +import 'package:test/test.dart'; + +import '../../src/sequential_numbers.dart'; + +void main() { + final controller = RepositoryController(InMemoryRepo([]), sequentialNumbers); + test('Incomplete relationship', () async { + try { + await controller.replaceRelationship( + HttpRequest('patch', Uri(), body: '{}'), + RelationshipTarget('posts', '1', 'author')); + fail('Exception expected'); + } on FormatException catch (e) { + expect(e.message, 'Incomplete relationship'); + } + }); +} From ba9cd4ad2b89bf740e01f3cef59825a90b6925c5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 14 Dec 2020 14:58:26 -0800 Subject: [PATCH 87/99] Release 8 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 6bbe334b..91b3b517 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-nullsafety.7 +version: 5.0.0-nullsafety.8 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: From 735bce5f168b91c3706fdb594a8996d6cc7c67d3 Mon Sep 17 00:00:00 2001 From: f3ath Date: Wed, 16 Dec 2020 13:47:14 -0800 Subject: [PATCH 88/99] Separate handler --- legacy/dart_http_handler.dart | 3 ++- lib/{src/http => }/handler.dart | 19 +++++++++++-------- lib/http.dart | 1 - lib/src/client/basic_client.dart | 3 ++- lib/src/server/router.dart | 4 ++-- lib/src/test/mock_handler.dart | 3 ++- pubspec.yaml | 2 +- test/handler/logging_handler_test.dart | 4 ++-- test/src/dart_io_http_handler.dart | 4 ++-- test/src/demo_handler.dart | 10 +++++----- test/src/json_api_server.dart | 3 ++- 11 files changed, 31 insertions(+), 25 deletions(-) rename lib/{src/http => }/handler.dart (71%) diff --git a/legacy/dart_http_handler.dart b/legacy/dart_http_handler.dart index 156c345c..5a6f22e0 100644 --- a/legacy/dart_http_handler.dart +++ b/legacy/dart_http_handler.dart @@ -1,8 +1,9 @@ // @dart=2.9 import 'package:http/http.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; -abstract class DartHttpHandler implements Handler { +abstract class DartHttpHandler implements AsyncHandler { factory DartHttpHandler([Client /*?*/ client]) => client != null ? _PersistentDartHttpHandler(client) : _OneOffDartHttpHandler(); diff --git a/lib/src/http/handler.dart b/lib/handler.dart similarity index 71% rename from lib/src/http/handler.dart rename to lib/handler.dart index 09e3803c..d81ffa39 100644 --- a/lib/src/http/handler.dart +++ b/lib/handler.dart @@ -1,20 +1,23 @@ +library handler; + /// A generic async handler -abstract class Handler { - static Handler lambda(Future Function(Rq request) fun) => +abstract class AsyncHandler { + static AsyncHandler lambda( + Future Function(Rq request) fun) => _FunHandler(fun); Future call(Rq request); } -/// A wrapper over [Handler] which allows logging -class LoggingHandler implements Handler { +/// A wrapper over [AsyncHandler] which allows logging +class LoggingHandler implements AsyncHandler { LoggingHandler(this._handler, {void Function(Rq request)? onRequest, void Function(Rs response)? onResponse}) : _onRequest = onRequest ?? _nothing, _onResponse = onResponse ?? _nothing; - final Handler _handler; + final AsyncHandler _handler; final void Function(Rq request) _onRequest; final void Function(Rs response) _onResponse; @@ -30,10 +33,10 @@ class LoggingHandler implements Handler { /// Calls the wrapped handler within a try-catch block. /// When a response object is thrown, returns it. /// When any other error is thrown, converts it using the callback. -class TryCatchHandler implements Handler { +class TryCatchHandler implements AsyncHandler { TryCatchHandler(this._handler, this._onError); - final Handler _handler; + final AsyncHandler _handler; final Future Function(dynamic error) _onError; @override @@ -48,7 +51,7 @@ class TryCatchHandler implements Handler { } } -class _FunHandler implements Handler { +class _FunHandler implements AsyncHandler { _FunHandler(this.handle); final Future Function(Rq request) handle; diff --git a/lib/http.dart b/lib/http.dart index ce12b453..7c17d648 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,7 +1,6 @@ /// This is a thin HTTP layer abstraction used by the client library http; -export 'package:json_api/src/http/handler.dart'; export 'package:json_api/src/http/http_message.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; diff --git a/lib/src/client/basic_client.dart b/lib/src/client/basic_client.dart index 5344a499..0bde4358 100644 --- a/lib/src/client/basic_client.dart +++ b/lib/src/client/basic_client.dart @@ -1,4 +1,5 @@ import 'package:json_api/codec.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; import 'package:json_api/src/client/request.dart'; import 'package:json_api/src/client/response/request_failure.dart'; @@ -9,7 +10,7 @@ class BasicClient { BasicClient(this._http, {PayloadCodec? codec}) : _codec = codec ?? const DefaultCodec(); - final Handler _http; + final AsyncHandler _http; final PayloadCodec _codec; /// Sends the [request] to the server. diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index bd465250..5cfc8750 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,11 +1,11 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/http/handler.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/method_not_allowed.dart'; import 'package:json_api/src/server/unmatched_target.dart'; -class Router implements Handler { +class Router implements AsyncHandler { Router(this.controller, this.matchTarget); final Controller controller; diff --git a/lib/src/test/mock_handler.dart b/lib/src/test/mock_handler.dart index 2dcc7787..7af1cba3 100644 --- a/lib/src/test/mock_handler.dart +++ b/lib/src/test/mock_handler.dart @@ -1,6 +1,7 @@ +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; -class MockHandler implements Handler { +class MockHandler implements AsyncHandler { late HttpRequest request; late HttpResponse response; diff --git a/pubspec.yaml b/pubspec.yaml index 91b3b517..3301257d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-nullsafety.8 +version: 5.0.0-nullsafety.9 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: diff --git a/test/handler/logging_handler_test.dart b/test/handler/logging_handler_test.dart index c4a60c12..72c4a657 100644 --- a/test/handler/logging_handler_test.dart +++ b/test/handler/logging_handler_test.dart @@ -1,4 +1,4 @@ -import 'package:json_api/src/http/handler.dart'; +import 'package:json_api/handler.dart'; import 'package:test/test.dart'; void main() { @@ -7,7 +7,7 @@ void main() { String? loggedResponse; final handler = - LoggingHandler(Handler.lambda((String s) async => s.toUpperCase()), + LoggingHandler(AsyncHandler.lambda((String s) async => s.toUpperCase()), onRequest: (String rq) { loggedRequest = rq; }, onResponse: (String rs) { diff --git a/test/src/dart_io_http_handler.dart b/test/src/dart_io_http_handler.dart index d3dba744..908576ba 100644 --- a/test/src/dart_io_http_handler.dart +++ b/test/src/dart_io_http_handler.dart @@ -1,11 +1,11 @@ import 'dart:convert'; import 'dart:io' as io; -import 'package:json_api/src/http/handler.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; Future Function(io.HttpRequest ioRequest) dartIOHttpHandler( - Handler handler, + AsyncHandler handler, ) => (request) async { final headers = {}; diff --git a/test/src/demo_handler.dart b/test/src/demo_handler.dart index 96e5cf1a..eb6de811 100644 --- a/test/src/demo_handler.dart +++ b/test/src/demo_handler.dart @@ -2,7 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/http/handler.dart'; +import 'package:json_api/handler.dart'; import 'package:json_api/src/server/_internal/in_memory_repo.dart'; import 'package:json_api/src/server/_internal/repo.dart'; import 'package:json_api/src/server/_internal/repository_controller.dart'; @@ -11,7 +11,7 @@ import 'package:json_api/src/server/unmatched_target.dart'; import 'sequential_numbers.dart'; -class DemoHandler implements Handler { +class DemoHandler implements AsyncHandler { DemoHandler( {void Function(HttpRequest request)? logRequest, void Function(HttpResponse response)? logResponse}) { @@ -26,7 +26,7 @@ class DemoHandler implements Handler { onRequest: logRequest); } - late Handler _handler; + late AsyncHandler _handler; @override Future call(HttpRequest request) => _handler.call(request); @@ -59,10 +59,10 @@ class DemoHandler implements Handler { } /// Adds CORS headers and handles pre-flight requests. -class _Cors implements Handler { +class _Cors implements AsyncHandler { _Cors(this._handler); - final Handler _handler; + final AsyncHandler _handler; @override Future call(HttpRequest request) async { diff --git a/test/src/json_api_server.dart b/test/src/json_api_server.dart index bf310ccc..7399be92 100644 --- a/test/src/json_api_server.dart +++ b/test/src/json_api_server.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; class JsonApiServer { @@ -16,7 +17,7 @@ class JsonApiServer { /// Server port final int port; - final Handler _handler; + final AsyncHandler _handler; HttpServer? _server; /// Server uri From 12ceed4fe87e39e26da3fedf9b3798eb8c272d19 Mon Sep 17 00:00:00 2001 From: f3ath Date: Mon, 28 Dec 2020 18:02:11 -0800 Subject: [PATCH 89/99] Added Client exports --- {test/src => demo}/dart_io_http_handler.dart | 0 {test/src => demo}/demo_handler.dart | 8 +++---- .../_internal => demo}/in_memory_repo.dart | 1 + {test/src => demo}/json_api_server.dart | 0 .../_internal => demo}/relationship_node.dart | 0 {lib/src/server/_internal => demo}/repo.dart | 0 .../repository_controller.dart | 5 +++-- {test/src => demo}/sequential_numbers.dart | 0 example/server.dart | 4 ++-- legacy/dart_http_handler.dart | 3 +-- lib/client.dart | 4 ++++ lib/handler.dart | 2 +- lib/server.dart | 2 ++ pubspec.yaml | 2 +- test/contract/crud_test.dart | 2 +- test/contract/errors_test.dart | 2 +- test/contract/resource_creation_test.dart | 2 +- test/e2e/hybrid_server.dart | 4 ++-- test/e2e/vm_test.dart | 4 ++-- .../server/repository_controller_test.dart | 21 ------------------- 20 files changed, 25 insertions(+), 41 deletions(-) rename {test/src => demo}/dart_io_http_handler.dart (100%) rename {test/src => demo}/demo_handler.dart (89%) rename {lib/src/server/_internal => demo}/in_memory_repo.dart (99%) rename {test/src => demo}/json_api_server.dart (100%) rename {lib/src/server/_internal => demo}/relationship_node.dart (100%) rename {lib/src/server/_internal => demo}/repo.dart (100%) rename {lib/src/server/_internal => demo}/repository_controller.dart (98%) rename {test/src => demo}/sequential_numbers.dart (100%) delete mode 100644 test/unit/server/repository_controller_test.dart diff --git a/test/src/dart_io_http_handler.dart b/demo/dart_io_http_handler.dart similarity index 100% rename from test/src/dart_io_http_handler.dart rename to demo/dart_io_http_handler.dart diff --git a/test/src/demo_handler.dart b/demo/demo_handler.dart similarity index 89% rename from test/src/demo_handler.dart rename to demo/demo_handler.dart index eb6de811..b8e88aed 100644 --- a/test/src/demo_handler.dart +++ b/demo/demo_handler.dart @@ -3,12 +3,10 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; import 'package:json_api/handler.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:json_api/src/server/method_not_allowed.dart'; -import 'package:json_api/src/server/unmatched_target.dart'; +import 'in_memory_repo.dart'; +import 'repo.dart'; +import 'repository_controller.dart'; import 'sequential_numbers.dart'; class DemoHandler implements AsyncHandler { diff --git a/lib/src/server/_internal/in_memory_repo.dart b/demo/in_memory_repo.dart similarity index 99% rename from lib/src/server/_internal/in_memory_repo.dart rename to demo/in_memory_repo.dart index 49e02b88..a217fced 100644 --- a/lib/src/server/_internal/in_memory_repo.dart +++ b/demo/in_memory_repo.dart @@ -3,6 +3,7 @@ import 'package:json_api/src/nullable.dart'; import 'repo.dart'; + class InMemoryRepo implements Repo { InMemoryRepo(Iterable types) { types.forEach((_) { diff --git a/test/src/json_api_server.dart b/demo/json_api_server.dart similarity index 100% rename from test/src/json_api_server.dart rename to demo/json_api_server.dart diff --git a/lib/src/server/_internal/relationship_node.dart b/demo/relationship_node.dart similarity index 100% rename from lib/src/server/_internal/relationship_node.dart rename to demo/relationship_node.dart diff --git a/lib/src/server/_internal/repo.dart b/demo/repo.dart similarity index 100% rename from lib/src/server/_internal/repo.dart rename to demo/repo.dart diff --git a/lib/src/server/_internal/repository_controller.dart b/demo/repository_controller.dart similarity index 98% rename from lib/src/server/_internal/repository_controller.dart rename to demo/repository_controller.dart index 777b1028..0e688102 100644 --- a/lib/src/server/_internal/repository_controller.dart +++ b/demo/repository_controller.dart @@ -5,11 +5,12 @@ import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/src/document/inbound_document.dart'; import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/_internal/relationship_node.dart'; -import 'package:json_api/src/server/_internal/repo.dart'; import 'package:json_api/src/server/controller.dart'; import 'package:json_api/src/server/json_api_response.dart'; +import 'relationship_node.dart'; +import 'repo.dart'; + class RepositoryController implements Controller { RepositoryController(this.repo, this.getId); diff --git a/test/src/sequential_numbers.dart b/demo/sequential_numbers.dart similarity index 100% rename from test/src/sequential_numbers.dart rename to demo/sequential_numbers.dart diff --git a/example/server.dart b/example/server.dart index f9429b30..f519c73e 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,8 +1,8 @@ // @dart=2.10 import 'dart:io' as io; -import '../test/src/demo_handler.dart'; -import '../test/src/json_api_server.dart'; +import '../demo/demo_handler.dart'; +import '../demo/json_api_server.dart'; Future main() async { final server = JsonApiServer(DemoHandler( diff --git a/legacy/dart_http_handler.dart b/legacy/dart_http_handler.dart index 5a6f22e0..8dfa5a80 100644 --- a/legacy/dart_http_handler.dart +++ b/legacy/dart_http_handler.dart @@ -1,10 +1,9 @@ -// @dart=2.9 import 'package:http/http.dart'; import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; abstract class DartHttpHandler implements AsyncHandler { - factory DartHttpHandler([Client /*?*/ client]) => client != null + factory DartHttpHandler([Client? client]) => client != null ? _PersistentDartHttpHandler(client) : _OneOffDartHttpHandler(); } diff --git a/lib/client.dart b/lib/client.dart index 12b0a38a..4939aff5 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -2,8 +2,12 @@ library json_api; export 'package:json_api/src/client/basic_client.dart'; export 'package:json_api/src/client/response/collection_fetched.dart'; +export 'package:json_api/src/client/response/related_resource_fetched.dart'; +export 'package:json_api/src/client/response/relationship_fetched.dart'; export 'package:json_api/src/client/response/relationship_updated.dart'; export 'package:json_api/src/client/response/request_failure.dart'; +export 'package:json_api/src/client/response/resource_created.dart'; +export 'package:json_api/src/client/response/resource_fetched.dart'; export 'package:json_api/src/client/response/resource_updated.dart'; export 'package:json_api/src/client/routing_client.dart'; export 'package:json_api/src/client/request.dart'; diff --git a/lib/handler.dart b/lib/handler.dart index d81ffa39..24c785ca 100644 --- a/lib/handler.dart +++ b/lib/handler.dart @@ -60,4 +60,4 @@ class _FunHandler implements AsyncHandler { Future call(Rq request) => handle(request); } -void _nothing(dynamic any) {} +void _nothing(any) {} diff --git a/lib/server.dart b/lib/server.dart index ad8ca8f8..011f67ba 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,3 +1,5 @@ export 'package:json_api/src/server/controller.dart'; export 'package:json_api/src/server/json_api_response.dart'; +export 'package:json_api/src/server/method_not_allowed.dart'; export 'package:json_api/src/server/router.dart'; +export 'package:json_api/src/server/unmatched_target.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 3301257d..f77fac9d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: json_api -version: 5.0.0-nullsafety.9 +version: 5.0.0-nullsafety.11 homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) environment: diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index ea8339d5..a5352d65 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -3,7 +3,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../src/demo_handler.dart'; +import '../../demo/demo_handler.dart'; void main() { late RoutingClient client; diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 06b8400d..8d11afa8 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -2,7 +2,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; import 'package:test/test.dart'; -import '../src/demo_handler.dart'; +import '../../demo/demo_handler.dart'; void main() { late BasicClient client; diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 9351e51c..944ade39 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -2,7 +2,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../src/demo_handler.dart'; +import '../../demo/demo_handler.dart'; void main() { late RoutingClient client; diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index d31742ad..19e4e587 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,8 +1,8 @@ // @dart=2.10 import 'package:stream_channel/stream_channel.dart'; -import '../src/demo_handler.dart'; -import '../src/json_api_server.dart'; +import '../../demo/demo_handler.dart'; +import '../../demo/json_api_server.dart'; void hybridMain(StreamChannel channel, Object message) async { final server = JsonApiServer(DemoHandler(), port: 8000); diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 4803b3ea..1b2a1756 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -4,8 +4,8 @@ import 'package:json_api/routing.dart'; import 'package:test/test.dart'; import '../../legacy/dart_http_handler.dart'; -import '../src/demo_handler.dart'; -import '../src/json_api_server.dart'; +import '../../demo/demo_handler.dart'; +import '../../demo/json_api_server.dart'; import 'e2e_test_set.dart'; void main() { diff --git a/test/unit/server/repository_controller_test.dart b/test/unit/server/repository_controller_test.dart deleted file mode 100644 index d24e4e31..00000000 --- a/test/unit/server/repository_controller_test.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/_internal/in_memory_repo.dart'; -import 'package:json_api/src/server/_internal/repository_controller.dart'; -import 'package:test/test.dart'; - -import '../../src/sequential_numbers.dart'; - -void main() { - final controller = RepositoryController(InMemoryRepo([]), sequentialNumbers); - test('Incomplete relationship', () async { - try { - await controller.replaceRelationship( - HttpRequest('patch', Uri(), body: '{}'), - RelationshipTarget('posts', '1', 'author')); - fail('Exception expected'); - } on FormatException catch (e) { - expect(e.message, 'Incomplete relationship'); - } - }); -} From 0d81b61b899319852dcb36d19d39082fa20024fc Mon Sep 17 00:00:00 2001 From: f3ath Date: Fri, 16 Apr 2021 17:53:05 -0700 Subject: [PATCH 90/99] WIP --- CHANGELOG.md | 9 ++ README.md | 31 +------ demo/dart_io_http_handler.dart | 21 ----- demo/demo_handler.dart | 88 ------------------ demo/sequential_numbers.dart | 2 - example/README.md | 1 - example/client.dart | 7 +- example/server.dart | 90 ++++++++++++++----- legacy/dart_http_handler.dart | 37 -------- lib/client.dart | 2 +- lib/handler.dart | 63 ------------- lib/http.dart | 7 +- lib/server.dart | 7 +- lib/src/_testing/demo_handler.dart | 49 ++++++++++ .../src/_testing}/in_memory_repo.dart | 6 +- .../src/_testing}/json_api_server.dart | 11 ++- lib/src/{test => _testing}/mock_handler.dart | 3 +- lib/src/{test => _testing}/payload.dart | 0 .../src/_testing}/relationship_node.dart | 0 .../src/_testing/repository.dart | 2 +- .../src/_testing}/repository_controller.dart | 66 +++++++------- lib/src/{test => _testing}/response.dart | 0 lib/src/_testing/try_catch_handler.dart | 22 +++++ .../client/{basic_client.dart => client.dart} | 16 ++-- lib/src/client/disposable_handler.dart | 19 ++++ lib/src/client/persistent_handler.dart | 22 +++++ lib/src/client/request.dart | 2 +- lib/src/client/response.dart | 4 +- lib/src/client/routing_client.dart | 8 +- lib/src/http/cors_handler.dart | 33 +++++++ lib/src/http/http_handler.dart | 6 ++ lib/src/http/http_message.dart | 1 + lib/src/http/logging_handler.dart | 20 +++++ .../http/payload_codec.dart} | 15 +--- lib/src/server/controller.dart | 29 +++--- .../{ => errors}/method_not_allowed.dart | 0 .../server/{ => errors}/unmatched_target.dart | 0 lib/src/server/json_api_response.dart | 36 -------- lib/src/server/response.dart | 37 ++++++++ lib/src/server/router.dart | 40 ++++----- pubspec.yaml | 17 ++-- test/contract/crud_test.dart | 7 +- test/contract/errors_test.dart | 7 +- test/contract/resource_creation_test.dart | 7 +- test/e2e/browser_test.dart | 13 ++- test/e2e/e2e_test_set.dart | 4 +- test/e2e/hybrid_server.dart | 12 +-- test/e2e/vm_test.dart | 18 ++-- test/handler/logging_handler_test.dart | 20 ----- test/unit/client/client_test.dart | 7 +- test/unit/document/inbound_document_test.dart | 2 +- 51 files changed, 439 insertions(+), 487 deletions(-) delete mode 100644 demo/dart_io_http_handler.dart delete mode 100644 demo/demo_handler.dart delete mode 100644 demo/sequential_numbers.dart delete mode 100644 example/README.md delete mode 100644 legacy/dart_http_handler.dart delete mode 100644 lib/handler.dart create mode 100644 lib/src/_testing/demo_handler.dart rename {demo => lib/src/_testing}/in_memory_repo.dart (95%) rename {demo => lib/src/_testing}/json_api_server.dart (88%) rename lib/src/{test => _testing}/mock_handler.dart (66%) rename lib/src/{test => _testing}/payload.dart (100%) rename {demo => lib/src/_testing}/relationship_node.dart (100%) rename demo/repo.dart => lib/src/_testing/repository.dart (99%) rename {demo => lib/src/_testing}/repository_controller.dart (78%) rename lib/src/{test => _testing}/response.dart (100%) create mode 100644 lib/src/_testing/try_catch_handler.dart rename lib/src/client/{basic_client.dart => client.dart} (84%) create mode 100644 lib/src/client/disposable_handler.dart create mode 100644 lib/src/client/persistent_handler.dart create mode 100644 lib/src/http/cors_handler.dart create mode 100644 lib/src/http/http_handler.dart create mode 100644 lib/src/http/logging_handler.dart rename lib/{codec.dart => src/http/payload_codec.dart} (56%) rename lib/src/server/{ => errors}/method_not_allowed.dart (100%) rename lib/src/server/{ => errors}/unmatched_target.dart (100%) delete mode 100644 lib/src/server/json_api_response.dart create mode 100644 lib/src/server/response.dart delete mode 100644 test/handler/logging_handler_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec33cb7..a68c4ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.0.0] - 2021-04-15 +### Added +- Sound null-safety support. + +### Changed +- Everything. Again. This is another major **BC-breaking** rework. Please refer to + the documentation, examples and tests. + ## [4.0.0] - 2020-02-29 ### Changed - Everything. This is a major **BC-breaking** rework which affected pretty much all areas. Please refer to the documentation. @@ -155,6 +163,7 @@ Most of the changes are **BC-BREAKING**. ### Added - Client: fetch resources, collections, related resources and relationships +[5.0.0]: https://github.com/f3ath/json-api-dart/compare/4.0.0..5.0.0 [4.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.2..4.0.0 [3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2..3.2.3 [3.2.2]: https://github.com/f3ath/json-api-dart/compare/3.2.1..3.2.2 diff --git a/README.md b/README.md index 4f03a121..7ab02beb 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,6 @@ -# JSON:API for Dart/Flutter +# JSON:API Client and Server for Dart/Flutter. Version 5. -[JSON:API] is a specification for building JSON APIs. - -This package consists of several libraries: -- The [Client library] is a JSON:API Client for Flutter, browsers and vm. -- The [Server library] is a framework-agnostic JSON:API server implementation. -- The [Document library] is the core of this package. It describes the JSON:API document structure. -- The [HTTP library] is a thin abstraction of HTTP requests and responses. -- The [Query library] builds and parses the query parameters (page, sorting, filtering, etc). -- The [Routing library] builds and matches URIs for resources, collections, and relationships. - - - -# Client -## Making requests -### Fetching -#### Query parameters -### Manipulating resources -### Manipulating relationships -### Error management -### Asynchronous Processing -### Custom request headers +[JSON:API] is a specification for building JSON APIs. The documentation is a work-in-progress. [JSON:API]: https://jsonapi.org - -[Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html -[Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html -[Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html -[Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html -[Routing library]: https://pub.dev/documentation/json_api/latest/routing/routing-library.html -[HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html diff --git a/demo/dart_io_http_handler.dart b/demo/dart_io_http_handler.dart deleted file mode 100644 index 908576ba..00000000 --- a/demo/dart_io_http_handler.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; - -Future Function(io.HttpRequest ioRequest) dartIOHttpHandler( - AsyncHandler handler, -) => - (request) async { - final headers = {}; - request.headers.forEach((k, v) => headers[k] = v.join(',')); - final response = await handler(HttpRequest( - request.method, request.requestedUri, - body: await request.cast>().transform(utf8.decoder).join()) - ..headers.addAll(headers)); - response.headers.forEach(request.response.headers.add); - request.response.statusCode = response.statusCode; - request.response.write(response.body); - await request.response.close(); - }; diff --git a/demo/demo_handler.dart b/demo/demo_handler.dart deleted file mode 100644 index b8e88aed..00000000 --- a/demo/demo_handler.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/handler.dart'; - -import 'in_memory_repo.dart'; -import 'repo.dart'; -import 'repository_controller.dart'; -import 'sequential_numbers.dart'; - -class DemoHandler implements AsyncHandler { - DemoHandler( - {void Function(HttpRequest request)? logRequest, - void Function(HttpResponse response)? logResponse}) { - final repo = InMemoryRepo(['users', 'posts', 'comments']); - - _handler = LoggingHandler( - _Cors(TryCatchHandler( - Router(RepositoryController(repo, sequentialNumbers), - StandardUriDesign.matchTarget), - _onError)), - onResponse: logResponse, - onRequest: logRequest); - } - - late AsyncHandler _handler; - - @override - Future call(HttpRequest request) => _handler.call(request); - - static Future _onError(dynamic error) async { - if (error is MethodNotAllowed) { - return JsonApiResponse.methodNotAllowed(); - } - if (error is UnmatchedTarget) { - return JsonApiResponse.badRequest(); - } - if (error is CollectionNotFound) { - return JsonApiResponse.notFound( - OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); - } - if (error is ResourceNotFound) { - return JsonApiResponse.notFound( - OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); - } - if (error is RelationshipNotFound) { - return JsonApiResponse.notFound( - OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); - } - return JsonApiResponse(500, - document: OutboundErrorDocument([ - ErrorObject( - title: 'Error: ${error.runtimeType}', detail: error.toString()) - ])); - } -} - -/// Adds CORS headers and handles pre-flight requests. -class _Cors implements AsyncHandler { - _Cors(this._handler); - - final AsyncHandler _handler; - - @override - Future call(HttpRequest request) async { - final headers = { - 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', - 'Access-Control-Expose-Headers': 'Location', - }; - - if (request.isOptions) { - const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; - return HttpResponse(204) - ..headers.addAll({ - ...headers, - 'Access-Control-Allow-Methods': - // TODO: Chrome works only with uppercase, but Firefox - only without. WTF? - request.headers['Access-Control-Request-Method']?.toUpperCase() ?? - methods.join(', '), - 'Access-Control-Allow-Headers': - request.headers['Access-Control-Request-Headers'] ?? '*', - }); - } - return await _handler(request) - ..headers.addAll(headers); - } -} diff --git a/demo/sequential_numbers.dart b/demo/sequential_numbers.dart deleted file mode 100644 index 2fdef157..00000000 --- a/demo/sequential_numbers.dart +++ /dev/null @@ -1,2 +0,0 @@ -String sequentialNumbers() => (_num++).toString(); -int _num = 0; diff --git a/example/README.md b/example/README.md deleted file mode 100644 index c845d1a5..00000000 --- a/example/README.md +++ /dev/null @@ -1 +0,0 @@ -Work in progress. See the tests meanwhile. \ No newline at end of file diff --git a/example/client.dart b/example/client.dart index 18dc57dc..609db611 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,14 +1,9 @@ -// @dart=2.9 import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/basic_client.dart'; - -// The handler is not migrated to null safety yet. -import '../legacy/dart_http_handler.dart'; // START THE SERVER FIRST! void main() async { final uri = Uri(host: 'localhost', port: 8080); final client = - RoutingClient(StandardUriDesign(uri), BasicClient(DartHttpHandler())); + RoutingClient(StandardUriDesign(uri)); } diff --git a/example/server.dart b/example/server.dart index f519c73e..847d8cab 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,31 +1,73 @@ -// @dart=2.10 -import 'dart:io' as io; +import 'dart:io'; -import '../demo/demo_handler.dart'; -import '../demo/json_api_server.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/_testing/in_memory_repo.dart'; +import 'package:json_api/src/_testing/json_api_server.dart'; +import 'package:json_api/src/_testing/repository.dart'; +import 'package:json_api/src/_testing/repository_controller.dart'; +import 'package:json_api/src/_testing/try_catch_handler.dart'; +import 'package:uuid/uuid.dart'; Future main() async { - final server = JsonApiServer(DemoHandler( - logRequest: (rq) => print([ - '>> Request >>', - '${rq.method.toUpperCase()} ${rq.uri}', - 'Headers: ${rq.headers}', - 'Body: ${rq.body}', - ].join('\n') + - '\n'), - logResponse: (rs) => print([ - '<< Response <<', - 'Status: ${rs.statusCode}', - 'Headers: ${rs.headers}', - 'Body: ${rs.body}', - ].join('\n') + - '\n'))); - - io.ProcessSignal.sigint.watch().listen((event) async { + final host = 'localhost'; + final port = 8080; + final resources = ['colors']; + final handler = DemoHandler( + types: resources, + onRequest: (r) => print('${r.method.toUpperCase()} ${r.uri}'), + onResponse: (r) => print('${r.statusCode}')); + final server = JsonApiServer(handler, host: host, port: port); + ProcessSignal.sigint.watch().listen((event) async { await server.stop(); - io.exit(0); + exit(0); }); - await server.start(); - print('Server is listening at ${server.uri}'); + print('The server is listening at $host:$port.' + ' Try opening the following URL(s) in your browser:'); + resources.forEach((resource) { + print('http://$host:$port/$resource'); + }); +} + +class DemoHandler extends LoggingHandler { + DemoHandler({ + Iterable types = const ['users', 'posts', 'comments'], + Function(HttpRequest request)? onRequest, + Function(HttpResponse response)? onResponse, + }) : super( + TryCatchHandler( + Router(RepositoryController(InMemoryRepo(types), Uuid().v4), + StandardUriDesign.matchTarget), + onError: convertError), + onRequest: onRequest, + onResponse: onResponse); +} + +Future convertError(dynamic error) async { + if (error is MethodNotAllowed) { + return Response.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return Response.badRequest(); + } + if (error is CollectionNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); + } + if (error is ResourceNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); + } + if (error is RelationshipNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); + } + return Response(500, + document: OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); } diff --git a/legacy/dart_http_handler.dart b/legacy/dart_http_handler.dart deleted file mode 100644 index 8dfa5a80..00000000 --- a/legacy/dart_http_handler.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:http/http.dart'; -import 'package:json_api/handler.dart'; -import 'package:json_api/http.dart'; - -abstract class DartHttpHandler implements AsyncHandler { - factory DartHttpHandler([Client? client]) => client != null - ? _PersistentDartHttpHandler(client) - : _OneOffDartHttpHandler(); -} - -class _PersistentDartHttpHandler implements DartHttpHandler { - _PersistentDartHttpHandler(this.client); - - final Client client; - - @override - Future call(HttpRequest request) async { - final response = await Response.fromStream( - await client.send(Request(request.method, request.uri) - ..headers.addAll(request.headers) - ..body = request.body)); - return HttpResponse(response.statusCode, body: response.body) - ..headers.addAll(response.headers); - } -} - -class _OneOffDartHttpHandler implements DartHttpHandler { - @override - Future call(HttpRequest request) async { - final client = Client(); - try { - return await _PersistentDartHttpHandler(client).call(request); - } finally { - client.close(); - } - } -} diff --git a/lib/client.dart b/lib/client.dart index 4939aff5..14b910ce 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,6 +1,6 @@ library json_api; -export 'package:json_api/src/client/basic_client.dart'; +export 'package:json_api/src/client/client.dart'; export 'package:json_api/src/client/response/collection_fetched.dart'; export 'package:json_api/src/client/response/related_resource_fetched.dart'; export 'package:json_api/src/client/response/relationship_fetched.dart'; diff --git a/lib/handler.dart b/lib/handler.dart deleted file mode 100644 index 24c785ca..00000000 --- a/lib/handler.dart +++ /dev/null @@ -1,63 +0,0 @@ -library handler; - -/// A generic async handler -abstract class AsyncHandler { - static AsyncHandler lambda( - Future Function(Rq request) fun) => - _FunHandler(fun); - - Future call(Rq request); -} - -/// A wrapper over [AsyncHandler] which allows logging -class LoggingHandler implements AsyncHandler { - LoggingHandler(this._handler, - {void Function(Rq request)? onRequest, - void Function(Rs response)? onResponse}) - : _onRequest = onRequest ?? _nothing, - _onResponse = onResponse ?? _nothing; - - final AsyncHandler _handler; - final void Function(Rq request) _onRequest; - final void Function(Rs response) _onResponse; - - @override - Future call(Rq request) async { - _onRequest(request); - final response = await _handler(request); - _onResponse(response); - return response; - } -} - -/// Calls the wrapped handler within a try-catch block. -/// When a response object is thrown, returns it. -/// When any other error is thrown, converts it using the callback. -class TryCatchHandler implements AsyncHandler { - TryCatchHandler(this._handler, this._onError); - - final AsyncHandler _handler; - final Future Function(dynamic error) _onError; - - @override - Future call(Rq request) async { - try { - return await _handler(request); - } on Rs catch (response) { - return response; - } catch (error) { - return await _onError(error); - } - } -} - -class _FunHandler implements AsyncHandler { - _FunHandler(this.handle); - - final Future Function(Rq request) handle; - - @override - Future call(Rq request) => handle(request); -} - -void _nothing(any) {} diff --git a/lib/http.dart b/lib/http.dart index 7c17d648..95dd065e 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,8 +1,13 @@ /// This is a thin HTTP layer abstraction used by the client library http; +export 'package:json_api/src/http/cors_handler.dart'; +export 'package:json_api/src/http/http_handler.dart'; +export 'package:json_api/src/http/http_headers.dart'; export 'package:json_api/src/http/http_message.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; -export 'package:json_api/src/http/media_type.dart'; export 'package:json_api/src/http/status_code.dart'; +export 'package:json_api/src/http/logging_handler.dart'; +export 'package:json_api/src/http/media_type.dart'; +export 'package:json_api/src/http/payload_codec.dart'; diff --git a/lib/server.dart b/lib/server.dart index 011f67ba..97f371dd 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,5 +1,6 @@ export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/json_api_response.dart'; -export 'package:json_api/src/server/method_not_allowed.dart'; +export 'package:json_api/src/http/cors_handler.dart'; +export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/errors/method_not_allowed.dart'; export 'package:json_api/src/server/router.dart'; -export 'package:json_api/src/server/unmatched_target.dart'; +export 'package:json_api/src/server/errors/unmatched_target.dart'; diff --git a/lib/src/_testing/demo_handler.dart b/lib/src/_testing/demo_handler.dart new file mode 100644 index 00000000..c68b0bb6 --- /dev/null +++ b/lib/src/_testing/demo_handler.dart @@ -0,0 +1,49 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/_testing/in_memory_repo.dart'; +import 'package:json_api/src/_testing/repository.dart'; +import 'package:json_api/src/_testing/repository_controller.dart'; +import 'package:json_api/src/_testing/try_catch_handler.dart'; +import 'package:uuid/uuid.dart'; + +class DemoHandler extends LoggingHandler { + DemoHandler({ + Iterable types = const ['users', 'posts', 'comments'], + Function(HttpRequest request)? onRequest, + Function(HttpResponse response)? onResponse, + }) : super( + TryCatchHandler( + Router(RepositoryController(InMemoryRepo(types), Uuid().v4), + StandardUriDesign.matchTarget), + onError: _onError), + onRequest: onRequest, + onResponse: onResponse); + + static Future _onError(dynamic error) async { + if (error is MethodNotAllowed) { + return Response.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return Response.badRequest(); + } + if (error is CollectionNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); + } + if (error is ResourceNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); + } + if (error is RelationshipNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); + } + return Response(500, + document: OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); + } +} diff --git a/demo/in_memory_repo.dart b/lib/src/_testing/in_memory_repo.dart similarity index 95% rename from demo/in_memory_repo.dart rename to lib/src/_testing/in_memory_repo.dart index a217fced..6983bf05 100644 --- a/demo/in_memory_repo.dart +++ b/lib/src/_testing/in_memory_repo.dart @@ -1,10 +1,8 @@ import 'package:json_api/document.dart'; +import 'package:json_api/src/_testing/repository.dart'; import 'package:json_api/src/nullable.dart'; -import 'repo.dart'; - - -class InMemoryRepo implements Repo { +class InMemoryRepo implements Repository { InMemoryRepo(Iterable types) { types.forEach((_) { _storage[_] = {}; diff --git a/demo/json_api_server.dart b/lib/src/_testing/json_api_server.dart similarity index 88% rename from demo/json_api_server.dart rename to lib/src/_testing/json_api_server.dart index 7399be92..dace22cd 100644 --- a/demo/json_api_server.dart +++ b/lib/src/_testing/json_api_server.dart @@ -1,15 +1,14 @@ import 'dart:convert'; import 'dart:io'; -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; class JsonApiServer { JsonApiServer( - this._handler, { - this.host = 'localhost', - this.port = 8080, - }); + this._handler, { + this.host = 'localhost', + this.port = 8080, + }); /// Server host name final String host; @@ -17,7 +16,7 @@ class JsonApiServer { /// Server port final int port; - final AsyncHandler _handler; + final HttpHandler _handler; HttpServer? _server; /// Server uri diff --git a/lib/src/test/mock_handler.dart b/lib/src/_testing/mock_handler.dart similarity index 66% rename from lib/src/test/mock_handler.dart rename to lib/src/_testing/mock_handler.dart index 7af1cba3..71ef1df9 100644 --- a/lib/src/test/mock_handler.dart +++ b/lib/src/_testing/mock_handler.dart @@ -1,7 +1,6 @@ -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; -class MockHandler implements AsyncHandler { +class MockHandler implements HttpHandler { late HttpRequest request; late HttpResponse response; diff --git a/lib/src/test/payload.dart b/lib/src/_testing/payload.dart similarity index 100% rename from lib/src/test/payload.dart rename to lib/src/_testing/payload.dart diff --git a/demo/relationship_node.dart b/lib/src/_testing/relationship_node.dart similarity index 100% rename from demo/relationship_node.dart rename to lib/src/_testing/relationship_node.dart diff --git a/demo/repo.dart b/lib/src/_testing/repository.dart similarity index 99% rename from demo/repo.dart rename to lib/src/_testing/repository.dart index 3a1e5801..2cd63ed6 100644 --- a/demo/repo.dart +++ b/lib/src/_testing/repository.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; -abstract class Repo { +abstract class Repository { /// Fetches a collection. /// Throws [CollectionNotFound]. Stream fetchCollection(String type); diff --git a/demo/repository_controller.dart b/lib/src/_testing/repository_controller.dart similarity index 78% rename from demo/repository_controller.dart rename to lib/src/_testing/repository_controller.dart index 0e688102..07cdd67a 100644 --- a/demo/repository_controller.dart +++ b/lib/src/_testing/repository_controller.dart @@ -1,27 +1,24 @@ -import 'package:json_api/codec.dart'; import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/document/inbound_document.dart'; +import 'package:json_api/server.dart'; import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; import 'relationship_node.dart'; -import 'repo.dart'; +import 'repository.dart'; -class RepositoryController implements Controller { +class RepositoryController implements Controller { RepositoryController(this.repo, this.getId); - final Repo repo; + final Repository repo; final IdGenerator getId; final design = StandardUriDesign.pathOnly; @override - Future fetchCollection( + Future fetchCollection( HttpRequest request, Target target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) @@ -32,11 +29,11 @@ class RepositoryController implements Controller { doc.included.add(r); } } - return JsonApiResponse.ok(doc); + return Response.ok(doc); } @override - Future fetchResource( + Future fetchResource( HttpRequest request, ResourceTarget target) async { final resource = await _fetchLinkedResource(target.type, target.id); final doc = OutboundDataDocument.resource(resource) @@ -45,61 +42,60 @@ class RepositoryController implements Controller { await for (final r in _getAllRelated(resource, forest)) { doc.included.add(r); } - return JsonApiResponse.ok(doc); + return Response.ok(doc); } @override - Future createResource( - HttpRequest request, Target target) async { + Future createResource(HttpRequest request, Target target) async { final res = (await _decode(request)).dataAsNewResource(); final ref = Ref(res.type, res.id ?? getId()); await repo.persist( res.type, Model(ref.id)..setFrom(ModelProps.fromResource(res))); if (res.id != null) { - return JsonApiResponse.noContent(); + return Response.noContent(); } final self = Link(design.resource(ref.type, ref.id)); final resource = (await _fetchResource(ref.type, ref.id)) ..links['self'] = self; - return JsonApiResponse.created( + return Response.created( OutboundDataDocument.resource(resource)..links['self'] = self, self.uri.toString()); } @override - Future addMany( + Future addMany( HttpRequest request, RelationshipTarget target) async { final many = (await _decode(request)).asRelationship(); final refs = await repo .addMany(target.type, target.id, target.relationship, many) .toList(); - return JsonApiResponse.ok( + return Response.ok( OutboundDataDocument.many(ToMany(refs.map(Identifier.of)))); } @override - Future deleteResource( + Future deleteResource( HttpRequest request, ResourceTarget target) async { await repo.delete(target.type, target.id); - return JsonApiResponse.noContent(); + return Response.noContent(); } @override - Future updateResource( + Future updateResource( HttpRequest request, ResourceTarget target) async { await repo.update(target.type, target.id, ModelProps.fromResource((await _decode(request)).dataAsResource())); - return JsonApiResponse.noContent(); + return Response.noContent(); } @override - Future replaceRelationship( + Future replaceRelationship( HttpRequest request, RelationshipTarget target) async { final rel = (await _decode(request)).asRelationship(); if (rel is ToOne) { final ref = rel.identifier; await repo.replaceOne(target.type, target.id, target.relationship, ref); - return JsonApiResponse.ok(OutboundDataDocument.one( + return Response.ok(OutboundDataDocument.one( ref == null ? ToOne.empty() : ToOne(Identifier.of(ref)))); } if (rel is ToMany) { @@ -107,62 +103,62 @@ class RepositoryController implements Controller { .replaceMany(target.type, target.id, target.relationship, rel) .map(Identifier.of) .toList(); - return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); + return Response.ok(OutboundDataDocument.many(ToMany(ids))); } throw FormatException('Incomplete relationship'); } @override - Future deleteMany( + Future deleteMany( HttpRequest request, RelationshipTarget target) async { final rel = (await _decode(request)).asToMany(); final ids = await repo .deleteMany(target.type, target.id, target.relationship, rel) .map(Identifier.of) .toList(); - return JsonApiResponse.ok(OutboundDataDocument.many(ToMany(ids))); + return Response.ok(OutboundDataDocument.many(ToMany(ids))); } @override - Future fetchRelationship( + Future fetchRelationship( HttpRequest request, RelationshipTarget target) async { final model = (await repo.fetch(target.type, target.id)); if (model.one.containsKey(target.relationship)) { - return JsonApiResponse.ok(OutboundDataDocument.one( + return Response.ok(OutboundDataDocument.one( ToOne(nullable(Identifier.of)(model.one[target.relationship])))); } final many = model.many[target.relationship]; if (many != null) { final doc = OutboundDataDocument.many(ToMany(many.map(Identifier.of))); - return JsonApiResponse.ok(doc); + return Response.ok(doc); } throw RelationshipNotFound(); } @override - Future fetchRelated( + Future fetchRelated( HttpRequest request, RelatedTarget target) async { final model = await repo.fetch(target.type, target.id); if (model.one.containsKey(target.relationship)) { final related = await nullable(_fetchRelatedResource)(model.one[target.relationship]); final doc = OutboundDataDocument.resource(related); - return JsonApiResponse.ok(doc); + return Response.ok(doc); } if (model.many.containsKey(target.relationship)) { final many = model.many[target.relationship] ?? {}; final doc = OutboundDataDocument.collection( await _fetchRelatedCollection(many).toList()); - return JsonApiResponse.ok(doc); + return Response.ok(doc); } throw RelationshipNotFound(); } /// Returns a stream of related resources recursively Stream _getAllRelated( - Resource resource, Iterable forest) async* { - for (final node in forest) { + Resource resource, Iterable nodes) async* { + for (final node in nodes) { await for (final r in _getRelated(resource, node.name)) { yield r; yield* _getAllRelated(r, node.children); @@ -204,7 +200,7 @@ class RepositoryController implements Controller { } Future _decode(HttpRequest r) async => - InboundDocument(await DefaultCodec().decode(r.body)); + InboundDocument(await PayloadCodec().decode(r.body)); } typedef IdGenerator = String Function(); diff --git a/lib/src/test/response.dart b/lib/src/_testing/response.dart similarity index 100% rename from lib/src/test/response.dart rename to lib/src/_testing/response.dart diff --git a/lib/src/_testing/try_catch_handler.dart b/lib/src/_testing/try_catch_handler.dart new file mode 100644 index 00000000..2f3704f2 --- /dev/null +++ b/lib/src/_testing/try_catch_handler.dart @@ -0,0 +1,22 @@ +import 'package:json_api/http.dart'; + +class TryCatchHandler implements HttpHandler { + TryCatchHandler(this.handler, {this.onError = sendInternalServerError}); + + final HttpHandler handler; + final Future Function(dynamic error) onError; + + static Future sendInternalServerError(dynamic e) async => + HttpResponse(500); + + @override + Future call(HttpRequest request) async { + try { + return await handler(request); + } on HttpResponse catch (response) { + return response; + } catch (error) { + return await onError(error); + } + } +} diff --git a/lib/src/client/basic_client.dart b/lib/src/client/client.dart similarity index 84% rename from lib/src/client/basic_client.dart rename to lib/src/client/client.dart index 0bde4358..29bcc890 100644 --- a/lib/src/client/basic_client.dart +++ b/lib/src/client/client.dart @@ -1,16 +1,18 @@ -import 'package:json_api/codec.dart'; -import 'package:json_api/handler.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/client/disposable_handler.dart'; import 'package:json_api/src/client/request.dart'; -import 'package:json_api/src/client/response/request_failure.dart'; import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/client/response/request_failure.dart'; /// A basic JSON:API client -class BasicClient { - BasicClient(this._http, {PayloadCodec? codec}) - : _codec = codec ?? const DefaultCodec(); +class Client { + const Client( + {PayloadCodec codec = const PayloadCodec(), + HttpHandler handler = const DisposableHandler()}) + : _codec = codec, + _http = handler; - final AsyncHandler _http; + final HttpHandler _http; final PayloadCodec _codec; /// Sends the [request] to the server. diff --git a/lib/src/client/disposable_handler.dart b/lib/src/client/disposable_handler.dart new file mode 100644 index 00000000..dc629b0a --- /dev/null +++ b/lib/src/client/disposable_handler.dart @@ -0,0 +1,19 @@ +import 'package:http/http.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/persistent_handler.dart'; + +/// This HTTP handler creates a new instance of the [Client] for every request +/// end disposes the client after the request completes. +class DisposableHandler implements HttpHandler { + const DisposableHandler(); + + @override + Future call(HttpRequest request) async { + final client = Client(); + try { + return await PersistentHandler(client).call(request); + } finally { + client.close(); + } + } +} diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart new file mode 100644 index 00000000..e0b6c0f9 --- /dev/null +++ b/lib/src/client/persistent_handler.dart @@ -0,0 +1,22 @@ +import 'package:http/http.dart' as http; +import 'package:json_api/http.dart'; + +/// Handler which relies on the built-in Dart HTTP client. +/// It is the developer's responsibility to instantiate the client and +/// call `close()` on it in the end pf the application lifecycle. +class PersistentHandler { + /// Creates a new instance of the handler. Do not forget to call `close()` on + /// the [client] when it's not longer needed. + PersistentHandler(this.client); + + final http.Client client; + + Future call(HttpRequest request) async { + final response = await http.Response.fromStream( + await client.send(http.Request(request.method, request.uri) + ..headers.addAll(request.headers) + ..body = request.body)); + return HttpResponse(response.statusCode, body: response.body) + ..headers.addAll(response.headers); + } +} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart index 492ddc5a..35b10aaf 100644 --- a/lib/src/client/request.dart +++ b/lib/src/client/request.dart @@ -1,5 +1,5 @@ +import 'package:json_api/http.dart'; import 'package:json_api/query.dart'; -import 'package:json_api/src/http/http_headers.dart'; /// JSON:API request consumed by the client class Request with HttpHeaders { diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index d38926a2..4e8705fb 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,10 +1,10 @@ -import 'package:json_api/http.dart'; +import 'package:json_api/http.dart' as h; class Response { Response(this.http, this.json); /// HTTP response - final HttpResponse http; + final h.HttpResponse http; /// Raw JSON response final Map? json; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 592882df..8e1a4a72 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -1,7 +1,6 @@ -import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/client/basic_client.dart'; +import 'package:json_api/src/client/client.dart'; import 'package:json_api/src/client/request.dart'; import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/response/collection_fetched.dart'; @@ -14,9 +13,10 @@ import 'package:json_api/src/client/response/resource_updated.dart'; /// A routing JSON:API client class RoutingClient { - RoutingClient(this._uri, this._client); + RoutingClient(this._uri, {Client client = const Client()}) + : _client = client; - final BasicClient _client; + final Client _client; final UriDesign _uri; /// Adds [identifiers] to a to-many relationship diff --git a/lib/src/http/cors_handler.dart b/lib/src/http/cors_handler.dart new file mode 100644 index 00000000..3ca0d509 --- /dev/null +++ b/lib/src/http/cors_handler.dart @@ -0,0 +1,33 @@ +import 'package:json_api/src/http/http_handler.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +class CorsHandler implements HttpHandler { + CorsHandler(this._handler); + + final HttpHandler _handler; + + @override + Future call(HttpRequest request) async { + final headers = { + 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', + 'Access-Control-Expose-Headers': 'Location', + }; + + if (request.isOptions) { + const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; + return HttpResponse(204) + ..headers.addAll({ + ...headers, + 'Access-Control-Allow-Methods': + // TODO: Make it work for all browsers. Why is toUpperCase() needed? + request.headers['Access-Control-Request-Method']?.toUpperCase() ?? + methods.join(', '), + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? '*', + }); + } + return await _handler(request) + ..headers.addAll(headers); + } +} diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart new file mode 100644 index 00000000..b7b348fd --- /dev/null +++ b/lib/src/http/http_handler.dart @@ -0,0 +1,6 @@ +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +abstract class HttpHandler { + Future call(HttpRequest request); +} diff --git a/lib/src/http/http_message.dart b/lib/src/http/http_message.dart index 43479cdf..64142ac9 100644 --- a/lib/src/http/http_message.dart +++ b/lib/src/http/http_message.dart @@ -1,5 +1,6 @@ import 'package:json_api/src/http/http_headers.dart'; +/// HTTP message. Request or Response. class HttpMessage with HttpHeaders { HttpMessage(this.body); diff --git a/lib/src/http/logging_handler.dart b/lib/src/http/logging_handler.dart new file mode 100644 index 00000000..d10aae0d --- /dev/null +++ b/lib/src/http/logging_handler.dart @@ -0,0 +1,20 @@ +import 'package:json_api/src/http/http_handler.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A wrapper over [HttpHandler] which allows logging +class LoggingHandler implements HttpHandler { + LoggingHandler(this.handler, {this.onRequest, this.onResponse}); + + final HttpHandler handler; + final Function(HttpRequest request)? onRequest; + final Function(HttpResponse response)? onResponse; + + @override + Future call(HttpRequest request) async { + onRequest?.call(request); + final response = await handler(request); + onResponse?.call(response); + return response; + } +} diff --git a/lib/codec.dart b/lib/src/http/payload_codec.dart similarity index 56% rename from lib/codec.dart rename to lib/src/http/payload_codec.dart index 77bfa088..9c6d272a 100644 --- a/lib/codec.dart +++ b/lib/src/http/payload_codec.dart @@ -1,23 +1,14 @@ -library codec; - import 'dart:convert'; -abstract class PayloadCodec { - Future decode(String body); - - Future encode(Object document); -} - -class DefaultCodec implements PayloadCodec { - const DefaultCodec(); +/// Encodes/decodes JSON payload +class PayloadCodec { + const PayloadCodec(); - @override Future decode(String body) async { final json = jsonDecode(body); if (json is Map) return json; throw FormatException('Invalid JSON payload: ${json.runtimeType}'); } - @override Future encode(Object document) async => jsonEncode(document); } diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 8ad6be5e..9759d0b6 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,34 +1,41 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -abstract class Controller { +/// JSON:API controller +abstract class Controller { /// Fetch a primary resource collection - Future fetchCollection(HttpRequest request, Target target); + Future fetchCollection(HttpRequest request, Target target); /// Create resource - Future createResource(HttpRequest request, Target target); + Future createResource(HttpRequest request, Target target); /// Fetch a single primary resource - Future fetchResource(HttpRequest request, ResourceTarget target); + Future fetchResource( + HttpRequest request, ResourceTarget target); /// Updates a primary resource - Future updateResource(HttpRequest request, ResourceTarget target); + Future updateResource( + HttpRequest request, ResourceTarget target); /// Deletes the primary resource - Future deleteResource(HttpRequest request, ResourceTarget target); + Future deleteResource( + HttpRequest request, ResourceTarget target); /// Fetches a relationship - Future fetchRelationship(HttpRequest rq, RelationshipTarget target); + Future fetchRelationship( + HttpRequest rq, RelationshipTarget target); /// Add new entries to a to-many relationship - Future addMany(HttpRequest request, RelationshipTarget target); + Future addMany(HttpRequest request, RelationshipTarget target); /// Updates the relationship - Future replaceRelationship(HttpRequest request, RelationshipTarget target); + Future replaceRelationship( + HttpRequest request, RelationshipTarget target); /// Deletes the members from the to-many relationship - Future deleteMany(HttpRequest request, RelationshipTarget target); + Future deleteMany( + HttpRequest request, RelationshipTarget target); /// Fetches related resource or collection - Future fetchRelated(HttpRequest request, RelatedTarget target); + Future fetchRelated(HttpRequest request, RelatedTarget target); } diff --git a/lib/src/server/method_not_allowed.dart b/lib/src/server/errors/method_not_allowed.dart similarity index 100% rename from lib/src/server/method_not_allowed.dart rename to lib/src/server/errors/method_not_allowed.dart diff --git a/lib/src/server/unmatched_target.dart b/lib/src/server/errors/unmatched_target.dart similarity index 100% rename from lib/src/server/unmatched_target.dart rename to lib/src/server/errors/unmatched_target.dart diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart deleted file mode 100644 index 9d5845b2..00000000 --- a/lib/src/server/json_api_response.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/src/nullable.dart'; - -/// JSON:API response -class JsonApiResponse extends HttpResponse { - JsonApiResponse(int statusCode, {this.document}) : super(statusCode) { - if (document != null) { - headers['Content-Type'] = MediaType.jsonApi; - } - } - - final D? document; - - @override - String get body => nullable(jsonEncode)(document) ?? ''; - - static JsonApiResponse ok(OutboundDocument document) => - JsonApiResponse(StatusCode.ok, document: document); - - static JsonApiResponse noContent() => JsonApiResponse(StatusCode.noContent); - - static JsonApiResponse created(OutboundDocument document, String location) => - JsonApiResponse(StatusCode.created, document: document)..headers['location'] = location; - - static JsonApiResponse notFound([OutboundErrorDocument? document]) => - JsonApiResponse(StatusCode.notFound, document: document); - - static JsonApiResponse methodNotAllowed([OutboundErrorDocument? document]) => - JsonApiResponse(StatusCode.methodNotAllowed, document: document); - - static JsonApiResponse badRequest([OutboundErrorDocument? document]) => - JsonApiResponse(StatusCode.badRequest, document: document); -} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart new file mode 100644 index 00000000..ba17cebb --- /dev/null +++ b/lib/src/server/response.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/nullable.dart'; + +/// JSON:API response +class Response extends HttpResponse { + Response(int statusCode, {this.document}) : super(statusCode) { + if (document != null) { + headers['Content-Type'] = MediaType.jsonApi; + } + } + + final D? document; + + @override + String get body => nullable(jsonEncode)(document) ?? ''; + + static Response ok(OutboundDocument document) => + Response(StatusCode.ok, document: document); + + static Response noContent() => Response(StatusCode.noContent); + + static Response created(OutboundDocument document, String location) => + Response(StatusCode.created, document: document) + ..headers['location'] = location; + + static Response notFound([OutboundErrorDocument? document]) => + Response(StatusCode.notFound, document: document); + + static Response methodNotAllowed([OutboundErrorDocument? document]) => + Response(StatusCode.methodNotAllowed, document: document); + + static Response badRequest([OutboundErrorDocument? document]) => + Response(StatusCode.badRequest, document: document); +} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 5cfc8750..eea6c779 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -1,60 +1,60 @@ import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/handler.dart'; import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/method_not_allowed.dart'; -import 'package:json_api/src/server/unmatched_target.dart'; +import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/unmatched_target.dart'; -class Router implements AsyncHandler { - Router(this.controller, this.matchTarget); +class Router implements HttpHandler { + Router(this._controller, this._matchTarget); - final Controller controller; - final Target? Function(Uri uri) matchTarget; + final Controller _controller; + final Target? Function(Uri uri) _matchTarget; @override - Future call(HttpRequest request) async { - final target = matchTarget(request.uri); + Future call(HttpRequest request) async { + final target = _matchTarget(request.uri); if (target is RelationshipTarget) { if (request.isGet) { - return await controller.fetchRelationship(request, target); + return await _controller.fetchRelationship(request, target); + } + if (request.isPost) { + return await _controller.addMany(request, target); } - if (request.isPost) return await controller.addMany(request, target); if (request.isPatch) { - return await controller.replaceRelationship(request, target); + return await _controller.replaceRelationship(request, target); } if (request.isDelete) { - return await controller.deleteMany(request, target); + return await _controller.deleteMany(request, target); } throw MethodNotAllowed(request.method); } if (target is RelatedTarget) { if (request.isGet) { - return await controller.fetchRelated(request, target); + return await _controller.fetchRelated(request, target); } throw MethodNotAllowed(request.method); } if (target is ResourceTarget) { if (request.isGet) { - return await controller.fetchResource(request, target); + return await _controller.fetchResource(request, target); } if (request.isPatch) { - return await controller.updateResource(request, target); + return await _controller.updateResource(request, target); } if (request.isDelete) { - return await controller.deleteResource(request, target); + return await _controller.deleteResource(request, target); } throw MethodNotAllowed(request.method); } if (target is Target) { if (request.isGet) { - return await controller.fetchCollection(request, target); + return await _controller.fetchCollection(request, target); } if (request.isPost) { - return await controller.createResource(request, target); + return await _controller.createResource(request, target); } throw MethodNotAllowed(request.method); } - throw UnmatchedTarget(request.uri); } } diff --git a/pubspec.yaml b/pubspec.yaml index f77fac9d..c6bd9b3f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,13 @@ name: json_api -version: 5.0.0-nullsafety.11 +version: 5.0.0-rc.0 homepage: https://github.com/f3ath/json-api-dart -description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (http://jsonapi.org) +description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) environment: - sdk: '>=2.12.0-29 <3.0.0' + sdk: '>=2.12.0 <3.0.0' dev_dependencies: - pedantic: ^1.10.0-nullsafety - test: ^1.16.0-nullsafety - http: ^0.12.2 - test_coverage: ^0.5.0 - stream_channel: ^2.0.0 + pedantic: ^1.10.0 + test: ^1.16.0 + http: ^0.13.0 + stream_channel: ^2.1.0 + klizma: ^0.1.0 + uuid: ^3.0.0-nullsafety diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index a5352d65..cc4166a9 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,16 +1,15 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/_testing/demo_handler.dart'; import 'package:test/test.dart'; -import '../../demo/demo_handler.dart'; - void main() { late RoutingClient client; setUp(() async { - client = - RoutingClient(StandardUriDesign.pathOnly, BasicClient(DemoHandler())); + client = RoutingClient(StandardUriDesign.pathOnly, + client: Client(handler: DemoHandler())); }); group('CRUD', () { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 8d11afa8..55bd706c 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,14 +1,13 @@ import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/src/_testing/demo_handler.dart'; import 'package:test/test.dart'; -import '../../demo/demo_handler.dart'; - void main() { - late BasicClient client; + late Client client; setUp(() async { - client = BasicClient(DemoHandler()); + client = Client(handler: DemoHandler()); }); group('Errors', () { diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 944ade39..9ce97ffd 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,15 +1,14 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/_testing/demo_handler.dart'; import 'package:test/test.dart'; -import '../../demo/demo_handler.dart'; - void main() { late RoutingClient client; setUp(() async { - client = - RoutingClient(StandardUriDesign.pathOnly, BasicClient(DemoHandler())); + client = RoutingClient(StandardUriDesign.pathOnly, + client: Client(handler: DemoHandler())); }); group('Resource creation', () { diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart index 507e73da..a33c45e5 100644 --- a/test/e2e/browser_test.dart +++ b/test/e2e/browser_test.dart @@ -1,23 +1,20 @@ -// @dart=2.9 import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../../legacy/dart_http_handler.dart'; import 'e2e_test_set.dart'; void main() { - RoutingClient client; + late RoutingClient client; setUp(() async { final channel = spawnHybridUri('hybrid_server.dart'); final serverUrl = await channel.stream.first; - // final serverUrl = 'http://localhost:8080'; - client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())), - BasicClient(DartHttpHandler())); + client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString()))); }); - test('On Browser', () { - e2eTests(client); + + test('On Browser', () async { + await e2eTests(client); }, testOn: 'browser'); } diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart index d8d94766..04f63bef 100644 --- a/test/e2e/e2e_test_set.dart +++ b/test/e2e/e2e_test_set.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:test/test.dart'; -void e2eTests(RoutingClient client) async { +Future e2eTests(RoutingClient client) async { await _testAllHttpMethods(client); await _testLocationIsSet(client); } @@ -22,7 +22,7 @@ Future _testAllHttpMethods(RoutingClient client) async { // DELETE await client.deleteResource('posts', id); await client.fetchCollection('posts').then((r) { - expect(r.collection.length, isEmpty); + expect(r.collection, isEmpty); }); } diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 19e4e587..5dc16c70 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,11 +1,11 @@ -// @dart=2.10 +import 'package:json_api/src/_testing/demo_handler.dart'; +import 'package:json_api/src/_testing/json_api_server.dart'; import 'package:stream_channel/stream_channel.dart'; -import '../../demo/demo_handler.dart'; -import '../../demo/json_api_server.dart'; - void hybridMain(StreamChannel channel, Object message) async { - final server = JsonApiServer(DemoHandler(), port: 8000); + final host = 'localhost'; + final port = 8000; + final server = JsonApiServer(DemoHandler(), host: host, port: port); await server.start(); - channel.sink.add(server.uri.toString()); + channel.sink.add('http://$host:$port'); } diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 1b2a1756..19d9e508 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -1,29 +1,27 @@ -// @dart=2.10 import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/_testing/demo_handler.dart'; +import 'package:json_api/src/_testing/json_api_server.dart'; import 'package:test/test.dart'; -import '../../legacy/dart_http_handler.dart'; -import '../../demo/demo_handler.dart'; -import '../../demo/json_api_server.dart'; import 'e2e_test_set.dart'; void main() { - RoutingClient client; - JsonApiServer server; + late RoutingClient client; + late JsonApiServer server; setUp(() async { server = JsonApiServer(DemoHandler(), port: 8001); await server.start(); - client = RoutingClient( - StandardUriDesign(server.uri), BasicClient(DartHttpHandler())); + client = RoutingClient(StandardUriDesign( + Uri(scheme: 'http', host: server.host, port: server.port))); }); tearDown(() async { await server.stop(); }); - test('On VM', () { - e2eTests(client); + test('On VM', () async { + await e2eTests(client); }, testOn: 'vm'); } diff --git a/test/handler/logging_handler_test.dart b/test/handler/logging_handler_test.dart deleted file mode 100644 index 72c4a657..00000000 --- a/test/handler/logging_handler_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_api/handler.dart'; -import 'package:test/test.dart'; - -void main() { - test('Logging handler can log', () async { - String? loggedRequest; - String? loggedResponse; - - final handler = - LoggingHandler(AsyncHandler.lambda((String s) async => s.toUpperCase()), - onRequest: (String rq) { - loggedRequest = rq; - }, onResponse: (String rs) { - loggedResponse = rs; - }); - expect(await handler.call('foo'), 'FOO'); - expect(loggedRequest, 'foo'); - expect(loggedResponse, 'FOO'); - }); -} diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index bb3559ca..1b43987b 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -3,13 +3,14 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/test/mock_handler.dart'; -import 'package:json_api/src/test/response.dart' as mock; +import 'package:json_api/src/_testing/mock_handler.dart'; +import 'package:json_api/src/_testing/response.dart' as mock; import 'package:test/test.dart'; void main() { final http = MockHandler(); - final client = RoutingClient(StandardUriDesign.pathOnly, BasicClient(http)); + final client = + RoutingClient(StandardUriDesign.pathOnly, client: Client(handler: http)); group('Failure', () { test('RequestFailure', () async { diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 6c4ae351..2ce56ffc 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/test/payload.dart' as payload; +import 'package:json_api/src/_testing/payload.dart' as payload; import 'package:test/test.dart'; void main() { From 308325082c59253ac9d9369c869e55d221626e77 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 10:38:29 -0700 Subject: [PATCH 91/99] rc.0 --- .github/workflows/dart.yml | 4 +- .gitignore | 1 + example/client.dart | 11 +++-- example/server.dart | 47 +++++++++++-------- .../http => example/server}/cors_handler.dart | 12 ++--- .../server}/demo_handler.dart | 15 +++--- .../server}/in_memory_repo.dart | 2 +- .../server}/json_api_server.dart | 2 +- .../server}/mock_handler.dart | 2 +- .../server}/relationship_node.dart | 0 .../server}/repository.dart | 0 .../server}/repository_controller.dart | 3 +- .../_testing => example/server}/response.dart | 0 .../server}/try_catch_handler.dart | 8 ++-- lib/client.dart | 5 +- lib/document.dart | 1 + lib/http.dart | 2 +- lib/server.dart | 8 ++-- lib/src/client/client.dart | 2 +- lib/src/client/disposable_handler.dart | 2 +- lib/src/client/persistent_handler.dart | 8 ++-- lib/src/document/error_object.dart | 3 -- lib/src/http/http_handler.dart | 2 +- lib/src/http/logging_handler.dart | 4 +- lib/src/server/router.dart | 2 +- pubspec.yaml | 10 ++-- test/contract/crud_test.dart | 2 +- test/contract/errors_test.dart | 4 +- test/contract/resource_creation_test.dart | 2 +- test/e2e/hybrid_server.dart | 4 +- test/e2e/vm_test.dart | 4 +- test/unit/client/client_test.dart | 4 +- test/unit/document/inbound_document_test.dart | 2 +- .../unit/document}/payload.dart | 0 test/unit/http/payload_codec_test.dart | 8 ++++ 35 files changed, 108 insertions(+), 78 deletions(-) rename {lib/src/http => example/server}/cors_handler.dart (71%) rename {lib/src/_testing => example/server}/demo_handler.dart (83%) rename {lib/src/_testing => example/server}/in_memory_repo.dart (97%) rename {lib/src/_testing => example/server}/json_api_server.dart (96%) rename {lib/src/_testing => example/server}/mock_handler.dart (77%) rename {lib/src/_testing => example/server}/relationship_node.dart (100%) rename {lib/src/_testing => example/server}/repository.dart (100%) rename {lib/src/_testing => example/server}/repository_controller.dart (98%) rename {lib/src/_testing => example/server}/response.dart (100%) rename {lib/src/_testing => example/server}/try_catch_handler.dart (66%) rename {lib/src/_testing => test/unit/document}/payload.dart (100%) create mode 100644 test/unit/http/payload_codec_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 47a25dc8..fb07a651 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -23,4 +23,6 @@ jobs: - name: Analyzer run: dart analyze --fatal-infos --fatal-warnings - name: Tests - run: dart run test_coverage --no-badge --print-test-output --min-coverage 100 --exclude=test/e2e/* + run: dart test --coverage=.coverage -j1 + - name: Coverage + run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib --packages=.packages | dart run check_coverage:check_coverage diff --git a/.gitignore b/.gitignore index 0f158446..e5e0a0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ doc/api/ test/.test_coverage.dart coverage coverage_badge.svg +/.coverage/ diff --git a/example/client.dart b/example/client.dart index 609db611..da5f693f 100644 --- a/example/client.dart +++ b/example/client.dart @@ -3,7 +3,12 @@ import 'package:json_api/routing.dart'; // START THE SERVER FIRST! void main() async { - final uri = Uri(host: 'localhost', port: 8080); - final client = - RoutingClient(StandardUriDesign(uri)); + final host = 'localhost'; + final port = 8080; + final uri = Uri(scheme: 'http', host: host, port: port); + final client = RoutingClient(StandardUriDesign(uri)); + final response = await client.fetchCollection('colors'); + response.collection.map((resource) => resource.attributes).forEach((attr) { + print('${attr['name']} - ${attr['red']}:${attr['green']}:${attr['blue']}'); + }); } diff --git a/example/server.dart b/example/server.dart index 847d8cab..3afa34f7 100644 --- a/example/server.dart +++ b/example/server.dart @@ -4,27 +4,34 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/_testing/in_memory_repo.dart'; -import 'package:json_api/src/_testing/json_api_server.dart'; -import 'package:json_api/src/_testing/repository.dart'; -import 'package:json_api/src/_testing/repository_controller.dart'; -import 'package:json_api/src/_testing/try_catch_handler.dart'; +import 'server/in_memory_repo.dart'; +import 'server/json_api_server.dart'; +import 'server/repository.dart'; +import 'server/repository_controller.dart'; +import 'server/try_catch_handler.dart'; import 'package:uuid/uuid.dart'; Future main() async { final host = 'localhost'; final port = 8080; final resources = ['colors']; - final handler = DemoHandler( - types: resources, + final repo = InMemoryRepo(resources); + await addColors(repo); + final controller = RepositoryController(repo, Uuid().v4); + HttpHandler handler = Router(controller, StandardUriDesign.matchTarget); + handler = TryCatchHandler(handler, onError: convertError); + handler = LoggingHandler(handler, onRequest: (r) => print('${r.method.toUpperCase()} ${r.uri}'), onResponse: (r) => print('${r.statusCode}')); final server = JsonApiServer(handler, host: host, port: port); + ProcessSignal.sigint.watch().listen((event) async { await server.stop(); exit(0); }); + await server.start(); + print('The server is listening at $host:$port.' ' Try opening the following URL(s) in your browser:'); resources.forEach((resource) { @@ -32,18 +39,20 @@ Future main() async { }); } -class DemoHandler extends LoggingHandler { - DemoHandler({ - Iterable types = const ['users', 'posts', 'comments'], - Function(HttpRequest request)? onRequest, - Function(HttpResponse response)? onResponse, - }) : super( - TryCatchHandler( - Router(RepositoryController(InMemoryRepo(types), Uuid().v4), - StandardUriDesign.matchTarget), - onError: convertError), - onRequest: onRequest, - onResponse: onResponse); +Future addColors(Repository repo) async { + final models = { + {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114}, + {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203}, + {'name': 'Lime', 'r': 0, 'g': 255, 'b': 0}, + {'name': 'Peru', 'r': 205, 'g': 133, 'b': 63}, + }.map((color) => Model(Uuid().v4()) + ..attributes['name'] = color['name'] + ..attributes['red'] = color['r'] + ..attributes['green'] = color['g'] + ..attributes['blue'] = color['b']); + for (final model in models) { + await repo.persist('colors', model); + } } Future convertError(dynamic error) async { diff --git a/lib/src/http/cors_handler.dart b/example/server/cors_handler.dart similarity index 71% rename from lib/src/http/cors_handler.dart rename to example/server/cors_handler.dart index 3ca0d509..e42f2efa 100644 --- a/lib/src/http/cors_handler.dart +++ b/example/server/cors_handler.dart @@ -1,14 +1,12 @@ -import 'package:json_api/src/http/http_handler.dart'; -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; +import 'package:json_api/http.dart'; class CorsHandler implements HttpHandler { - CorsHandler(this._handler); + CorsHandler(this._inner); - final HttpHandler _handler; + final HttpHandler _inner; @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { final headers = { 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', 'Access-Control-Expose-Headers': 'Location', @@ -27,7 +25,7 @@ class CorsHandler implements HttpHandler { request.headers['Access-Control-Request-Headers'] ?? '*', }); } - return await _handler(request) + return await _inner.handle(request) ..headers.addAll(headers); } } diff --git a/lib/src/_testing/demo_handler.dart b/example/server/demo_handler.dart similarity index 83% rename from lib/src/_testing/demo_handler.dart rename to example/server/demo_handler.dart index c68b0bb6..457e6fc2 100644 --- a/lib/src/_testing/demo_handler.dart +++ b/example/server/demo_handler.dart @@ -2,11 +2,10 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; -import 'package:json_api/src/_testing/in_memory_repo.dart'; -import 'package:json_api/src/_testing/repository.dart'; -import 'package:json_api/src/_testing/repository_controller.dart'; -import 'package:json_api/src/_testing/try_catch_handler.dart'; -import 'package:uuid/uuid.dart'; +import 'in_memory_repo.dart'; +import 'repository.dart'; +import 'repository_controller.dart'; +import 'try_catch_handler.dart'; class DemoHandler extends LoggingHandler { DemoHandler({ @@ -15,7 +14,7 @@ class DemoHandler extends LoggingHandler { Function(HttpResponse response)? onResponse, }) : super( TryCatchHandler( - Router(RepositoryController(InMemoryRepo(types), Uuid().v4), + Router(RepositoryController(InMemoryRepo(types), _id), StandardUriDesign.matchTarget), onError: _onError), onRequest: onRequest, @@ -47,3 +46,7 @@ class DemoHandler extends LoggingHandler { ])); } } + +int _counter = 0; + +String _id() => (_counter++).toString(); diff --git a/lib/src/_testing/in_memory_repo.dart b/example/server/in_memory_repo.dart similarity index 97% rename from lib/src/_testing/in_memory_repo.dart rename to example/server/in_memory_repo.dart index 6983bf05..30c872bb 100644 --- a/lib/src/_testing/in_memory_repo.dart +++ b/example/server/in_memory_repo.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/_testing/repository.dart'; +import 'repository.dart'; import 'package:json_api/src/nullable.dart'; class InMemoryRepo implements Repository { diff --git a/lib/src/_testing/json_api_server.dart b/example/server/json_api_server.dart similarity index 96% rename from lib/src/_testing/json_api_server.dart rename to example/server/json_api_server.dart index dace22cd..1fef68cb 100644 --- a/lib/src/_testing/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -44,7 +44,7 @@ class JsonApiServer { server.listen((request) async { final headers = {}; request.headers.forEach((k, v) => headers[k] = v.join(',')); - final response = await _handler.call(HttpRequest( + final response = await _handler.handle(HttpRequest( request.method, request.requestedUri, body: await request.cast>().transform(utf8.decoder).join()) ..headers.addAll(headers)); diff --git a/lib/src/_testing/mock_handler.dart b/example/server/mock_handler.dart similarity index 77% rename from lib/src/_testing/mock_handler.dart rename to example/server/mock_handler.dart index 71ef1df9..9dfe2ff9 100644 --- a/lib/src/_testing/mock_handler.dart +++ b/example/server/mock_handler.dart @@ -5,7 +5,7 @@ class MockHandler implements HttpHandler { late HttpResponse response; @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { this.request = request; return response; } diff --git a/lib/src/_testing/relationship_node.dart b/example/server/relationship_node.dart similarity index 100% rename from lib/src/_testing/relationship_node.dart rename to example/server/relationship_node.dart diff --git a/lib/src/_testing/repository.dart b/example/server/repository.dart similarity index 100% rename from lib/src/_testing/repository.dart rename to example/server/repository.dart diff --git a/lib/src/_testing/repository_controller.dart b/example/server/repository_controller.dart similarity index 98% rename from lib/src/_testing/repository_controller.dart rename to example/server/repository_controller.dart index 07cdd67a..5e4100d1 100644 --- a/lib/src/_testing/repository_controller.dart +++ b/example/server/repository_controller.dart @@ -18,8 +18,7 @@ class RepositoryController implements Controller { final design = StandardUriDesign.pathOnly; @override - Future fetchCollection( - HttpRequest request, Target target) async { + Future fetchCollection(HttpRequest request, Target target) async { final resources = await _fetchAll(target.type).toList(); final doc = OutboundDataDocument.collection(resources) ..links['self'] = Link(design.collection(target.type)); diff --git a/lib/src/_testing/response.dart b/example/server/response.dart similarity index 100% rename from lib/src/_testing/response.dart rename to example/server/response.dart diff --git a/lib/src/_testing/try_catch_handler.dart b/example/server/try_catch_handler.dart similarity index 66% rename from lib/src/_testing/try_catch_handler.dart rename to example/server/try_catch_handler.dart index 2f3704f2..a9a02405 100644 --- a/lib/src/_testing/try_catch_handler.dart +++ b/example/server/try_catch_handler.dart @@ -1,18 +1,18 @@ import 'package:json_api/http.dart'; class TryCatchHandler implements HttpHandler { - TryCatchHandler(this.handler, {this.onError = sendInternalServerError}); + TryCatchHandler(this._inner, {this.onError = sendInternalServerError}); - final HttpHandler handler; + final HttpHandler _inner; final Future Function(dynamic error) onError; static Future sendInternalServerError(dynamic e) async => HttpResponse(500); @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { try { - return await handler(request); + return await _inner.handle(request); } on HttpResponse catch (response) { return response; } catch (error) { diff --git a/lib/client.dart b/lib/client.dart index 14b910ce..56e69047 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,6 +1,8 @@ -library json_api; +/// JSON:API client for Flutter, browsers and vm. +library client; export 'package:json_api/src/client/client.dart'; +export 'package:json_api/src/client/request.dart'; export 'package:json_api/src/client/response/collection_fetched.dart'; export 'package:json_api/src/client/response/related_resource_fetched.dart'; export 'package:json_api/src/client/response/relationship_fetched.dart'; @@ -10,4 +12,3 @@ export 'package:json_api/src/client/response/resource_created.dart'; export 'package:json_api/src/client/response/resource_fetched.dart'; export 'package:json_api/src/client/response/resource_updated.dart'; export 'package:json_api/src/client/routing_client.dart'; -export 'package:json_api/src/client/request.dart'; diff --git a/lib/document.dart b/lib/document.dart index 27e6aa04..03bac6c9 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,3 +1,4 @@ +/// JSON:API Document model. library document; export 'package:json_api/src/document/error_object.dart'; diff --git a/lib/http.dart b/lib/http.dart index 95dd065e..d9da8dd4 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,7 +1,7 @@ /// This is a thin HTTP layer abstraction used by the client library http; -export 'package:json_api/src/http/cors_handler.dart'; +export 'file:///home/f3ath/project/json-api-dart/example/server/cors_handler.dart'; export 'package:json_api/src/http/http_handler.dart'; export 'package:json_api/src/http/http_headers.dart'; export 'package:json_api/src/http/http_message.dart'; diff --git a/lib/server.dart b/lib/server.dart index 97f371dd..f3913a8b 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,6 +1,8 @@ +/// JSON:API server on top of dart:io. +library server; + export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/http/cors_handler.dart'; -export 'package:json_api/src/server/response.dart'; export 'package:json_api/src/server/errors/method_not_allowed.dart'; -export 'package:json_api/src/server/router.dart'; export 'package:json_api/src/server/errors/unmatched_target.dart'; +export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 29bcc890..b7890383 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -19,7 +19,7 @@ class Client { /// Throws a [RequestFailure] if the server responds with an error. Future send(Uri uri, Request request) async { final body = await _encode(request.document); - final response = await _http.call(HttpRequest( + final response = await _http.handle(HttpRequest( request.method, request.query.isEmpty ? uri diff --git a/lib/src/client/disposable_handler.dart b/lib/src/client/disposable_handler.dart index dc629b0a..79a0ade3 100644 --- a/lib/src/client/disposable_handler.dart +++ b/lib/src/client/disposable_handler.dart @@ -8,7 +8,7 @@ class DisposableHandler implements HttpHandler { const DisposableHandler(); @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { final client = Client(); try { return await PersistentHandler(client).call(request); diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart index e0b6c0f9..b2b62ea3 100644 --- a/lib/src/client/persistent_handler.dart +++ b/lib/src/client/persistent_handler.dart @@ -1,4 +1,4 @@ -import 'package:http/http.dart' as http; +import 'package:http/http.dart'; import 'package:json_api/http.dart'; /// Handler which relies on the built-in Dart HTTP client. @@ -9,11 +9,11 @@ class PersistentHandler { /// the [client] when it's not longer needed. PersistentHandler(this.client); - final http.Client client; + final Client client; Future call(HttpRequest request) async { - final response = await http.Response.fromStream( - await client.send(http.Request(request.method, request.uri) + final response = await Response.fromStream( + await client.send(Request(request.method, request.uri) ..headers.addAll(request.headers) ..body = request.body)); return HttpResponse(response.statusCode, body: response.body) diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index ab8f5e04..07ef02c9 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -53,7 +53,4 @@ class ErrorObject { if (links.isNotEmpty) 'links': links, if (meta.isNotEmpty) 'meta': meta, }; - - @override - String toString() => toJson().toString(); } diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart index b7b348fd..234c125e 100644 --- a/lib/src/http/http_handler.dart +++ b/lib/src/http/http_handler.dart @@ -2,5 +2,5 @@ import 'package:json_api/src/http/http_request.dart'; import 'package:json_api/src/http/http_response.dart'; abstract class HttpHandler { - Future call(HttpRequest request); + Future handle(HttpRequest request); } diff --git a/lib/src/http/logging_handler.dart b/lib/src/http/logging_handler.dart index d10aae0d..1ae8c38a 100644 --- a/lib/src/http/logging_handler.dart +++ b/lib/src/http/logging_handler.dart @@ -11,9 +11,9 @@ class LoggingHandler implements HttpHandler { final Function(HttpResponse response)? onResponse; @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { onRequest?.call(request); - final response = await handler(request); + final response = await handler.handle(request); onResponse?.call(response); return response; } diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index eea6c779..aeb29b7a 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -11,7 +11,7 @@ class Router implements HttpHandler { final Target? Function(Uri uri) _matchTarget; @override - Future call(HttpRequest request) async { + Future handle(HttpRequest request) async { final target = _matchTarget(request.uri); if (target is RelationshipTarget) { if (request.isGet) { diff --git a/pubspec.yaml b/pubspec.yaml index c6bd9b3f..d9f675de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,10 +4,14 @@ homepage: https://github.com/f3ath/json-api-dart description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) environment: sdk: '>=2.12.0 <3.0.0' + +dependencies: + http: ^0.13.0 + dev_dependencies: pedantic: ^1.10.0 test: ^1.16.0 - http: ^0.13.0 stream_channel: ^2.1.0 - klizma: ^0.1.0 - uuid: ^3.0.0-nullsafety + uuid: ^3.0.0 + coverage: ^1.0.2 + check_coverage: ^0.0.2 diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index cc4166a9..feaa2c91 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/_testing/demo_handler.dart'; +import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 55bd706c..816e3a9f 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,6 +1,6 @@ import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; -import 'package:json_api/src/_testing/demo_handler.dart'; +import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; void main() { @@ -30,7 +30,7 @@ void main() { }); test('Bad request when target can not be matched', () async { final r = await DemoHandler() - .call(HttpRequest('get', Uri.parse('/a/long/prefix/'))); + .handle(HttpRequest('get', Uri.parse('/a/long/prefix/'))); expect(r.statusCode, 400); }); }); diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index 9ce97ffd..e8181317 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,6 +1,6 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/_testing/demo_handler.dart'; +import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 5dc16c70..32a31470 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,5 +1,5 @@ -import 'package:json_api/src/_testing/demo_handler.dart'; -import 'package:json_api/src/_testing/json_api_server.dart'; +import '../../example/server/demo_handler.dart'; +import '../../example/server/json_api_server.dart'; import 'package:stream_channel/stream_channel.dart'; void hybridMain(StreamChannel channel, Object message) async { diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 19d9e508..6e2cbca8 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -1,7 +1,7 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/_testing/demo_handler.dart'; -import 'package:json_api/src/_testing/json_api_server.dart'; +import '../../example/server/demo_handler.dart'; +import '../../example/server/json_api_server.dart'; import 'package:test/test.dart'; import 'e2e_test_set.dart'; diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 1b43987b..1710f717 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import 'package:json_api/src/_testing/mock_handler.dart'; -import 'package:json_api/src/_testing/response.dart' as mock; +import '../../../example/server/mock_handler.dart'; +import '../../../example/server/response.dart' as mock; import 'package:test/test.dart'; void main() { diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 2ce56ffc..9e5e4832 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,5 +1,5 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/_testing/payload.dart' as payload; +import 'file:///home/f3ath/project/json-api-dart/test/unit/document/payload.dart' as payload; import 'package:test/test.dart'; void main() { diff --git a/lib/src/_testing/payload.dart b/test/unit/document/payload.dart similarity index 100% rename from lib/src/_testing/payload.dart rename to test/unit/document/payload.dart diff --git a/test/unit/http/payload_codec_test.dart b/test/unit/http/payload_codec_test.dart new file mode 100644 index 00000000..ef1057ad --- /dev/null +++ b/test/unit/http/payload_codec_test.dart @@ -0,0 +1,8 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + test('Throws format exception if the payload is not a Map', () { + expect(() => PayloadCodec().decode('"oops"'), throwsFormatException); + }); +} From 9b751d034919ec618a0455269101a7b763998ee5 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 10:53:41 -0700 Subject: [PATCH 92/99] formatting --- example/server.dart | 3 ++- example/server/demo_handler.dart | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/example/server.dart b/example/server.dart index 3afa34f7..fbb647bd 100644 --- a/example/server.dart +++ b/example/server.dart @@ -4,12 +4,13 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; +import 'package:uuid/uuid.dart'; + import 'server/in_memory_repo.dart'; import 'server/json_api_server.dart'; import 'server/repository.dart'; import 'server/repository_controller.dart'; import 'server/try_catch_handler.dart'; -import 'package:uuid/uuid.dart'; Future main() async { final host = 'localhost'; diff --git a/example/server/demo_handler.dart b/example/server/demo_handler.dart index 457e6fc2..9f973cd8 100644 --- a/example/server/demo_handler.dart +++ b/example/server/demo_handler.dart @@ -2,6 +2,7 @@ import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; + import 'in_memory_repo.dart'; import 'repository.dart'; import 'repository_controller.dart'; From c23d6775294c18ae0bb3713e555db0ce70d4449f Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 10:56:11 -0700 Subject: [PATCH 93/99] formatting --- example/server/in_memory_repo.dart | 3 ++- example/server/json_api_server.dart | 8 ++++---- lib/http.dart | 5 +++-- lib/src/client/client.dart | 2 +- lib/src/client/routing_client.dart | 3 +-- lib/src/document/many.dart | 2 +- lib/src/document/one.dart | 2 +- test/contract/crud_test.dart | 3 ++- test/contract/errors_test.dart | 3 ++- test/contract/resource_creation_test.dart | 3 ++- test/e2e/hybrid_server.dart | 3 ++- test/e2e/vm_test.dart | 4 ++-- test/unit/client/client_test.dart | 3 ++- test/unit/document/inbound_document_test.dart | 3 ++- 14 files changed, 27 insertions(+), 20 deletions(-) diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart index 30c872bb..d6639514 100644 --- a/example/server/in_memory_repo.dart +++ b/example/server/in_memory_repo.dart @@ -1,7 +1,8 @@ import 'package:json_api/document.dart'; -import 'repository.dart'; import 'package:json_api/src/nullable.dart'; +import 'repository.dart'; + class InMemoryRepo implements Repository { InMemoryRepo(Iterable types) { types.forEach((_) { diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart index 1fef68cb..f53b7e83 100644 --- a/example/server/json_api_server.dart +++ b/example/server/json_api_server.dart @@ -5,10 +5,10 @@ import 'package:json_api/http.dart'; class JsonApiServer { JsonApiServer( - this._handler, { - this.host = 'localhost', - this.port = 8080, - }); + this._handler, { + this.host = 'localhost', + this.port = 8080, + }); /// Server host name final String host; diff --git a/lib/http.dart b/lib/http.dart index d9da8dd4..c94df08b 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,13 +1,14 @@ /// This is a thin HTTP layer abstraction used by the client library http; -export 'file:///home/f3ath/project/json-api-dart/example/server/cors_handler.dart'; export 'package:json_api/src/http/http_handler.dart'; export 'package:json_api/src/http/http_headers.dart'; export 'package:json_api/src/http/http_message.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; -export 'package:json_api/src/http/status_code.dart'; export 'package:json_api/src/http/logging_handler.dart'; export 'package:json_api/src/http/media_type.dart'; export 'package:json_api/src/http/payload_codec.dart'; +export 'package:json_api/src/http/status_code.dart'; + +export 'file:///home/f3ath/project/json-api-dart/example/server/cors_handler.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index b7890383..fe361fa3 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -8,7 +8,7 @@ import 'package:json_api/src/client/response/request_failure.dart'; class Client { const Client( {PayloadCodec codec = const PayloadCodec(), - HttpHandler handler = const DisposableHandler()}) + HttpHandler handler = const DisposableHandler()}) : _codec = codec, _http = handler; diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 8e1a4a72..13533832 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -13,8 +13,7 @@ import 'package:json_api/src/client/response/resource_updated.dart'; /// A routing JSON:API client class RoutingClient { - RoutingClient(this._uri, {Client client = const Client()}) - : _client = client; + RoutingClient(this._uri, {Client client = const Client()}) : _client = client; final Client _client; final UriDesign _uri; diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart index 0e79420d..05696739 100644 --- a/lib/src/document/many.dart +++ b/lib/src/document/many.dart @@ -1,7 +1,7 @@ import 'package:json_api/document.dart'; -import 'package:json_api/src/document/resource_collection.dart'; import 'package:json_api/src/document/identifier.dart'; import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource_collection.dart'; class ToMany extends Relationship { ToMany(Iterable identifiers) { diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart index 1af6663c..99680bd3 100644 --- a/lib/src/document/one.dart +++ b/lib/src/document/one.dart @@ -1,7 +1,7 @@ -import 'package:json_api/src/document/resource_collection.dart'; import 'package:json_api/src/document/identifier.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.dart'; class ToOne extends Relationship { ToOne(this.identifier); diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart index feaa2c91..a2a2177c 100644 --- a/test/contract/crud_test.dart +++ b/test/contract/crud_test.dart @@ -1,9 +1,10 @@ import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; -import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; +import '../../example/server/demo_handler.dart'; + void main() { late RoutingClient client; diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart index 816e3a9f..559e93ae 100644 --- a/test/contract/errors_test.dart +++ b/test/contract/errors_test.dart @@ -1,8 +1,9 @@ import 'package:json_api/client.dart'; import 'package:json_api/http.dart'; -import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; +import '../../example/server/demo_handler.dart'; + void main() { late Client client; diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart index e8181317..e1b09902 100644 --- a/test/contract/resource_creation_test.dart +++ b/test/contract/resource_creation_test.dart @@ -1,8 +1,9 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import '../../example/server/demo_handler.dart'; import 'package:test/test.dart'; +import '../../example/server/demo_handler.dart'; + void main() { late RoutingClient client; diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart index 32a31470..578430f8 100644 --- a/test/e2e/hybrid_server.dart +++ b/test/e2e/hybrid_server.dart @@ -1,6 +1,7 @@ +import 'package:stream_channel/stream_channel.dart'; + import '../../example/server/demo_handler.dart'; import '../../example/server/json_api_server.dart'; -import 'package:stream_channel/stream_channel.dart'; void hybridMain(StreamChannel channel, Object message) async { final host = 'localhost'; diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart index 6e2cbca8..1c3d98f7 100644 --- a/test/e2e/vm_test.dart +++ b/test/e2e/vm_test.dart @@ -1,9 +1,9 @@ import 'package:json_api/client.dart'; import 'package:json_api/routing.dart'; -import '../../example/server/demo_handler.dart'; -import '../../example/server/json_api_server.dart'; import 'package:test/test.dart'; +import '../../example/server/demo_handler.dart'; +import '../../example/server/json_api_server.dart'; import 'e2e_test_set.dart'; void main() { diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index 1710f717..a7c9956d 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -3,9 +3,10 @@ import 'dart:convert'; import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + import '../../../example/server/mock_handler.dart'; import '../../../example/server/response.dart' as mock; -import 'package:test/test.dart'; void main() { final http = MockHandler(); diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart index 9e5e4832..75669439 100644 --- a/test/unit/document/inbound_document_test.dart +++ b/test/unit/document/inbound_document_test.dart @@ -1,7 +1,8 @@ import 'package:json_api/document.dart'; -import 'file:///home/f3ath/project/json-api-dart/test/unit/document/payload.dart' as payload; import 'package:test/test.dart'; +import 'payload.dart' as payload; + void main() { group('InboundDocument', () { group('Errors', () { From f95b3ee92e46280a5b993ce3ce29126fe15fb450 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 10:56:53 -0700 Subject: [PATCH 94/99] use latest container --- .github/workflows/dart.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index fb07a651..532f2059 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest container: - image: google/dart:beta + image: google/dart:latest steps: - uses: actions/checkout@v2 From a8f6f9c8e110ba12b5c11aedaa27d4677c2de29e Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 11:00:08 -0700 Subject: [PATCH 95/99] lost changelog entries --- .gitignore | 5 +---- CHANGELOG.md | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index e5e0a0e7..d30eaee0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,5 @@ pubspec.lock # If you don't generate documentation locally you can remove this line. doc/api/ -# Generated by test_coverage -test/.test_coverage.dart -coverage -coverage_badge.svg +# Generated by coverage /.coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 68eb08f7..1668fe2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Everything. Again. This is another major **BC-breaking** rework. Please refer to the API documentation, examples and tests. +## [4.3.0] - 2020-07-30 +### Added +- `meta` parameter for createResourceAt() + +### Removed +- Dropped support for Dart 2.6 + +## [4.2.2] - 2020-06-05 +### Fixed +- Client throws NoSuchMethodError on unexpected primary data ([issue](https://github.com/f3ath/json-api-dart/issues/102)). + +## [4.2.1] - 2020-06-04 +### Fixed +- The server library was not exporting `Controller`. +- `ResourceData.toJson()` was not calling the underlying `ResourceObject.toJson()`. + +## [4.2.0] - 2020-06-03 +### Added +- Filtering support for collections ([pr](https://github.com/f3ath/json-api-dart/pull/97)) + +### Changed +- The client will not attempt to decode the body of the HTTP response with error status if the correct Content-Type + is missing. Before in such cases a `FormatException` would be thrown ([pr](https://github.com/f3ath/json-api-dart/pull/98)) + +## [4.1.0] - 2020-05-28 +### Changed +- `DartHttp` now defaults to utf8 if no encoding is specified in the response. + ## [4.0.0] - 2020-02-29 ### Changed - Everything. This is a major **BC-breaking** rework which affected pretty much all areas. Please refer to the documentation. @@ -159,12 +187,17 @@ Most of the changes are **BC-BREAKING**. - Resource creation - Resource deletion -## 0.1.0 - 2019-02-27 +## [0.1.0] - 2019-02-27 ### Added - Client: fetch resources, collections, related resources and relationships -[5.0.0]: https://github.com/f3ath/json-api-dart/compare/4.0.0..5.0.0 -[4.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.2..4.0.0 +[5.0.0]: https://github.com/f3ath/json-api-dart/compare/4.3.0..5.0.0 +[4.3.0]: https://github.com/f3ath/json-api-dart/compare/4.2.2...4.3.0 +[4.2.2]: https://github.com/f3ath/json-api-dart/compare/4.2.1...4.2.2 +[4.2.1]: https://github.com/f3ath/json-api-dart/compare/4.2.0...4.2.1 +[4.2.0]: https://github.com/f3ath/json-api-dart/compare/4.1.0...4.2.0 +[4.1.0]: https://github.com/f3ath/json-api-dart/compare/4.0.0...4.1.0 +[4.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.2...4.0.0 [3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2..3.2.3 [3.2.2]: https://github.com/f3ath/json-api-dart/compare/3.2.1..3.2.2 [3.2.1]: https://github.com/f3ath/json-api-dart/compare/3.2.0...3.2.1 @@ -183,3 +216,4 @@ Most of the changes are **BC-BREAKING**. [0.4.0]: https://github.com/f3ath/json-api-dart/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/f3ath/json-api-dart/compare/0.2.0...0.3.0 [0.2.0]: https://github.com/f3ath/json-api-dart/compare/0.1.0...0.2.0 +[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0 From 6f0af53699364eaa392c07386c92e39317e33988 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 11:02:46 -0700 Subject: [PATCH 96/99] lic update --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index a0cd9434..0683a86c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Alexey +Copyright (c) 2019-2021 Alexey Karapetov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 8ce0779e9923434ff0bbcf07c05ba6df096fdc3c Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 11:09:30 -0700 Subject: [PATCH 97/99] bad export --- lib/http.dart | 4 +--- test/unit/client/client_test.dart | 4 ++-- {example/server => test/unit/client}/mock_handler.dart | 0 {example/server => test/unit/client}/response.dart | 0 4 files changed, 3 insertions(+), 5 deletions(-) rename {example/server => test/unit/client}/mock_handler.dart (100%) rename {example/server => test/unit/client}/response.dart (100%) diff --git a/lib/http.dart b/lib/http.dart index c94df08b..724805be 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,4 +1,4 @@ -/// This is a thin HTTP layer abstraction used by the client +/// This is a thin HTTP layer abstraction used by the client and the server library http; export 'package:json_api/src/http/http_handler.dart'; @@ -10,5 +10,3 @@ export 'package:json_api/src/http/logging_handler.dart'; export 'package:json_api/src/http/media_type.dart'; export 'package:json_api/src/http/payload_codec.dart'; export 'package:json_api/src/http/status_code.dart'; - -export 'file:///home/f3ath/project/json-api-dart/example/server/cors_handler.dart'; diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart index a7c9956d..eb27eb31 100644 --- a/test/unit/client/client_test.dart +++ b/test/unit/client/client_test.dart @@ -5,8 +5,8 @@ import 'package:json_api/document.dart'; import 'package:json_api/routing.dart'; import 'package:test/test.dart'; -import '../../../example/server/mock_handler.dart'; -import '../../../example/server/response.dart' as mock; +import 'mock_handler.dart'; +import 'response.dart' as mock; void main() { final http = MockHandler(); diff --git a/example/server/mock_handler.dart b/test/unit/client/mock_handler.dart similarity index 100% rename from example/server/mock_handler.dart rename to test/unit/client/mock_handler.dart diff --git a/example/server/response.dart b/test/unit/client/response.dart similarity index 100% rename from example/server/response.dart rename to test/unit/client/response.dart From e7c1e1c065bc3f85a7f11923b7fa91c89dc83ab2 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 11:13:09 -0700 Subject: [PATCH 98/99] bad export --- lib/src/query/filter.dart | 2 +- lib/src/query/page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart index ed8d8a46..85852a41 100644 --- a/lib/src/query/filter.dart +++ b/lib/src/query/filter.dart @@ -23,7 +23,7 @@ class Filter with MapMixin { /// Converts to a map of query parameters Map get asQueryParameters => - _.map((k, v) => MapEntry('filter[${k}]', v)); + _.map((k, v) => MapEntry('filter[$k]', v)); @override String? operator [](Object? key) => _[key]; diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index a5cf2f88..301b0741 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -25,7 +25,7 @@ class Page with MapMixin { /// Converts to a map of query parameters Map get asQueryParameters => - _.map((k, v) => MapEntry('page[${k}]', v)); + _.map((k, v) => MapEntry('page[$k]', v)); @override String? operator [](Object? key) => _[key]; From bd6a678625b4b710b794311e354c0826d5096f94 Mon Sep 17 00:00:00 2001 From: f3ath Date: Sat, 17 Apr 2021 11:17:11 -0700 Subject: [PATCH 99/99] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1668fe2c..bd152942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [5.0.0] - 2021-04-15 +## [Unreleased] ### Added - Sound null-safety support. @@ -191,7 +191,7 @@ Most of the changes are **BC-BREAKING**. ### Added - Client: fetch resources, collections, related resources and relationships -[5.0.0]: https://github.com/f3ath/json-api-dart/compare/4.3.0..5.0.0 +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/4.3.0..HEAD [4.3.0]: https://github.com/f3ath/json-api-dart/compare/4.2.2...4.3.0 [4.2.2]: https://github.com/f3ath/json-api-dart/compare/4.2.1...4.2.2 [4.2.1]: https://github.com/f3ath/json-api-dart/compare/4.2.0...4.2.1