From ccba4a553aeb544934487c3ee9950a72df29aa4c Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Fri, 1 Mar 2019 18:41:25 -0800 Subject: [PATCH 1/4] WIP --- example/cars_server.dart | 25 +++-- example/cars_server/controller.dart | 15 ++- example/cars_server/dao.dart | 50 +++++++++- example/cars_server/model.dart | 17 +++- lib/client.dart | 2 +- lib/{src => }/document.dart | 0 lib/src/client/client.dart | 41 ++++---- lib/src/server/json_api_controller.dart | 3 + lib/src/server/request.dart | 5 +- lib/src/server/resource_controller.dart | 25 ++++- lib/src/server/router.dart | 2 + lib/src/server/server.dart | 31 +++++- ...ient_create_test.dart => create_test.dart} | 0 ...ient_delete_test.dart => delete_test.dart} | 0 ...client_fetch_test.dart => fetch_test.dart} | 0 test/functional/update_test.dart | 95 +++++++++++++++++++ 16 files changed, 261 insertions(+), 50 deletions(-) rename lib/{src => }/document.dart (100%) rename test/functional/{client_create_test.dart => create_test.dart} (100%) rename test/functional/{client_delete_test.dart => delete_test.dart} (100%) rename test/functional/{client_fetch_test.dart => fetch_test.dart} (100%) create mode 100644 test/functional/update_test.dart diff --git a/example/cars_server.dart b/example/cars_server.dart index e187666..16b190c 100644 --- a/example/cars_server.dart +++ b/example/cars_server.dart @@ -16,24 +16,29 @@ void main() async { SimpleServer createServer() { final models = ModelDAO(); [ - Model('1', 'Roadster'), - Model('2', 'Model S'), - Model('3', 'Model X'), - Model('4', 'Model 3'), + Model('1')..name = 'Roadster', + Model('2')..name = 'Model S', + Model('3')..name = 'Model X', + Model('4')..name = 'Model 3', ].forEach(models.insert); final cities = CityDAO(); [ - City('1', 'Munich'), - City('2', 'Palo Alto'), - City('3', 'Ingolstadt'), + City('1')..name = 'Munich', + City('2')..name = 'Palo Alto', + City('3')..name = 'Ingolstadt', ].forEach(cities.insert); final companies = CompanyDAO(); [ - Company('1', 'Tesla', headquarters: '2', models: ['1', '2', '3', '4']), - Company('2', 'BMW', headquarters: '1'), - Company('3', 'Audi'), + Company('1') + ..name = 'Tesla' + ..headquarters = '2' + ..models.addAll(['1', '2', '3', '4']), + Company('2') + ..name = 'BMW' + ..headquarters = '1', + Company('3')..name = 'Audi', ].forEach(companies.insert); return SimpleServer(CarsController( diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart index b3c285a..bd944e1 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -17,15 +17,15 @@ class CarsController implements ResourceController { @override bool supports(String type) => dao.containsKey(type); - Future> fetchCollection( + Future>> fetchCollection( String type, JsonApiHttpRequest request) async { final page = NumberedPage.fromQueryParameters(request.uri.queryParameters, total: dao[type].length); - return Collection( + return OperationResult.ok(Collection( dao[type] .fetchCollection(offset: page.number - 1) .map(dao[type].toResource), - page: page); + page: page)); } @override @@ -71,4 +71,13 @@ class CarsController implements ResourceController { } return null; } + + @override + Future updateResource(String type, String id, Resource resource, + JsonApiHttpRequest request) async { + if (dao[type].fetchById(id) == null) { + throw ResourceControllerException(404, detail: 'Resource not found'); + } + return dao[type].update(id, resource); + } } diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart index 0b6388d..4ba25b0 100644 --- a/example/cars_server/dao.dart +++ b/example/cars_server/dao.dart @@ -24,6 +24,10 @@ abstract class DAO { _collection.remove(id); return 0; } + + Resource update(String id, Resource resource) { + throw UnimplementedError(); + } } class ModelDAO extends DAO { @@ -33,8 +37,16 @@ class ModelDAO extends DAO { void insert(Model model) => _collection[model.id] = model; Model create(Resource r) { - return Model(r.id, r.attributes['name']); + 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 { @@ -44,14 +56,16 @@ class CityDAO extends DAO { void insert(City city) => _collection[city.id] = city; City create(Resource r) { - return City(r.id, r.attributes['name']); + return City(r.id)..name = r.attributes['name']; } } class CompanyDAO extends DAO { Resource toResource(Company company) => Resource('companies', company.id, attributes: { - 'name': company.name + 'name': company.name, + 'nasdaq': company.nasdaq, + 'updatedAt': company.updatedAt.toIso8601String() }, toOne: { 'hq': company.headquarters == null ? null @@ -60,10 +74,15 @@ class CompanyDAO extends DAO { 'models': company.models.map((_) => Identifier('models', _)).toList() }); - void insert(Company company) => _collection[company.id] = company; + void insert(Company company) { + company.updatedAt = DateTime.now(); + _collection[company.id] = company; + } Company create(Resource r) { - return Company(r.id, r.attributes['name']); + return Company(r.id) + ..name = r.attributes['name'] + ..updatedAt = DateTime.now(); } @override @@ -74,4 +93,25 @@ class CompanyDAO extends DAO { _collection.remove(id); return deps; } + + @override + Resource update(String id, Resource resource) { + // TODO: What is 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); + } } diff --git a/example/cars_server/model.dart b/example/cars_server/model.dart index f85a1eb..6a6595b 100644 --- a/example/cars_server/model.dart +++ b/example/cars_server/model.dart @@ -1,22 +1,29 @@ class Company { final String id; - final String headquarters; - final List models; + String headquarters; + final models = []; + + /// Company name String name; - Company(this.id, this.name, {this.headquarters, this.models = const []}); + /// NASDAQ symbol + String nasdaq; + + DateTime updatedAt = DateTime.now(); + + Company(this.id); } class City { final String id; String name; - City(this.id, this.name); + City(this.id); } class Model { final String id; String name; - Model(this.id, this.name); + Model(this.id); } diff --git a/lib/client.dart b/lib/client.dart index 15eed33..ef87521 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,2 +1,2 @@ export 'package:json_api/src/client/client.dart'; -export 'package:json_api/src/document.dart'; +export 'package:json_api/document.dart'; diff --git a/lib/src/document.dart b/lib/document.dart similarity index 100% rename from lib/src/document.dart rename to lib/document.dart diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 88daf82..30986a9 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -76,15 +76,14 @@ class JsonApiClient { // {Map headers}) => // _post(ToMany.fromJson, uri, // ToMany(identifiers.map(IdentifierObject.fromIdentifier)), headers); -// -// Future> updateResource(Uri uri, Resource resource, -// {Map headers}) async => -// _patch( -// ResourceDocument.fromJson, -// uri, -// ResourceDocument(ResourceObject(resource.type, resource.id, -// attributes: resource.attributes)), -// headers); + + /// Updates the resource via PATH request. + /// + /// https://jsonapi.org/format/#crud-updating + Future> updateResource(Uri uri, Resource resource, + {Map headers}) async => + _patch(ResourceDocument.fromJson, uri, + ResourceDocument(ResourceObject.fromResource(resource)), headers); Future> _get( ResponseParser parse, uri, Map headers) => @@ -117,18 +116,18 @@ class JsonApiClient { 'Content-Type': contentType, }))); -// Future> _patch(ResponseParser parse, uri, -// Document document, Map headers) => -// _call( -// parse, -// (_) => _.patch(uri, -// body: json.encode(document), -// headers: {} -// ..addAll(headers ?? {}) -// ..addAll({ -// 'Accept': contentType, -// 'Content-Type': contentType, -// }))); + Future> _patch(ResponseParser parse, uri, + Document document, Map headers) => + _call( + parse, + (_) => _.patch(uri, + body: json.encode(document), + headers: {} + ..addAll(headers ?? {}) + ..addAll({ + 'Accept': contentType, + 'Content-Type': contentType, + }))); Future> _call(ResponseParser parse, Future fn(http.Client client)) async { diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index b5a63ff..bbb44d5 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -22,4 +22,7 @@ abstract class JsonApiController { Future deleteResource( String type, String id, JsonApiHttpRequest request); + + Future updateResource( + String type, String id, JsonApiHttpRequest request); } diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 6096256..031496a 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -enum HttpMethod { get, post, put, delete } +enum HttpMethod { get, post, put, delete, patch } abstract class JsonApiHttpRequest { HttpMethod get method; @@ -23,7 +23,8 @@ class NativeHttpRequestAdapter implements JsonApiHttpRequest { 'get': HttpMethod.get, 'post': HttpMethod.post, 'delete': HttpMethod.delete, - 'put': HttpMethod.put + 'put': HttpMethod.put, + 'patch': HttpMethod.patch, }[request.method.toLowerCase()]; Uri get uri => request.uri; diff --git a/lib/src/server/resource_controller.dart b/lib/src/server/resource_controller.dart index 26d0d84..7ef51e6 100644 --- a/lib/src/server/resource_controller.dart +++ b/lib/src/server/resource_controller.dart @@ -1,5 +1,6 @@ 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/page.dart'; @@ -21,7 +22,7 @@ abstract class ResourceController { /// Returns true if the resource type is supported by the controller bool supports(String type); - Future> fetchCollection( + Future>> fetchCollection( String type, JsonApiHttpRequest request); Stream fetchResources(Iterable ids); @@ -29,6 +30,9 @@ abstract class ResourceController { Future createResource( String type, Resource resource, JsonApiHttpRequest request); + Future updateResource( + String type, String id, Resource resource, JsonApiHttpRequest request); + /// This method should delete the resource specified by [type] and [id]. /// It may return metadata to be sent back as 200 OK response. /// If an empty map or null is returned, the server will respond with 204 No Content. @@ -36,6 +40,25 @@ abstract class ResourceController { String type, String id, JsonApiHttpRequest request); } +class OperationResult { + final T result; + final bool complete; + final errors = []; + final int httpStatus; + + bool get failed => !complete; + + OperationResult.ok(this.result) + : complete = true, + httpStatus = 200; + + OperationResult.fail(this.httpStatus, Iterable errors) + : complete = false, + result = null { + this.errors.addAll(errors); + } +} + class ResourceControllerException implements Exception { final int httpStatus; final String id; diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 71b56b6..8c98fd1 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -111,6 +111,8 @@ class ResourceRoute implements JsonApiRoute { return controller.fetchResource(type, id, request); case HttpMethod.delete: return controller.deleteResource(type, id, request); + case HttpMethod.patch: + return controller.updateResource(type, id, request); default: return Future.value(ServerResponse(405)); // TODO: meaningful error } diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 7a87cc4..791ac50 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:json_api/src/document.dart'; +import 'package:json_api/document.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/server/json_api_controller.dart'; import 'package:json_api/src/server/request.dart'; @@ -31,7 +31,14 @@ class JsonApiServer implements JsonApiController { Future fetchCollection( String type, JsonApiHttpRequest request) async { - final collection = await controller.fetchCollection(type, request); + final operation = await controller.fetchCollection(type, request); + + if (operation.failed) { + return ServerResponse( + operation.httpStatus, ErrorDocument(operation.errors)); + } + + final collection = operation.result; final pagination = Pagination.fromMap(collection.page .mapPages((_) => Link(router.collection(type, params: _?.parameters)))); @@ -146,6 +153,26 @@ class JsonApiServer implements JsonApiController { } } + Future updateResource( + String type, String id, JsonApiHttpRequest request) async { + try { + final resource = + ResourceDocument.fromJson(json.decode(await request.body())) + .resourceObject + .toResource(); + final updated = + await controller.updateResource(type, id, resource, request); + if (updated == null) { + return ServerResponse.noContent(); + } + return ServerResponse.ok( + ResourceDocument(ResourceObject.fromResource(updated))); + } on ResourceControllerException catch (e) { + return ServerResponse(e.httpStatus, + ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + } + } + Future _resource(String type, String id) => controller.fetchResources([Identifier(type, id)]).first; } diff --git a/test/functional/client_create_test.dart b/test/functional/create_test.dart similarity index 100% rename from test/functional/client_create_test.dart rename to test/functional/create_test.dart diff --git a/test/functional/client_delete_test.dart b/test/functional/delete_test.dart similarity index 100% rename from test/functional/client_delete_test.dart rename to test/functional/delete_test.dart diff --git a/test/functional/client_fetch_test.dart b/test/functional/fetch_test.dart similarity index 100% rename from test/functional/client_fetch_test.dart rename to test/functional/fetch_test.dart diff --git a/test/functional/update_test.dart b/test/functional/update_test.dart new file mode 100644 index 0000000..559784d --- /dev/null +++ b/test/functional/update_test.dart @@ -0,0 +1,95 @@ +@TestOn('vm') +import 'dart:io'; + +import 'package:json_api/client.dart'; +import 'package:json_api/src/server/simple_server.dart'; +import 'package:test/test.dart'; + +import '../../example/cars_server.dart'; + +/// Updating a Resource’s Attributes +/// ================================ +/// +/// Any or all of a resource’s attributes MAY be included +/// in the resource object included in a PATCH request. +/// +/// If a request 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 request. +/// +/// If a request 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 request, 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. +void main() async { + final client = JsonApiClient(); + SimpleServer s; + setUp(() async { + s = createServer(); + return await s.start(InternetAddress.loopbackIPv4, 8080); + }); + + tearDown(() => s.stop()); + + group('resource', () { + + /// If a server accepts an update but also changes the resource(s) + /// in ways other than those specified by the request (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 request was made to the request URL. + test('200 OK', () async { + final r0 = await client.fetchResource(Url.resource('companies', '1')); + final original = r0.document.resourceObject.toResource(); + + expect(original.attributes['name'], 'Tesla'); + expect(original.attributes['nasdaq'], isNull); + expect(original.toMany['models'].length, 4); + + original.attributes['nasdaq'] = 'TSLA'; + original.attributes.remove('name'); // Not changing this + original.toMany['models'].removeLast(); + original.toOne.clear(); // Not changing these + + final r1 = await client.updateResource(Url.resource('companies', '1'), original); + final updated = r1.document.resourceObject.toResource(); + + expect(r1.status, 200); + expect(updated.attributes['name'], 'Tesla'); + expect(updated.attributes['nasdaq'], 'TSLA'); + expect(updated.toMany['models'].length, 3); + }); + + test('204 No Content', () async { + final r0 = await client.fetchResource(Url.resource('models', '3')); + final original = r0.document.resourceObject.toResource(); + + expect(original.attributes['name'], 'Model X'); + + original.attributes['name'] = 'Model XXX'; + + final r1 = await client.updateResource(Url.resource('models', '3'), original); + expect(r1.status, 204); + expect(r1.document, isNull); + + final r2 = await client.fetchResource(Url.resource('models', '3')); + + expect(r2.document.resourceObject.attributes['name'], 'Model XXX'); + }); + + }); +} From f50cbf65081576b8bc825ba40afe3069f2190ca1 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 3 Mar 2019 19:14:07 -0800 Subject: [PATCH 2/4] Update Resource --- example/cars_server/controller.dart | 11 +- lib/src/client/client.dart | 6 +- lib/src/server/resource_controller.dart | 21 +-- lib/src/server/router.dart | 30 ++--- lib/src/server/server.dart | 172 +++++++++--------------- test/functional/update_test.dart | 35 ++++- 6 files changed, 128 insertions(+), 147 deletions(-) diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart index bd944e1..afc5a6f 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -17,15 +17,15 @@ class CarsController implements ResourceController { @override bool supports(String type) => dao.containsKey(type); - Future>> fetchCollection( + Future> fetchCollection( String type, JsonApiHttpRequest request) async { final page = NumberedPage.fromQueryParameters(request.uri.queryParameters, total: dao[type].length); - return OperationResult.ok(Collection( + return Collection( dao[type] .fetchCollection(offset: page.number - 1) .map(dao[type].toResource), - page: page)); + page: page); } @override @@ -75,6 +75,11 @@ class CarsController implements ResourceController { @override Future updateResource(String type, String id, Resource resource, JsonApiHttpRequest request) async { + if (resource.type != type) { + throw ResourceControllerException(409, + title: 'Type mismatch', + detail: 'Resource type does not match the endpoint'); + } if (dao[type].fetchById(id) == null) { throw ResourceControllerException(404, detail: 'Resource not found'); } diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index 30986a9..6ef8ebf 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -61,12 +61,16 @@ class JsonApiClient { /// Creates a new resource. The resource will be added to a collection /// according to its type. + /// + /// https://jsonapi.org/format/#crud-creating Future> createResource(Uri uri, Resource resource, {Map headers}) => _post(ResourceDocument.fromJson, uri, ResourceDocument(ResourceObject.fromResource(resource)), headers); /// Deletes the resource. + /// + /// https://jsonapi.org/format/#crud-deleting Future> deleteResource(Uri uri, {Map headers}) => _delete(MetaDocument.fromJson, uri, headers); @@ -77,7 +81,7 @@ class JsonApiClient { // _post(ToMany.fromJson, uri, // ToMany(identifiers.map(IdentifierObject.fromIdentifier)), headers); - /// Updates the resource via PATH request. + /// Updates the resource via PATCH request. /// /// https://jsonapi.org/format/#crud-updating Future> updateResource(Uri uri, Resource resource, diff --git a/lib/src/server/resource_controller.dart b/lib/src/server/resource_controller.dart index 7ef51e6..83fa7ba 100644 --- a/lib/src/server/resource_controller.dart +++ b/lib/src/server/resource_controller.dart @@ -22,7 +22,7 @@ abstract class ResourceController { /// Returns true if the resource type is supported by the controller bool supports(String type); - Future>> fetchCollection( + Future> fetchCollection( String type, JsonApiHttpRequest request); Stream fetchResources(Iterable ids); @@ -40,25 +40,6 @@ abstract class ResourceController { String type, String id, JsonApiHttpRequest request); } -class OperationResult { - final T result; - final bool complete; - final errors = []; - final int httpStatus; - - bool get failed => !complete; - - OperationResult.ok(this.result) - : complete = true, - httpStatus = 200; - - OperationResult.fail(this.httpStatus, Iterable errors) - : complete = false, - result = null { - this.errors.addAll(errors); - } -} - class ResourceControllerException implements Exception { final int httpStatus; final String id; diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 8c98fd1..144324c 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -86,14 +86,14 @@ class CollectionRoute implements JsonApiRoute { CollectionRoute(this.type); Future call( - JsonApiController controller, JsonApiHttpRequest request) { + JsonApiController controller, JsonApiHttpRequest request) async { switch (request.method) { case HttpMethod.get: - return controller.fetchCollection(type, request); + return await controller.fetchCollection(type, request); case HttpMethod.post: - return controller.createResource(type, request); + return await controller.createResource(type, request); default: - return Future.value(ServerResponse(405)); // TODO: meaningful error + return ServerResponse(405); // TODO: meaningful error } } } @@ -105,16 +105,16 @@ class ResourceRoute implements JsonApiRoute { ResourceRoute(this.type, this.id); Future call( - JsonApiController controller, JsonApiHttpRequest request) { + JsonApiController controller, JsonApiHttpRequest request) async { switch (request.method) { case HttpMethod.get: - return controller.fetchResource(type, id, request); + return await controller.fetchResource(type, id, request); case HttpMethod.delete: - return controller.deleteResource(type, id, request); + return await controller.deleteResource(type, id, request); case HttpMethod.patch: - return controller.updateResource(type, id, request); + return await controller.updateResource(type, id, request); default: - return Future.value(ServerResponse(405)); // TODO: meaningful error + return ServerResponse(405); // TODO: meaningful error } } } @@ -127,12 +127,12 @@ class RelatedRoute implements JsonApiRoute { RelatedRoute(this.type, this.id, this.relationship); Future call( - JsonApiController controller, JsonApiHttpRequest request) { + JsonApiController controller, JsonApiHttpRequest request) async { switch (request.method) { case HttpMethod.get: - return controller.fetchRelated(type, id, relationship, request); + return await controller.fetchRelated(type, id, relationship, request); default: - return Future.value(ServerResponse(405)); // TODO: meaningful error + return ServerResponse(405); // TODO: meaningful error } } } @@ -145,12 +145,12 @@ class RelationshipRoute implements JsonApiRoute { RelationshipRoute(this.type, this.id, this.relationship); Future call( - JsonApiController controller, JsonApiHttpRequest request) { + JsonApiController controller, JsonApiHttpRequest request) async { switch (request.method) { case HttpMethod.get: - return controller.fetchRelationship(type, id, relationship, request); + return await controller.fetchRelationship(type, id, relationship, request); default: - return Future.value(ServerResponse(405)); // TODO: meaningful error + return ServerResponse(405); // TODO: meaningful error } } } diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 791ac50..d4133be 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -26,19 +26,17 @@ class JsonApiServer implements JsonApiController { return ServerResponse.notFound(ErrorDocument( [ErrorObject(status: '404', detail: 'Unknown resource type')])); } - return route.call(this, request); + try { + return await route.call(this, request); + } on ResourceControllerException catch (e) { + return ServerResponse(e.httpStatus, + ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + } } Future fetchCollection( String type, JsonApiHttpRequest request) async { - final operation = await controller.fetchCollection(type, request); - - if (operation.failed) { - return ServerResponse( - operation.httpStatus, ErrorDocument(operation.errors)); - } - - final collection = operation.result; + final collection = await controller.fetchCollection(type, request); final pagination = Pagination.fromMap(collection.page .mapPages((_) => Link(router.collection(type, params: _?.parameters)))); @@ -53,126 +51,90 @@ class JsonApiServer implements JsonApiController { Future fetchResource( String type, String id, JsonApiHttpRequest request) async { - try { - final res = await _resource(type, id); - return ServerResponse.ok( - ResourceDocument(nullable(ResourceObject.fromResource)(res))); - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); - } + final res = await controller.fetchResources([Identifier(type, id)]).first; + return ServerResponse.ok( + ResourceDocument(nullable(ResourceObject.fromResource)(res))); } Future fetchRelated(String type, String id, String relationship, JsonApiHttpRequest request) async { - try { - final res = await controller.fetchResources([Identifier(type, id)]).first; - - if (res.toOne.containsKey(relationship)) { - final id = res.toOne[relationship]; - // TODO check if id == null - final related = await controller.fetchResources([id]).first; - return ServerResponse.ok( - ResourceDocument(ResourceObject.fromResource(related))); - } - - if (res.toMany.containsKey(relationship)) { - final ids = res.toMany[relationship]; - final related = await controller.fetchResources(ids).toList(); - return ServerResponse.ok( - CollectionDocument(related.map(ResourceObject.fromResource))); - } - - return ServerResponse(404); - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + final res = await controller.fetchResources([Identifier(type, id)]).first; + + if (res.toOne.containsKey(relationship)) { + final id = res.toOne[relationship]; + // TODO check if id == null + final related = await controller.fetchResources([id]).first; + return ServerResponse.ok( + ResourceDocument(ResourceObject.fromResource(related))); } + + if (res.toMany.containsKey(relationship)) { + final ids = res.toMany[relationship]; + final related = await controller.fetchResources(ids).toList(); + return ServerResponse.ok( + CollectionDocument(related.map(ResourceObject.fromResource))); + } + + return ServerResponse(404); } Future fetchRelationship(String type, String id, String relationship, JsonApiHttpRequest request) async { - try { - final res = await _resource(type, id); - if (res.toOne.containsKey(relationship)) { - return ServerResponse.ok(ToOne( - nullable(IdentifierObject.fromIdentifier)(res.toOne[relationship]), - self: Link(router.relationship(res.type, res.id, relationship)), - related: Link(router.related(res.type, res.id, relationship)))); - } - if (res.toMany.containsKey(relationship)) { - return ServerResponse.ok(ToMany( - res.toMany[relationship].map(IdentifierObject.fromIdentifier), - self: Link(router.relationship(res.type, res.id, relationship)), - related: Link(router.related(res.type, res.id, relationship)))); - } - return ServerResponse(404); - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + final res = await controller.fetchResources([Identifier(type, id)]).first; + if (res.toOne.containsKey(relationship)) { + return ServerResponse.ok(ToOne( + nullable(IdentifierObject.fromIdentifier)(res.toOne[relationship]), + self: Link(router.relationship(res.type, res.id, relationship)), + related: Link(router.related(res.type, res.id, relationship)))); } + if (res.toMany.containsKey(relationship)) { + return ServerResponse.ok(ToMany( + res.toMany[relationship].map(IdentifierObject.fromIdentifier), + self: Link(router.relationship(res.type, res.id, relationship)), + related: Link(router.related(res.type, res.id, relationship)))); + } + return ServerResponse(404); } Future createResource( String type, JsonApiHttpRequest request) async { - try { - final requestedResource = - ResourceDocument.fromJson(json.decode(await request.body())) - .resourceObject - .toResource(); - final createdResource = - await controller.createResource(type, requestedResource, request); - - if (requestedResource.hasId) { - return ServerResponse.noContent(); - } else { - return ServerResponse.created( - ResourceDocument(ResourceObject.fromResource(createdResource))) - ..headers['Location'] = router - .resource(createdResource.type, createdResource.id) - .toString(); - } - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + final requestedResource = + ResourceDocument.fromJson(json.decode(await request.body())) + .resourceObject + .toResource(); + final createdResource = + await controller.createResource(type, requestedResource, request); + + if (requestedResource.hasId) { + return ServerResponse.noContent(); } + return ServerResponse.created( + ResourceDocument(ResourceObject.fromResource(createdResource))) + ..headers['Location'] = + router.resource(createdResource.type, createdResource.id).toString(); } Future deleteResource( String type, String id, JsonApiHttpRequest request) async { - try { - final meta = await controller.deleteResource(type, id, request); - if (meta?.isNotEmpty == true) { - return ServerResponse.ok(MetaDocument(meta)); - } else { - return ServerResponse.noContent(); - } - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + final meta = await controller.deleteResource(type, id, request); + if (meta?.isNotEmpty == true) { + return ServerResponse.ok(MetaDocument(meta)); } + return ServerResponse.noContent(); } Future updateResource( String type, String id, JsonApiHttpRequest request) async { - try { - final resource = - ResourceDocument.fromJson(json.decode(await request.body())) - .resourceObject - .toResource(); - final updated = - await controller.updateResource(type, id, resource, request); - if (updated == null) { - return ServerResponse.noContent(); - } - return ServerResponse.ok( - ResourceDocument(ResourceObject.fromResource(updated))); - } on ResourceControllerException catch (e) { - return ServerResponse(e.httpStatus, - ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + final resource = + ResourceDocument.fromJson(json.decode(await request.body())) + .resourceObject + .toResource(); + final updated = + await controller.updateResource(type, id, resource, request); + if (updated == null) { + return ServerResponse.noContent(); } + return ServerResponse.ok( + ResourceDocument(ResourceObject.fromResource(updated))); } - - Future _resource(String type, String id) => - controller.fetchResources([Identifier(type, id)]).first; } diff --git a/test/functional/update_test.dart b/test/functional/update_test.dart index 559784d..1574a1b 100644 --- a/test/functional/update_test.dart +++ b/test/functional/update_test.dart @@ -44,7 +44,6 @@ void main() async { tearDown(() => s.stop()); group('resource', () { - /// If a server accepts an update but also changes the resource(s) /// in ways other than those specified by the request (for example, /// updating the updated-at attribute or a computed sha), @@ -52,6 +51,8 @@ void main() async { /// /// The response document MUST include a representation of the /// updated resource(s) as if a GET request was made to the request URL. + /// + /// 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.resourceObject.toResource(); @@ -65,7 +66,8 @@ void main() async { original.toMany['models'].removeLast(); original.toOne.clear(); // Not changing these - final r1 = await client.updateResource(Url.resource('companies', '1'), original); + final r1 = + await client.updateResource(Url.resource('companies', '1'), original); final updated = r1.document.resourceObject.toResource(); expect(r1.status, 200); @@ -74,6 +76,12 @@ void main() async { expect(updated.toMany['models'].length, 3); }); + /// 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.resourceObject.toResource(); @@ -82,7 +90,8 @@ void main() async { original.attributes['name'] = 'Model XXX'; - final r1 = await client.updateResource(Url.resource('models', '3'), original); + final r1 = + await client.updateResource(Url.resource('models', '3'), original); expect(r1.status, 204); expect(r1.document, isNull); @@ -91,5 +100,25 @@ void main() async { expect(r2.document.resourceObject.attributes['name'], 'Model XXX'); }); + /// A server MAY return 409 Conflict when processing a PATCH request + /// 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 request + /// 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.resourceObject.toResource(); + + final r1 = + await client.updateResource(Url.resource('companies', '1'), original); + expect(r1.status, 409); + expect(r1.document, isNull); + expect(r1.errorDocument.errors.first.detail, + 'Resource type does not match the endpoint'); + }); }); } From da9e50aaf8e2c7d1e8f6af47357582b41fe5ada4 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 3 Mar 2019 19:15:51 -0800 Subject: [PATCH 3/4] Update Resource --- CHANGELOG.md | 3 +++ README.md | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dccbefb..b628a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Resource attributes update + ## [0.2.0] - 2019-03-01 ### Added - Improved ResourceController error handling diff --git a/README.md b/README.md index 7ac7e43..5eba407 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ - [x] Fetching single resources - [x] Creating resources - [x] Deleting resources -- [ ] Updating resource's attributes -- [ ] Updating resource's relationships +- [x] Updating resource's attributes +- [x] Updating resource's relationships - [ ] Updating relationships - [ ] Asynchronous processing - [ ] Optional check for `Content-Type` header in incoming responses @@ -19,8 +19,8 @@ - [x] Fetching single resources - [x] Creating resources - [x] Deleting resources -- [ ] Updating resource's attributes -- [ ] Updating resource's relationships +- [x] Updating resource's attributes +- [x] Updating resource's relationships - [ ] Updating relationships - [ ] Inclusion of related resources - [ ] Sparse fieldsets From 1ce39952b15c7b012f137bcb3be54ee769634ca4 Mon Sep 17 00:00:00 2001 From: Alexey Karapetov Date: Sun, 3 Mar 2019 19:18:51 -0800 Subject: [PATCH 4/4] Update Resource --- example/cars_server/dao.dart | 2 -- lib/client.dart | 2 +- lib/src/server/router.dart | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart index 4ba25b0..52a0fe4 100644 --- a/example/cars_server/dao.dart +++ b/example/cars_server/dao.dart @@ -45,8 +45,6 @@ class ModelDAO extends DAO { _collection[id].name = resource.attributes['name']; return null; } - - } class CityDAO extends DAO { diff --git a/lib/client.dart b/lib/client.dart index ef87521..4c2a0fb 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,2 +1,2 @@ -export 'package:json_api/src/client/client.dart'; export 'package:json_api/document.dart'; +export 'package:json_api/src/client/client.dart'; diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 144324c..038a332 100644 --- a/lib/src/server/router.dart +++ b/lib/src/server/router.dart @@ -148,7 +148,8 @@ class RelationshipRoute implements JsonApiRoute { JsonApiController controller, JsonApiHttpRequest request) async { switch (request.method) { case HttpMethod.get: - return await controller.fetchRelationship(type, id, relationship, request); + return await controller.fetchRelationship( + type, id, relationship, request); default: return ServerResponse(405); // TODO: meaningful error }