diff --git a/CHANGELOG.md b/CHANGELOG.md index 85688fb..5beac9a 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 +- Improved ResourceController error handling +- Resource creation ## 0.1.0 - 2019-02-27 ### Added diff --git a/README.md b/README.md index 631f2ca..91e9fdf 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,19 @@ - [x] Fetching single resources and resource collections - [x] Fetching relationships and related resources and collections - [x] Fetching single resources -- [ ] Creating resources +- [x] Creating resources - [ ] Updating resource's attributes - [ ] Updating resource's relationships - [ ] Updating relationships - [ ] Deleting resources - [ ] Asynchronous processing +- [ ] Optional check for `Content-Type` header in incoming responses ##### Server (The Server API is not stable yet!) - [x] Fetching single resources and resource collections - [x] Fetching relationships and related resources and collections - [x] Fetching single resources -- [ ] Creating resources +- [x] Creating resources - [ ] Updating resource's attributes - [ ] Updating resource's relationships - [ ] Updating relationships @@ -25,10 +26,13 @@ - [ ] Sparse fieldsets - [ ] Sorting, pagination, filtering - [ ] Asynchronous processing +- [ ] Optional check for `Content-Type` header in incoming requests +- [ ] Support annotations in resource mappers (?) ##### Document - [ ] Support `meta` and `jsonapi` members -- [ ] Structure Validation +- [ ] Structure Validation including compound documents +- [ ] Support relationship objects lacking the `data` member - [ ] Naming Validation - [ ] JSON:API v1.1 features @@ -48,4 +52,4 @@ import 'package:http/browser_client.dart'; final client = JsonApiClient(factory: () => BrowserClient()); ``` -For usage examples see a corresponding test in `test/functional`. \ No newline at end of file +For usage examples see the [functional tests](https://github.com/f3ath/json-api-dart/tree/master/test/functional). \ No newline at end of file diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..ba1bd59 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1 @@ +concurrency: 1 \ No newline at end of file diff --git a/example/README.md b/example/README.md index b22aa85..1d99ee6 100644 --- a/example/README.md +++ b/example/README.md @@ -1,11 +1,11 @@ # JSON:API examples ## [Cars Server](./cars_server) -This is a simple JSON:API server which is used in the tests. It provides an API to a collection to car brands ad models. +This is a simple JSON:API server which is used in the tests. It provides an API to a collection to car companies ad 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. -- Open http://localhost:8080/brands in the browser. +- Open http://localhost:8080/companies in the browser. **Warning: Server API is not stable yet!** diff --git a/example/cars_client.dart b/example/cars_client.dart index 4a741b9..5a2df0b 100644 --- a/example/cars_client.dart +++ b/example/cars_client.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:json_api/client.dart'; -import 'package:json_api/src/transport/collection_document.dart'; -import 'package:json_api/src/transport/relationship.dart'; -import 'package:json_api/src/transport/resource_object.dart'; +import 'package:json_api/src/document/collection_document.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource_object.dart'; class CarsClient { final JsonApiClient c; diff --git a/example/cars_server.dart b/example/cars_server.dart index 6dcec2a..e187666 100644 --- a/example/cars_server.dart +++ b/example/cars_server.dart @@ -14,13 +14,13 @@ void main() async { } SimpleServer createServer() { - final cars = CarDAO(); + final models = ModelDAO(); [ - Car('1', 'Roadster'), - Car('2', 'Model S'), - Car('3', 'Model X'), - Car('4', 'Model 3'), - ].forEach(cars.insert); + Model('1', 'Roadster'), + Model('2', 'Model S'), + Model('3', 'Model X'), + Model('4', 'Model 3'), + ].forEach(models.insert); final cities = CityDAO(); [ @@ -29,15 +29,27 @@ SimpleServer createServer() { City('3', 'Ingolstadt'), ].forEach(cities.insert); - final brands = BrandDAO(); + final companies = CompanyDAO(); [ - Brand('1', 'Tesla', headquarters: '2', models: ['1', '2', '3', '4']), - Brand('2', 'BMW', headquarters: '1'), - Brand('3', 'Audi', headquarters: '3'), - Brand('4', 'Ford'), - Brand('5', 'Toyota') - ].forEach(brands.insert); - - return SimpleServer( - CarsController({'brands': brands, 'cities': cities, 'cars': cars})); + Company('1', 'Tesla', headquarters: '2', models: ['1', '2', '3', '4']), + Company('2', 'BMW', headquarters: '1'), + Company('3', 'Audi'), + ].forEach(companies.insert); + + return SimpleServer(CarsController( + {'companies': companies, 'cities': cities, 'models': models})); +} + +class Url { + static final _base = Uri.parse('http://localhost:8080'); + + static collection(String type) => _base.replace(path: '/$type'); + + static resource(String type, String id) => _base.replace(path: '/$type/$id'); + + static related(String type, String id, String rel) => + _base.replace(path: '/$type/$id/$rel'); + + static relationship(String type, String id, String rel) => + _base.replace(path: '/$type/$id/relationships/$rel'); } diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart index 73772b6..0116958 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -4,6 +4,7 @@ import 'package:json_api/src/identifier.dart'; import 'package:json_api/src/resource.dart'; import 'package:json_api/src/server/numbered_page.dart'; import 'package:json_api/src/server/resource_controller.dart'; +import 'package:uuid/uuid.dart'; import 'dao.dart'; @@ -30,7 +31,30 @@ class CarsController implements ResourceController { Stream fetchResources(Iterable ids) async* { for (final id in ids) { final obj = dao[id.type].fetchById(id.id); - yield obj == null ? null : dao[id.type].toResource(obj); + if (obj == null) { + throw ResourceControllerException(404, detail: 'Resource not found'); + } + yield dao[id.type].toResource(obj); } } + + @override + Future createResource( + String type, Resource resource, Map params) async { + if (type != resource.type) { + throw ResourceControllerException(409, detail: 'Incompatible type'); + } + Object obj; + if (resource.hasId) { + if (dao[type].fetchById(resource.id) != null) { + throw ResourceControllerException(409, + detail: 'Resource already exists'); + } + obj = dao[type].create(resource); + } else { + obj = dao[type].create(resource.replace(id: Uuid().v4())); + } + dao[type].insert(obj); + return dao[type].toResource(obj); + } } diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart index 1b5fac1..f8bdf67 100644 --- a/example/cars_server/dao.dart +++ b/example/cars_server/dao.dart @@ -10,6 +10,8 @@ abstract class DAO { Resource toResource(T t); + T create(Resource resource); + T fetchById(String id) => _collection[id]; void insert(T t); // => collection[t.id] = t; @@ -18,11 +20,15 @@ abstract class DAO { _collection.values.skip(offset).take(limit); } -class CarDAO extends DAO { - Resource toResource(Car _) => - Resource('cars', _.id, attributes: {'name': _.name}); +class ModelDAO extends DAO { + Resource toResource(Model _) => + Resource('models', _.id, attributes: {'name': _.name}); + + void insert(Model model) => _collection[model.id] = model; - void insert(Car car) => _collection[car.id] = car; + Model create(Resource r) { + return Model(r.id, r.attributes['name']); + } } class CityDAO extends DAO { @@ -30,18 +36,27 @@ class CityDAO extends DAO { Resource('cities', _.id, attributes: {'name': _.name}); void insert(City city) => _collection[city.id] = city; + + City create(Resource r) { + return City(r.id, r.attributes['name']); + } } -class BrandDAO extends DAO { - Resource toResource(Brand brand) => Resource('brands', brand.id, attributes: { - 'name': brand.name +class CompanyDAO extends DAO { + Resource toResource(Company company) => + Resource('companies', company.id, attributes: { + 'name': company.name }, toOne: { - 'hq': brand.headquarters == null + 'hq': company.headquarters == null ? null - : Identifier('cities', brand.headquarters) + : Identifier('cities', company.headquarters) }, toMany: { - 'models': brand.models.map((_) => Identifier('cars', _)).toList() + 'models': company.models.map((_) => Identifier('models', _)).toList() }); - void insert(Brand brand) => _collection[brand.id] = brand; + void insert(Company company) => _collection[company.id] = company; + + Company create(Resource r) { + return Company(r.id, r.attributes['name']); + } } diff --git a/example/cars_server/model.dart b/example/cars_server/model.dart index 017b52d..f85a1eb 100644 --- a/example/cars_server/model.dart +++ b/example/cars_server/model.dart @@ -1,10 +1,10 @@ -class Brand { +class Company { final String id; final String headquarters; final List models; String name; - Brand(this.id, this.name, {this.headquarters, this.models = const []}); + Company(this.id, this.name, {this.headquarters, this.models = const []}); } class City { @@ -14,9 +14,9 @@ class City { City(this.id, this.name); } -class Car { +class Model { final String id; String name; - Car(this.id, this.name); + Model(this.id, this.name); } diff --git a/lib/client.dart b/lib/client.dart index 157c5df..3d3e8f5 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1 +1,3 @@ export 'package:json_api/src/client/client.dart'; +export 'package:json_api/src/identifier.dart'; +export 'package:json_api/src/resource.dart'; diff --git a/lib/core.dart b/lib/core.dart deleted file mode 100644 index 0dbcb71..0000000 --- a/lib/core.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'package:json_api/src/identifier.dart'; -export 'package:json_api/src/resource.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index bab5fce..4128e82 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -4,12 +4,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/client/status_code.dart'; +import 'package:json_api/src/document/collection_document.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/error_document.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource_document.dart'; +import 'package:json_api/src/document/resource_object.dart'; import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/transport/collection_document.dart'; -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/error_document.dart'; -import 'package:json_api/src/transport/relationship.dart'; -import 'package:json_api/src/transport/resource_document.dart'; +import 'package:json_api/src/resource.dart'; typedef D ResponseParser(Object j); @@ -56,17 +58,13 @@ class JsonApiClient { {Map headers}) => _get(Relationship.fromJson, uri, headers); -// /// Creates a new resource. The resource will be added to a collection -// /// according to its type. -// Future> createResource(Uri uri, Resource resource, -// {Map headers}) => -// _post( -// ResourceDocument.fromJson, -// uri, -// ResourceDocument(ResourceObject(resource.type, resource.id, -// attributes: resource.attributes)), -// headers); -// + /// Creates a new resource. The resource will be added to a collection + /// according to its type. + Future> createResource(Uri uri, Resource resource, + {Map headers}) => + _post(ResourceDocument.fromJson, uri, + ResourceDocument(ResourceObject.fromResource(resource)), headers); + // /// Adds the [identifiers] to a to-many relationship identified by [uri] // Future> addToMany(Uri uri, Iterable identifiers, // {Map headers}) => @@ -91,19 +89,19 @@ class JsonApiClient { ..addAll(headers ?? {}) ..addAll({'Accept': contentType}))); -// Future> _post(ResponseParser parse, uri, -// Document document, Map headers) => -// _call( -// parse, -// (_) => _.post(uri, -// body: json.encode(document), -// headers: {} -// ..addAll(headers ?? {}) -// ..addAll({ -// 'Accept': contentType, -// 'Content-Type': contentType, -// }))); -// + Future> _post(ResponseParser parse, uri, + Document document, Map headers) => + _call( + parse, + (_) => _.post(uri, + body: json.encode(document), + headers: {} + ..addAll(headers ?? {}) + ..addAll({ + 'Accept': contentType, + 'Content-Type': contentType, + }))); + // Future> _patch(ResponseParser parse, uri, // Document document, Map headers) => // _call( diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 82789a5..09d7469 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,6 +1,6 @@ import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/error_document.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/error_document.dart'; /// A response returned by JSON:API cars_server class Response { @@ -43,4 +43,6 @@ class Response { /// 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; + + String get location => headers['location']; } diff --git a/lib/src/transport/collection_document.dart b/lib/src/document/collection_document.dart similarity index 88% rename from lib/src/transport/collection_document.dart rename to lib/src/document/collection_document.dart index 8ce293c..f571f9b 100644 --- a/lib/src/transport/collection_document.dart +++ b/lib/src/document/collection_document.dart @@ -1,6 +1,6 @@ -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/link.dart'; -import 'package:json_api/src/transport/resource_object.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_object.dart'; class CollectionDocument implements Document { final List collection; @@ -9,7 +9,7 @@ class CollectionDocument implements Document { final Link self; final Pagination pagination; - CollectionDocument(List collection, + CollectionDocument(Iterable collection, {List included, this.self, this.pagination}) : collection = List.unmodifiable(collection), included = List.unmodifiable(included ?? []); diff --git a/lib/src/transport/document.dart b/lib/src/document/document.dart similarity index 100% rename from lib/src/transport/document.dart rename to lib/src/document/document.dart diff --git a/lib/src/transport/error_document.dart b/lib/src/document/error_document.dart similarity index 81% rename from lib/src/transport/error_document.dart rename to lib/src/document/error_document.dart index 9615ebd..8d55091 100644 --- a/lib/src/transport/error_document.dart +++ b/lib/src/document/error_document.dart @@ -1,5 +1,5 @@ -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/error_object.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/error_object.dart'; class ErrorDocument implements Document { final errors = []; diff --git a/lib/src/transport/error_object.dart b/lib/src/document/error_object.dart similarity index 83% rename from lib/src/transport/error_object.dart rename to lib/src/document/error_object.dart index cc690cf..1865cd4 100644 --- a/lib/src/transport/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,4 +1,5 @@ -import 'package:json_api/src/transport/link.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/server/resource_controller.dart'; /// Error Object /// Error objects provide additional information about problems encountered while performing an operation. @@ -71,6 +72,17 @@ class ErrorObject { throw 'Can not parse ErrorObject from $json'; } + static ErrorObject fromResourceControllerException( + ResourceControllerException e) => + ErrorObject( + id: e.id, + status: e.httpStatus.toString(), + code: e.code, + title: e.title, + detail: e.detail, + sourceParameter: e.sourceParameter, + sourcePointer: e.sourcePointer); + toJson() { final json = {}; if (id != null) json['id'] = id; @@ -78,12 +90,12 @@ class ErrorObject { if (code != null) json['code'] = code; if (title != null) json['title'] = title; if (detail != null) json['detail'] = detail; - if (meta != null) json['meta'] = meta; + if (meta.isNotEmpty) json['meta'] = meta; if (about != null) json['links'] = {'about': about}; final source = Map(); if (sourcePointer != null) source['pointer'] = sourcePointer; if (sourceParameter != null) source['parameter'] = sourceParameter; - if (source.length > 0) json['source'] = source; + if (source.isNotEmpty) json['source'] = source; return json; } } diff --git a/lib/src/transport/identifier_object.dart b/lib/src/document/identifier_object.dart similarity index 100% rename from lib/src/transport/identifier_object.dart rename to lib/src/document/identifier_object.dart diff --git a/lib/src/transport/link.dart b/lib/src/document/link.dart similarity index 100% rename from lib/src/transport/link.dart rename to lib/src/document/link.dart diff --git a/lib/src/transport/relationship.dart b/lib/src/document/relationship.dart similarity index 81% rename from lib/src/transport/relationship.dart rename to lib/src/document/relationship.dart index d34d5e1..a901512 100644 --- a/lib/src/transport/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,17 +1,22 @@ import 'dart:async'; import 'package:json_api/src/client/client.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/identifier_object.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_object.dart'; import 'package:json_api/src/identifier.dart'; import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/identifier_object.dart'; -import 'package:json_api/src/transport/link.dart'; -import 'package:json_api/src/transport/resource_object.dart'; +/// A relationship. Can be to-one or to-many. +/// +/// https://jsonapi.org/format/#document-resource-object-linkage abstract class Relationship implements Document { final Link self; final Link related; + bool get isEmpty; + Object get _data; Relationship._({this.self, this.related}); @@ -45,6 +50,9 @@ abstract class Relationship implements Document { } } +/// A to-many relationship +/// +/// https://jsonapi.org/format/#document-resource-object-linkage class ToMany extends Relationship { final List _data; @@ -76,14 +84,21 @@ class ToMany extends Relationship { if (response.isSuccessful) return response.document.collection; throw 'Error'; // TODO define exceptions } + + bool get isEmpty => collection.isEmpty; } +/// A to-one relationship +/// +/// https://jsonapi.org/format/#document-resource-object-linkage class ToOne extends Relationship { final IdentifierObject _data; ToOne(this._data, {Link self, Link related}) : super._(self: self, related: related); + get isEmpty => identifierObject == null; + static ToOne fromJson(Object json) { if (json is Map) { final links = Link.parseMap(json['links'] ?? {}); @@ -96,7 +111,7 @@ class ToOne extends Relationship { IdentifierObject get identifierObject => _data; - Identifier toIdentifier() => identifierObject.toIdentifier(); + Identifier toIdentifier() => identifierObject?.toIdentifier(); Future fetchRelated(JsonApiClient client) async { if (related == null) throw StateError('The "related" link is null'); diff --git a/lib/src/transport/resource_document.dart b/lib/src/document/resource_document.dart similarity index 84% rename from lib/src/transport/resource_document.dart rename to lib/src/document/resource_document.dart index 512a96f..78ea922 100644 --- a/lib/src/transport/resource_document.dart +++ b/lib/src/document/resource_document.dart @@ -1,6 +1,6 @@ -import 'package:json_api/src/transport/document.dart'; -import 'package:json_api/src/transport/link.dart'; -import 'package:json_api/src/transport/resource_object.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_object.dart'; class ResourceDocument implements Document { final ResourceObject resourceObject; diff --git a/lib/src/transport/resource_object.dart b/lib/src/document/resource_object.dart similarity index 91% rename from lib/src/transport/resource_object.dart rename to lib/src/document/resource_object.dart index 418bc57..5b0f0d1 100644 --- a/lib/src/transport/resource_object.dart +++ b/lib/src/document/resource_object.dart @@ -1,9 +1,9 @@ +import 'package:json_api/src/document/identifier_object.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; import 'package:json_api/src/identifier.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/resource.dart'; -import 'package:json_api/src/transport/identifier_object.dart'; -import 'package:json_api/src/transport/link.dart'; -import 'package:json_api/src/transport/relationship.dart'; /// Resource object class ResourceObject { @@ -37,7 +37,7 @@ class ResourceObject { attributes: attributes, toMany: toMany, toOne: toOne); } - static ResourceObject enclose(Resource r) { + static ResourceObject fromResource(Resource r) { final toOne = r.toOne.map((name, v) => MapEntry(name, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))); diff --git a/lib/src/resource.dart b/lib/src/resource.dart index 7f9fc8b..29a2691 100644 --- a/lib/src/resource.dart +++ b/lib/src/resource.dart @@ -20,6 +20,9 @@ class Resource { /// to-many relationships final toMany = >{}; + /// True if the Resource has a non-empty id + bool get hasId => id != null && id.isNotEmpty; + Resource(this.type, this.id, {Map attributes, Map toOne, @@ -29,4 +32,17 @@ class Resource { this.toOne.addAll(toOne ?? {}); this.toMany.addAll(toMany ?? {}); } + + /// Returns a new Resource instance with the given fields replaced. + /// Provided values must not be null. + Resource replace( + {String id, + String type, + 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); } diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart index fc51c0f..5486389 100644 --- a/lib/src/server/json_api_controller.dart +++ b/lib/src/server/json_api_controller.dart @@ -1,17 +1,17 @@ import 'dart:async'; +import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/response.dart'; /// JSON:API Controller abstract class JsonApiController { - Future fetchCollection( - String type, Map params); + Future fetchCollection(CollectionRequest rq); - Future fetchResource(String type, String id); + Future fetchResource(ResourceRequest rq); - Future fetchRelationship( - String type, String id, String relationship); + Future fetchRelationship(RelationshipRequest rq); - Future fetchRelated( - String type, String id, String relationship); + Future fetchRelated(RelatedRequest rq); + + Future createResource(CollectionRequest rq); } diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 9da3519..7af2b7b 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -22,9 +22,9 @@ class CollectionRequest implements JsonApiRequest { Future fulfill(JsonApiController controller) async { switch (method.toUpperCase()) { case 'GET': - return controller.fetchCollection(type, params); -// case 'POST': -// return controller.createResource(body); + return controller.fetchCollection(this); + case 'POST': + return controller.createResource(this); } return ServerResponse(405); // TODO: meaningful error } @@ -41,7 +41,7 @@ class ResourceRequest implements JsonApiRequest { Future fulfill(JsonApiController controller) async { switch (method.toUpperCase()) { case 'GET': - return controller.fetchResource(type, id); + return controller.fetchResource(this); // case 'PATCH': // return controller.updateResource(type, id, body); } @@ -60,7 +60,7 @@ class RelatedRequest implements JsonApiRequest { {this.params}); Future fulfill(JsonApiController controller) => - controller.fetchRelated(type, id, relationship); + controller.fetchRelated(this); } class RelationshipRequest implements JsonApiRequest { @@ -76,7 +76,7 @@ class RelationshipRequest implements JsonApiRequest { Future fulfill(JsonApiController controller) async { switch (method.toUpperCase()) { case 'GET': - return controller.fetchRelationship(type, id, relationship); + return controller.fetchRelationship(this); // case 'POST': // return controller.addToMany(type, id, relationship, body); } diff --git a/lib/src/server/resource_controller.dart b/lib/src/server/resource_controller.dart index 4933fb8..1303eb2 100644 --- a/lib/src/server/resource_controller.dart +++ b/lib/src/server/resource_controller.dart @@ -19,9 +19,30 @@ abstract class ResourceController { Stream fetchResources(Iterable ids); -// Future createResource(Resource resource); + Future createResource( + String type, Resource resource, Map params); // Future addToMany(Identifier id, String rel, Iterable ids); // Future updateResource(Identifier id, Resource resource); } + +class ResourceControllerException implements Exception { + final int httpStatus; + final String id; + final String code; + final String title; + final String detail; + final String sourcePointer; + final String sourceParameter; + + ResourceControllerException(this.httpStatus, + {this.id, + this.code, + this.sourceParameter, + this.sourcePointer, + this.detail, + this.title}) { + ArgumentError.checkNotNull(this.httpStatus); + } +} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 2b7c170..05e2d04 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,17 +1,22 @@ import 'dart:convert'; -import 'package:json_api/src/transport/error_document.dart'; -import 'package:json_api/src/transport/error_object.dart'; +import 'package:json_api/src/document/document.dart'; +import 'package:json_api/src/document/error_document.dart'; +import 'package:json_api/src/nullable.dart'; class ServerResponse { final String body; final int status; + final headers = {}; - ServerResponse(this.status, {this.body}); + ServerResponse(this.status, [Document document]) + : body = nullable(json.encode)(document); - ServerResponse.ok([Object doc]) - : this(200, body: doc != null ? json.encode(doc) : null); + ServerResponse.ok(Document document) : this(200, document); - ServerResponse.notFound({List errors = const []}) - : this(404, body: json.encode(ErrorDocument(errors))); + ServerResponse.notFound(ErrorDocument document) : this(404, document); + + ServerResponse.created(Document document) : this(201, document); + + ServerResponse.noContent() : this(204); } diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 4f6fee8..1791dd7 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -1,19 +1,22 @@ import 'dart:async'; - +import 'dart:convert'; + +import 'package:json_api/src/document/collection_document.dart'; +import 'package:json_api/src/document/error_document.dart'; +import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api/src/document/identifier_object.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource_document.dart'; +import 'package:json_api/src/document/resource_object.dart'; import 'package:json_api/src/identifier.dart'; import 'package:json_api/src/nullable.dart'; import 'package:json_api/src/resource.dart'; import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/request.dart'; import 'package:json_api/src/server/resource_controller.dart'; import 'package:json_api/src/server/response.dart'; import 'package:json_api/src/server/routing.dart'; -import 'package:json_api/src/transport/collection_document.dart'; -import 'package:json_api/src/transport/error_object.dart'; -import 'package:json_api/src/transport/identifier_object.dart'; -import 'package:json_api/src/transport/link.dart'; -import 'package:json_api/src/transport/relationship.dart'; -import 'package:json_api/src/transport/resource_document.dart'; -import 'package:json_api/src/transport/resource_object.dart'; class JsonApiServer implements JsonApiController { final ResourceController controller; @@ -25,82 +28,113 @@ class JsonApiServer implements JsonApiController { final jsonApiRequest = await routing.resolve(method, uri, body); if (jsonApiRequest == null) { return ServerResponse.notFound( - errors: [ErrorObject(status: '404', detail: 'Unknown route')]); + ErrorDocument([ErrorObject(status: '404', detail: 'Unknown route')])); } if (!controller.supports(jsonApiRequest.type)) { - return ServerResponse.notFound(errors: [ - ErrorObject(status: '404', detail: 'Unknown resource type') - ]); + return ServerResponse.notFound(ErrorDocument( + [ErrorObject(status: '404', detail: 'Unknown resource type')])); } return jsonApiRequest.fulfill(this); } - Future fetchCollection( - String type, Map params) async { - final collection = await controller.fetchCollection(type, params); + Future fetchCollection(CollectionRequest rq) async { + final collection = await controller.fetchCollection(rq.type, rq.params); final pagination = Pagination.fromMap(collection.page.mapPages( - (_) => Link(routing.collection(type, params: _?.parameters)))); + (_) => Link(routing.collection(rq.type, params: _?.parameters)))); - final doc = CollectionDocument(collection.elements.map(_enclose).toList(), - self: - Link(routing.collection(type, params: collection.page?.parameters)), + final doc = CollectionDocument( + collection.elements.map(ResourceObject.fromResource), + self: Link( + routing.collection(rq.type, params: collection.page?.parameters)), pagination: pagination); return ServerResponse.ok(doc); } - Future fetchResource(String type, String id) async { - final res = await _resource(type, id); - return ServerResponse.ok(ResourceDocument(nullable(_enclose)(res))); + Future fetchResource(ResourceRequest rq) async { + try { + final res = await _resource(rq.type, rq.id); + return ServerResponse.ok( + ResourceDocument(nullable(ResourceObject.fromResource)(res))); + } on ResourceControllerException catch (e) { + return ServerResponse(e.httpStatus, + ErrorDocument([ErrorObject.fromResourceControllerException(e)])); + } } - Future fetchRelated( - String type, String id, String relationship) async { - 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(_enclose(related))); + Future fetchRelated(RelatedRequest rq) async { + try { + final res = + await controller.fetchResources([Identifier(rq.type, rq.id)]).first; + + if (res.toOne.containsKey(rq.relationship)) { + final id = res.toOne[rq.relationship]; + // TODO check if id == null + final related = await controller.fetchResources([id]).first; + return ServerResponse.ok( + ResourceDocument(ResourceObject.fromResource(related))); + } + + if (res.toMany.containsKey(rq.relationship)) { + final ids = res.toMany[rq.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)])); } + } - if (res.toMany.containsKey(relationship)) { - final ids = res.toMany[relationship]; - final related = await controller.fetchResources(ids).toList(); - return ServerResponse.ok( - CollectionDocument(related.map(_enclose).toList())); + Future fetchRelationship(RelationshipRequest rq) async { + try { + final res = await _resource(rq.type, rq.id); + if (res.toOne.containsKey(rq.relationship)) { + return ServerResponse.ok(ToOne( + nullable(IdentifierObject.fromIdentifier)( + res.toOne[rq.relationship]), + self: Link(routing.relationship(res.type, res.id, rq.relationship)), + related: Link(routing.related(res.type, res.id, rq.relationship)))); + } + if (res.toMany.containsKey(rq.relationship)) { + return ServerResponse.ok(ToMany( + res.toMany[rq.relationship].map(IdentifierObject.fromIdentifier), + self: Link(routing.relationship(res.type, res.id, rq.relationship)), + related: Link(routing.related(res.type, res.id, rq.relationship)))); + } + return ServerResponse(404); + } on ResourceControllerException catch (e) { + return ServerResponse(e.httpStatus, + ErrorDocument([ErrorObject.fromResourceControllerException(e)])); } - - return ServerResponse.notFound(); } - Future fetchRelationship( - String type, String id, String relationship) async { - final res = await _resource(type, id); - if (res.toOne.containsKey(relationship)) { - return ServerResponse.ok(ToOne( - nullable(IdentifierObject.fromIdentifier)(res.toOne[relationship]), - self: Link(routing.relationship(res.type, res.id, relationship)), - related: Link(routing.related(res.type, res.id, relationship)))); + Future createResource(CollectionRequest rq) async { + try { + final requestedResource = ResourceDocument.fromJson(json.decode(rq.body)) + .resourceObject + .toResource(); + final createdResource = await controller.createResource( + rq.type, requestedResource, rq.params); + + if (requestedResource.hasId) { + return ServerResponse.noContent(); + } else { + return ServerResponse.created( + ResourceDocument(ResourceObject.fromResource(createdResource))) + ..headers['Location'] = routing + .resource(createdResource.type, createdResource.id) + .toString(); + } + } on ResourceControllerException catch (e) { + return ServerResponse(e.httpStatus, + ErrorDocument([ErrorObject.fromResourceControllerException(e)])); } - if (res.toMany.containsKey(relationship)) { - return ServerResponse.ok(ToMany( - res.toMany[relationship] - .map(IdentifierObject.fromIdentifier) - .toList(), - self: Link(routing.relationship(res.type, res.id, relationship)), - related: Link(routing.related(res.type, res.id, relationship)))); - } - return ServerResponse.notFound(); } -// Future createResource(String body) async { -// final doc = ResourceDocument.fromJson(json.decode(body)); -// await controller.createResource(doc.resourceEnvelope.toResource()); -// return ServerResponse(204); -// } -// // Future updateResource( // String type, String id, String body) async { // // TODO: check that [type] matcher [resource.type] @@ -128,24 +162,6 @@ class JsonApiServer implements JsonApiController { // return ServerResponse.notFound(); // } - ResourceObject _enclose(Resource r) { - final toOne = r.toOne.map((name, v) => MapEntry( - name, - ToOne(nullable(IdentifierObject.fromIdentifier)(v), - self: Link(routing.relationship(r.type, r.id, name)), - related: Link(routing.related(r.type, r.id, name))))); - - final toMany = r.toMany.map((name, v) => MapEntry( - name, - ToMany(v.map(nullable(IdentifierObject.fromIdentifier)).toList(), - self: Link(routing.relationship(r.type, r.id, name)), - related: Link(routing.related(r.type, r.id, name))))); - return ResourceObject(r.type, r.id, - attributes: r.attributes, - self: Link(routing.resource(r.type, r.id)), - relationships: {}..addAll(toOne)..addAll(toMany)); - } - Future _resource(String type, String id) => controller.fetchResources([Identifier(type, id)]).first; } diff --git a/lib/src/server/simple_server.dart b/lib/src/server/simple_server.dart index 5a5ac6c..e612d08 100644 --- a/lib/src/server/simple_server.dart +++ b/lib/src/server/simple_server.dart @@ -19,17 +19,18 @@ class SimpleServer { _httpServer = await HttpServer.bind(address, port); - _httpServer.forEach((rq) async { - final rs = await jsonApiServer.handle( - rq.method, rq.uri, await rq.transform(utf8.decoder).join()); - rq.response.statusCode = rs.status; - rq.response.headers.set('Access-Control-Allow-Origin', '*'); - if (rs.body != null) { - rq.response.write(rs.body); + _httpServer.forEach((request) async { + final serverResponse = await jsonApiServer.handle(request.method, + request.uri, await request.transform(utf8.decoder).join()); + request.response.statusCode = serverResponse.status; + serverResponse.headers.forEach(request.response.headers.set); + request.response.headers.set('Access-Control-Allow-Origin', '*'); + if (serverResponse.body != null) { + request.response.write(serverResponse.body); } - rq.response.close(); + await request.response.close(); }); } - Future stop() => _httpServer.close(); + Future stop() => _httpServer.close(force: true); } diff --git a/pubspec.yaml b/pubspec.yaml index e8b7bca..35c5ad3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,5 +10,6 @@ dev_dependencies: json_matcher: "^0.2.2" test: "^1.3.0" stream_channel: "^1.6.8" + uuid: "^2.0.0" environment: sdk: ">=2.0.0 <3.0.0" diff --git a/test/browser_compat_test.dart b/test/browser_compat_test.dart index ff6e5ed..a918d7e 100644 --- a/test/browser_compat_test.dart +++ b/test/browser_compat_test.dart @@ -10,7 +10,7 @@ void main() async { final port = await channel.stream.first; print('Port: $port'); final r = await client - .fetchCollection(Uri.parse('http://localhost:$port/brands')); + .fetchCollection(Uri.parse('http://localhost:$port/companies')); expect(r.status, 200); expect(r.isSuccessful, true); expect(r.document.collection.first.attributes['name'], 'Tesla'); diff --git a/test/functional/client_create_test.dart b/test/functional/client_create_test.dart new file mode 100644 index 0000000..7f72e00 --- /dev/null +++ b/test/functional/client_create_test.dart @@ -0,0 +1,98 @@ +@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'; + +void main() async { + final client = JsonApiClient(); + SimpleServer s; + setUp(() async { + s = createServer(); + return await s.start(InternetAddress.loopbackIPv4, 8080); + }); + + tearDown(() => s.stop()); + + group('resource', () { + /// https://jsonapi.org/format/#crud-creating-responses-201 + /// + /// If a POST request 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. + test('201 Created', () async { + final modelY = Resource('models', null, attributes: {'name': 'Model Y'}); + final r0 = await client.createResource(Url.collection('models'), modelY); + + expect(r0.status, 201); + expect(r0.isSuccessful, true); + expect(r0.document.resourceObject.id, isNotEmpty); + expect(r0.document.resourceObject.type, 'models'); + expect(r0.document.resourceObject.attributes['name'], 'Model Y'); + expect(r0.location, isNotEmpty); + + // Make sure the resource is available + final r1 = await client + .fetchResource(Url.resource('models', r0.document.resourceObject.id)); + expect(r1.document.resourceObject.attributes['name'], 'Model Y'); + }); + + /// https://jsonapi.org/format/#crud-creating-responses-204 + /// + /// If a POST request 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. + test('204 No Content', () async { + final modelY = Resource('models', '555', attributes: {'name': 'Model Y'}); + final r0 = await client.createResource(Url.collection('models'), modelY); + + 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('models', '555')); + expect(r1.document.resourceObject.attributes['name'], 'Model Y'); + }); + + /// https://jsonapi.org/format/#crud-creating-responses-409 + /// + /// A server MUST return 409 Conflict when processing a POST request to + /// create a resource with a client-generated ID that already exists. + test('409 Conflict - Resource already exists', () async { + final modelY = Resource('models', '1', attributes: {'name': 'Model Y'}); + final r0 = await client.createResource(Url.collection('models'), modelY); + + expect(r0.status, 409); + expect(r0.isSuccessful, false); + expect(r0.document, isNull); + expect(r0.errorDocument.errors.first.detail, 'Resource already exists'); + }); + + /// https://jsonapi.org/format/#crud-creating-responses-409 + /// + /// A server MUST return 409 Conflict when processing a POST request in + /// which the resource object’s type is not among the type(s) that + /// constitute the collection represented by the endpoint. + test('409 Conflict - Incompatible type', () async { + final modelY = Resource('models', '555', attributes: {'name': 'Model Y'}); + final r0 = + await client.createResource(Url.collection('companies'), modelY); + + expect(r0.status, 409); + expect(r0.isSuccessful, false); + expect(r0.document, isNull); + expect(r0.errorDocument.errors.first.detail, 'Incompatible type'); + }); + }); +} diff --git a/test/functional/client_fetch_test.dart b/test/functional/client_fetch_test.dart index c9810e6..d7f031f 100644 --- a/test/functional/client_fetch_test.dart +++ b/test/functional/client_fetch_test.dart @@ -3,116 +3,167 @@ import 'dart:io'; import 'package:json_api/client.dart'; import 'package:json_api/src/server/simple_server.dart'; -import 'package:json_api/src/transport/relationship.dart'; +import 'package:json_api/src/document/relationship.dart'; import 'package:test/test.dart'; import '../../example/cars_server.dart'; -void main() { - group('Fetch', () { - final client = JsonApiClient(); - SimpleServer s; - setUp(() async { - s = createServer(); - await s.start(InternetAddress.loopbackIPv4, 8080); - }); - - tearDown(() async { - await s.stop(); - }); - - final baseUri = Uri.parse('http://localhost:8080'); - - collection(String type) => baseUri.replace(path: '/$type'); - - resource(String type, String id) => baseUri.replace(path: '/$type/$id'); - - related(String type, String id, String rel) => - baseUri.replace(path: '/$type/$id/$rel'); - - relationship(String type, String id, String rel) => - baseUri.replace(path: '/$type/$id/relationships/$rel'); - - group('collection', () { - test('resource collection', () async { - final r = await client.fetchCollection(collection('brands')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.collection.first.attributes['name'], 'Tesla'); - expect(r.document.included, isEmpty); - }); - - test('related collection', () async { - final r = - await client.fetchCollection(related('brands', '1', 'models')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.collection.first.attributes['name'], 'Roadster'); - }); - - test('404', () async { - final r = await client.fetchCollection(collection('unicorns')); - expect(r.status, 404); - expect(r.isSuccessful, false); - expect(r.errorDocument.errors.first.detail, 'Unknown resource type'); - }); - }); - - group('single resource', () { - test('single resource', () async { - final r = await client.fetchResource(resource('cars', '1')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.resourceObject.attributes['name'], 'Roadster'); - }); - - test('related resource', () async { - final r = await client.fetchResource(related('brands', '1', 'hq')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.resourceObject.attributes['name'], 'Palo Alto'); - }); - - test('404', () async { - final r = await client.fetchResource(resource('unicorns', '1')); - expect(r.status, 404); - expect(r.isSuccessful, false); - }); - }); - - group('relationships', () { - test('to-one', () async { - final r = await client.fetchToOne(relationship('brands', '1', 'hq')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.toIdentifier().type, 'cities'); - }); - - test('generic to-one', () async { - final r = - await client.fetchRelationship(relationship('brands', '1', 'hq')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document, TypeMatcher()); - expect((r.document as ToOne).toIdentifier().type, 'cities'); - }); - - test('to-many', () async { - final r = - await client.fetchToMany(relationship('brands', '1', 'models')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document.toIdentifiers().first.type, 'cars'); - }); - - test('generic to-many', () async { - final r = await client - .fetchRelationship(relationship('brands', '1', 'models')); - expect(r.status, 200); - expect(r.isSuccessful, true); - expect(r.document, TypeMatcher()); - expect((r.document as ToMany).toIdentifiers().first.type, 'cars'); - }); +void main() async { + final client = JsonApiClient(); + SimpleServer s; + setUp(() async { + s = createServer(); + return await s.start(InternetAddress.loopbackIPv4, 8080); + }); + + tearDown(() => s.stop()); + + group('collection', () { + test('resource collection', () async { + final r = await client.fetchCollection(Url.collection('companies')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.collection.first.attributes['name'], 'Tesla'); + expect(r.document.included, isEmpty); + }); + + test('related collection', () async { + final r = + await client.fetchCollection(Url.related('companies', '1', 'models')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.collection.first.attributes['name'], 'Roadster'); + }); + + test('404', () async { + final r = await client.fetchCollection(Url.collection('unicorns')); + expect(r.status, 404); + expect(r.isSuccessful, false); + expect(r.errorDocument.errors.first.detail, 'Unknown resource type'); + }); + }); + + group('single resource', () { + test('single resource', () async { + final r = await client.fetchResource(Url.resource('models', '1')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.resourceObject.attributes['name'], 'Roadster'); + }); + + 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); + }); + }); + + group('related resource', () { + test('related resource', () async { + final r = await client.fetchResource(Url.related('companies', '1', 'hq')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.resourceObject.attributes['name'], 'Palo Alto'); + }); + + 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); + }); + }); + + group('relationships', () { + test('to-one', () async { + final r = + await client.fetchToOne(Url.relationship('companies', '1', 'hq')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.toIdentifier().type, 'cities'); + }); + + test('empty to-one', () async { + final r = + await client.fetchToOne(Url.relationship('companies', '3', 'hq')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.isEmpty, true); + expect(r.document.toIdentifier(), isNull); + }); + + test('generic to-one', () async { + final r = await client + .fetchRelationship(Url.relationship('companies', '1', 'hq')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document, TypeMatcher()); + expect((r.document as ToOne).toIdentifier().type, 'cities'); + }); + + test('to-many', () async { + final r = await client + .fetchToMany(Url.relationship('companies', '1', 'models')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.toIdentifiers().first.type, 'models'); + }); + + test('empty to-many', () async { + final r = await client + .fetchToMany(Url.relationship('companies', '3', 'models')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.isEmpty, true); + expect(r.document.toIdentifiers().isEmpty, true); + }); + + test('generic to-many', () async { + final r = await client + .fetchRelationship(Url.relationship('companies', '1', 'models')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document, TypeMatcher()); + expect((r.document as ToMany).toIdentifiers().first.type, 'models'); + }); + + test('404 on type', () async { + final r = await client + .fetchRelationship(Url.relationship('unicorns', '555', 'models')); + expect(r.status, 404); + expect(r.isSuccessful, false); + }); + + test('404 on id', () async { + final r = await client + .fetchRelationship(Url.relationship('companies', '555', 'models')); + expect(r.status, 404); + expect(r.isSuccessful, false); + }); + + test('404 on relationship', () async { + final r = await client + .fetchRelationship(Url.relationship('companies', '1', 'unicorn')); + expect(r.status, 404); + expect(r.isSuccessful, false); }); }); }