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 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..afc5a6f 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -71,4 +71,18 @@ class CarsController implements ResourceController { } return null; } + + @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'); + } + return dao[type].update(id, resource); + } } diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart index 0b6388d..52a0fe4 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,7 +37,13 @@ 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; } } @@ -44,14 +54,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 +72,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 +91,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..4c2a0fb 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,2 +1,2 @@ +export 'package:json_api/document.dart'; export 'package:json_api/src/client/client.dart'; -export 'package:json_api/src/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..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); @@ -76,15 +80,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 PATCH 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 +120,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..83fa7ba 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'; @@ -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. diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart index 71b56b6..038a332 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,14 +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 await controller.updateResource(type, id, request); default: - return Future.value(ServerResponse(405)); // TODO: meaningful error + return ServerResponse(405); // TODO: meaningful error } } } @@ -125,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 } } } @@ -143,12 +145,13 @@ 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 7a87cc4..d4133be 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'; @@ -26,7 +26,12 @@ 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( @@ -46,106 +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 _resource(String type, String id) => - controller.fetchResources([Identifier(type, id)]).first; + Future updateResource( + String type, String id, JsonApiHttpRequest request) async { + 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))); + } } 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..1574a1b --- /dev/null +++ b/test/functional/update_test.dart @@ -0,0 +1,124 @@ +@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. + /// + /// 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(); + + 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); + }); + + /// 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(); + + 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'); + }); + + /// 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'); + }); + }); +}