diff --git a/.travis.yml b/.travis.yml index 806f747..a9b2452 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ dart: - stable dart_task: - test: --platform vm -- test: --platform chrome --exclude-tags vm-only +- test: --platform chrome +- test: --platform firefox - dartfmt: true -- dartanalyzer: --fatal-infos --fatal-warnings lib test +- dartanalyzer: --fatal-infos --fatal-warnings lib test example diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5a8ab..85688fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ # Changelog All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) -and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +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] -## 0.0.1 - 2019-01-08 +## 0.1.0 - 2019-02-27 ### Added -- Initial client implementation +- Client: fetch resources, collections, related resources and relationships -[Unreleased]: https://github.com/f3ath/json-api-dart/compare/0.0.1...HEAD +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/0.1.0...HEAD diff --git a/README.md b/README.md index 8949e9b..631f2ca 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,51 @@ # Implementation of [JSON:API v1.0](http://jsonapi.org) in Dart -**This is a work in progress. The API may change.** +#### Feature roadmap +##### Client +- [x] Fetching single resources and resource collections +- [x] Fetching relationships and related resources and collections +- [x] Fetching single resources +- [ ] Creating resources +- [ ] Updating resource's attributes +- [ ] Updating resource's relationships +- [ ] Updating relationships +- [ ] Deleting resources +- [ ] Asynchronous processing -## General architecture +##### 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 +- [ ] Updating resource's attributes +- [ ] Updating resource's relationships +- [ ] Updating relationships +- [ ] Deleting resources +- [ ] Inclusion of related resources +- [ ] Sparse fieldsets +- [ ] Sorting, pagination, filtering +- [ ] Asynchronous processing -The library consists of three major parts: Document, Server, and Client. +##### Document +- [ ] Support `meta` and `jsonapi` members +- [ ] Structure Validation +- [ ] Naming Validation +- [ ] JSON:API v1.1 features -### Document -This is the core part. -It describes JSON:API Document and its components (e.g. Resource Objects, Identifiers, Relationships, Links), -validation rules (naming conventions, full linkage), and service discovery (e.g. fetching pages and related resources). +### Usage +In the VM: +```dart +import 'package:json_api/client.dart'; -### Client -This is a JSON:API client based on Dart's native HttpClient. +final client = JsonApiClient(); +``` -### Server -A JSON:API server. Routes requests, builds responses. \ No newline at end of file +In a browser: +```dart +import 'package:json_api/client.dart'; +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 diff --git a/dart_test.yaml b/dart_test.yaml deleted file mode 100644 index 6f95080..0000000 --- a/dart_test.yaml +++ /dev/null @@ -1,2 +0,0 @@ -tags: - vm-only: {test_on: "vm"} \ No newline at end of file diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..b22aa85 --- /dev/null +++ b/example/README.md @@ -0,0 +1,13 @@ +# 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. +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. + +**Warning: Server API is not stable yet!** + +## [Cars Client](./cars_client.dart) +A simple client for Cars Server API. It is also used in the tests. \ No newline at end of file diff --git a/example/cars_client.dart b/example/cars_client.dart new file mode 100644 index 0000000..4a741b9 --- /dev/null +++ b/example/cars_client.dart @@ -0,0 +1,35 @@ +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'; + +class CarsClient { + final JsonApiClient c; + final _base = Uri.parse('http://localhost:8080'); + + CarsClient(this.c); + + Future fetchCollection(String type) async { + final response = await c.fetchCollection(_base.replace(path: '/$type')); + return response.document; + } + + Future fetchToOne(String type, String id, String name) async { + final response = await c + .fetchToOne(_base.replace(path: '/$type/$id/relationships/$name')); + return response.document; + } + + Future fetchToMany(String type, String id, String name) async { + final response = await c + .fetchToMany(_base.replace(path: '/$type/$id/relationships/$name')); + return response.document; + } + + Future fetchResource(String type, String id) async { + final response = await c.fetchResource(_base.replace(path: '/$type/$id')); + return response.document.resourceObject; + } +} diff --git a/example/server/server.dart b/example/cars_server.dart similarity index 63% rename from example/server/server.dart rename to example/cars_server.dart index 8ca4396..6dcec2a 100644 --- a/example/server/server.dart +++ b/example/cars_server.dart @@ -1,8 +1,17 @@ -import 'package:json_api/simple_server.dart'; +import 'dart:io'; -import 'controller.dart'; -import 'dao.dart'; -import 'model.dart'; +import 'package:json_api/src/server/simple_server.dart'; + +import 'cars_server/controller.dart'; +import 'cars_server/dao.dart'; +import 'cars_server/model.dart'; + +void main() async { + final addr = InternetAddress.loopbackIPv4; + final port = 8080; + await createServer().start(addr, port); + print('Listening on ${addr.host}:$port'); +} SimpleServer createServer() { final cars = CarDAO(); @@ -29,8 +38,6 @@ SimpleServer createServer() { Brand('5', 'Toyota') ].forEach(brands.insert); - final controller = - CarsController({'brands': brands, 'cities': cities, 'cars': cars}); - - return SimpleServer(controller); + return SimpleServer( + CarsController({'brands': brands, 'cities': cities, 'cars': cars})); } diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart new file mode 100644 index 0000000..73772b6 --- /dev/null +++ b/example/cars_server/controller.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +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 'dao.dart'; + +class CarsController implements ResourceController { + final Map dao; + + CarsController(this.dao); + + @override + bool supports(String type) => dao.containsKey(type); + + Future> fetchCollection( + String type, Map params) async { + final page = + NumberedPage.fromQueryParameters(params, total: dao[type].length); + return Collection( + dao[type] + .fetchCollection(offset: page.number - 1) + .map(dao[type].toResource), + page: page); + } + + @override + 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); + } + } +} diff --git a/example/cars_server/dao.dart b/example/cars_server/dao.dart new file mode 100644 index 0000000..1b5fac1 --- /dev/null +++ b/example/cars_server/dao.dart @@ -0,0 +1,47 @@ +import 'package:json_api/src/identifier.dart'; +import 'package:json_api/src/resource.dart'; + +import 'model.dart'; + +abstract class DAO { + final _collection = {}; + + int get length => _collection.length; + + Resource toResource(T t); + + T fetchById(String id) => _collection[id]; + + void insert(T t); // => collection[t.id] = t; + + Iterable fetchCollection({int offset = 0, int limit = 1}) => + _collection.values.skip(offset).take(limit); +} + +class CarDAO extends DAO { + Resource toResource(Car _) => + Resource('cars', _.id, attributes: {'name': _.name}); + + void insert(Car car) => _collection[car.id] = car; +} + +class CityDAO extends DAO { + Resource toResource(City _) => + Resource('cities', _.id, attributes: {'name': _.name}); + + void insert(City city) => _collection[city.id] = city; +} + +class BrandDAO extends DAO { + Resource toResource(Brand brand) => Resource('brands', brand.id, attributes: { + 'name': brand.name + }, toOne: { + 'hq': brand.headquarters == null + ? null + : Identifier('cities', brand.headquarters) + }, toMany: { + 'models': brand.models.map((_) => Identifier('cars', _)).toList() + }); + + void insert(Brand brand) => _collection[brand.id] = brand; +} diff --git a/example/server/model.dart b/example/cars_server/model.dart similarity index 56% rename from example/server/model.dart rename to example/cars_server/model.dart index f156309..017b52d 100644 --- a/example/server/model.dart +++ b/example/cars_server/model.dart @@ -1,26 +1,22 @@ -abstract class HasId { - String get id; -} - -class Brand implements HasId { - final String name; +class Brand { final String id; final String headquarters; final List models; + String name; Brand(this.id, this.name, {this.headquarters, this.models = const []}); } -class City implements HasId { - final String name; +class City { final String id; + String name; City(this.id, this.name); } -class Car implements HasId { - final String name; +class Car { final String id; + String name; Car(this.id, this.name); } diff --git a/example/server.dart b/example/server.dart deleted file mode 100644 index 334fd8e..0000000 --- a/example/server.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'dart:io'; - -import 'server/server.dart'; - -void main() async { - final addr = InternetAddress.loopbackIPv4; - final port = 8080; - await createServer().start(addr, port); - print('Listening on ${addr.host}:$port'); -} diff --git a/example/server/controller.dart b/example/server/controller.dart deleted file mode 100644 index 1dd9428..0000000 --- a/example/server/controller.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; - -import 'dao.dart'; - -class CarsController implements ResourceController { - final Map dao; - - CarsController(this.dao); - - @override - bool supports(String type) => dao.containsKey(type); - - Future> fetchCollection( - String type, Map queryParameters) async { - final page = NumberedPage.fromQueryParameters(queryParameters, - total: dao[type].length); - return Collection( - dao[type] - .fetchCollection(offset: page.number - 1) - .map(dao[type].toResource), - page: page); - } - - @override - 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); - } - } - - @override - Future createResource(String type, Resource resource) async { - final obj = dao[type].fromResource(resource); - dao[type].insert(obj); - return null; - } - - @override - Future mergeToMany(Identifier id, String name, ToMany rel) async { - final obj = dao[id.type].fetchById(id.id); - rel.identifiers - .map((id) => dao[id.type].fetchById(id.id)) - .forEach((related) => dao[id.type].addRelationship(obj, name, related)); - - return null; - } -} diff --git a/example/server/dao.dart b/example/server/dao.dart deleted file mode 100644 index 45b6a6a..0000000 --- a/example/server/dao.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:json_api/document.dart'; - -import 'model.dart'; - -abstract class DAO { - final collection = {}; - - int get length => collection.length; - - Resource toResource(T t); - - Relationship relationship(String name, T t) { - throw ArgumentError(); - } - - Map relationships(List names, T t) => - Map.fromIterables(names, names.map((_) => relationship(_, t))); - - T fetchById(String id) => collection[id]; - - void insert(T t) => collection[t.id] = t; - - Iterable fetchCollection({int offset = 0, int limit = 1}) => - collection.values.skip(offset).take(limit); - - HasId fromResource(Resource r); - - addRelationship(T t, String name, HasId related) {} -} - -class CarDAO extends DAO { - Resource toResource(Car _) => - Resource('cars', _.id, attributes: {'name': _.name}); - - @override - HasId fromResource(Resource r) => Car(r.id, r.attributes['name']); -} - -class CityDAO extends DAO { - Resource toResource(City _) => - Resource('cities', _.id, attributes: {'name': _.name}); - - @override - HasId fromResource(Resource r) => City(r.id, r.attributes['name']); -} - -class BrandDAO extends DAO { - Resource toResource(Brand brand) => Resource('brands', brand.id, - attributes: {'name': brand.name}, - relationships: relationships(['headquarters', 'models'], brand)); - - Relationship relationship(String name, Brand brand) { - switch (name) { - case 'headquarters': - return ToOne(brand.headquarters == null - ? null - : Identifier('cities', brand.headquarters)); - case 'models': - return ToMany(brand.models.map((_) => Identifier('cars', _))); - } - throw ArgumentError(); - } - - @override - HasId fromResource(Resource r) => Brand(r.id, r.attributes['name']); - - @override - addRelationship(Brand obj, String name, HasId related) { - switch (name) { - case 'models': - obj.models.add(related.id); - break; - default: - throw ArgumentError.value(name, 'name'); - } - } -} diff --git a/lib/client.dart b/lib/client.dart index ce9ca71..157c5df 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,21 +1 @@ -import 'package:json_api/document.dart'; - -export 'package:json_api/src/client/dart_http_client.dart'; - -abstract class Client implements ResourceFetcher, CollectionFetcher { - Future> fetchCollection(Uri uri, - {Map headers}); - - Future> fetchResource(Uri uri, - {Map headers}); - - Future> fetchToOne(Uri uri, {Map headers}); - - Future> fetchToMany(Uri uri, {Map headers}); - - Future> createResource(Uri uri, Resource r, - {Map headers}); - - Future> addToMany(Uri uri, Iterable ids, - {Map headers}); -} +export 'package:json_api/src/client/client.dart'; diff --git a/lib/core.dart b/lib/core.dart new file mode 100644 index 0000000..0dbcb71 --- /dev/null +++ b/lib/core.dart @@ -0,0 +1,2 @@ +export 'package:json_api/src/identifier.dart'; +export 'package:json_api/src/resource.dart'; diff --git a/lib/document.dart b/lib/document.dart deleted file mode 100644 index 12cbe8d..0000000 --- a/lib/document.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'package:json_api/src/document/document.dart'; -export 'package:json_api/src/document/http.dart'; -export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/relationship.dart'; -export 'package:json_api/src/document/resource.dart'; diff --git a/lib/server.dart b/lib/server.dart deleted file mode 100644 index 3fd8175..0000000 --- a/lib/server.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/routing.dart'; -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/response.dart'; - -export 'package:json_api/src/server/routing.dart'; -export 'package:json_api/src/server/request.dart'; - -class JsonApiServer implements JsonApiController { - final ResourceController resource; - final Routing route; - - JsonApiServer(this.resource, this.route); - - Future handle(String method, Uri uri, String body) async { - final jsonApiRequest = await route.resolve(method, uri, body); - if (jsonApiRequest == null || !resource.supports(jsonApiRequest.type)) { - return ServerResponse(404); - } - return jsonApiRequest.fulfill(this); - } - - Future fetchCollection(CollectionRequest rq) async { - final collection = - await resource.fetchCollection(rq.type, rq.queryParameters); - - final pagination = PaginationLinks.fromMap(collection.page.asMap.map((name, - page) => - MapEntry(name, route.collection(rq.type, params: page?.parameters)))); - - return ServerResponse.ok(CollectionDocument( - collection.elements.map(_addResourceLinks), - self: route.collection(rq.type, params: collection.page?.parameters), - pagination: pagination)); - } - - Future fetchResource(ResourceRequest rq) async { - final res = await _resource(rq.identifier); - return ServerResponse.ok( - ResourceDocument(res == null ? null : _addResourceLinks(res))); - } - - Future fetchRelated(RelatedRequest rq) async { - final res = await _resource(rq.identifier); - final rel = res.relationships[rq.name]; - if (rel is ToOne) { - return ServerResponse.ok( - ResourceDocument(_addResourceLinks(await _resource(rel.identifier)))); - } - - if (rel is ToMany) { - final list = await resource - .fetchResources(rel.identifiers) - .map(_addResourceLinks) - .toList(); - - return ServerResponse.ok(CollectionDocument(list, - self: route.related(rq.type, rq.id, rq.name))); - } - - throw StateError('Unknown relationship type ${rel.runtimeType}'); - } - - Future _resource(Identifier id) => - resource.fetchResources([id]).first; - - Future fetchRelationship(RelationshipRequest rq) async { - final res = await _resource(rq.identifier); - final rel = res.relationships[rq.name]; - return ServerResponse.ok( - _addRelationshipLinks(rel, rq.type, rq.id, rq.name)); - } - - Future createResource(CollectionRequest rq) async { - final doc = ResourceDocument.fromJson(json.decode(rq.body)); - await resource.createResource(rq.type, doc.resource); - return ServerResponse(204); - } - - Future addRelationship(RelationshipRequest rq) async { - final rel = Relationship.fromJson(json.decode(rq.body)); - if (rel is ToMany) { - await resource.mergeToMany(rq.identifier, rq.name, rel); - final res = await _resource(rq.identifier); - return ServerResponse.ok(_addRelationshipLinks( - res.relationships[rq.name], rq.type, rq.id, rq.name)); - } - // TODO: Return a meaningful response - return null; - } - - Resource _addResourceLinks(Resource r) => r.replace( - self: route.resource(r.type, r.id), - relationships: r.relationships.map((name, _) => - MapEntry(name, _addRelationshipLinks(_, r.type, r.id, name)))); - - Relationship _addRelationshipLinks( - Relationship r, String type, String id, String name) => - r.replace( - related: route.related(type, id, name), - self: route.relationship(type, id, name)); -} - -class Collection { - Iterable elements; - final Page page; - - Collection(this.elements, {this.page}); -} - -abstract class ResourceController { - bool supports(String type); - - Future> fetchCollection( - String type, Map queryParameters); - - Stream fetchResources(Iterable ids); - - Future createResource(String type, Resource resource); - - /// Add all ids in [rel] to the relationship [name] of the resource identified by [id]. - /// This implies that the relationship is a [ToMany] one. - Future mergeToMany(Identifier id, String name, ToMany rel); -} - -/// An object which can be encoded as URI query parameters -abstract class QueryParameters { - Map get parameters; -} - -/// Pagination -/// https://jsonapi.org/format/#fetching-pagination -abstract class Page implements QueryParameters { - /// Next page or null - Page get next; - - /// Previous page or null - Page get prev; - - /// First page or null - Page get first; - - /// Last page or null - Page get last; - - Map get asMap => - {'first': first, 'last': last, 'prev': prev, 'next': next}; -} - -class NumberedPage extends Page { - final int number; - final int total; - - NumberedPage(this.number, {this.total}); - - Map get parameters { - if (number > 1) { - return {'page[number]': number.toString()}; - } - return {}; - } - - Page get first => NumberedPage(1, total: total); - - Page get last => NumberedPage(total, total: total); - - Page get next => NumberedPage(min(number + 1, total), total: total); - - Page get prev => NumberedPage(max(number - 1, 1), total: total); - - NumberedPage.fromQueryParameters(Map queryParameters, - {int total}) - : this(int.parse(queryParameters['page[number]'] ?? '1'), total: total); -} diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart new file mode 100644 index 0000000..bab5fce --- /dev/null +++ b/lib/src/client/client.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +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/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'; + +typedef D ResponseParser(Object j); + +typedef http.Client HttpClientFactory(); + +/// JSON:API client +class JsonApiClient { + static const contentType = 'application/vnd.api+json'; + + final HttpClientFactory _factory; + + /// JSON:API client uses Dart's native Http Client internally. + /// To customize its behavior you can pass the [factory] function. + JsonApiClient({HttpClientFactory factory}) + : _factory = factory ?? (() => http.Client()); + + /// Fetches a resource collection by sending a GET request to the [uri]. + /// Use [headers] to pass extra HTTP headers. + Future> fetchCollection(Uri uri, + {Map headers}) => + _get(CollectionDocument.fromJson, uri, headers); + + /// Fetches a single resource + /// Use [headers] to pass extra HTTP headers. + Future> fetchResource(Uri uri, + {Map headers}) => + _get(ResourceDocument.fromJson, uri, headers); + + /// Fetches a to-one relationship + /// Use [headers] to pass extra HTTP headers. + Future> fetchToOne(Uri uri, {Map headers}) => + _get(ToOne.fromJson, uri, headers); + + /// Fetches a to-many relationship + /// Use [headers] to pass extra HTTP headers. + Future> fetchToMany(Uri uri, + {Map headers}) => + _get(ToMany.fromJson, uri, headers); + + /// Fetches a to-one or to-many relationship. + /// The actual type of the relationship can be determined afterwards. + /// Use [headers] to pass extra HTTP headers. + Future> fetchRelationship(Uri uri, + {Map headers}) => + _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); +// +// /// Adds the [identifiers] to a to-many relationship identified by [uri] +// Future> addToMany(Uri uri, Iterable identifiers, +// {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); + + Future> _get( + ResponseParser parse, uri, Map headers) => + _call( + parse, + (_) => _.get(uri, + headers: {} + ..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> _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 { + final client = _factory(); + try { + final r = await fn(client); + final body = r.body.isNotEmpty ? json.decode(r.body) : null; + final statusCode = StatusCode(r.statusCode); + if (statusCode.isSuccessful) { + return Response(r.statusCode, r.headers, nullable(parse)(body)); + } + return Response.error( + r.statusCode, r.headers, nullable(ErrorDocument.fromJson)(body)); + } finally { + client.close(); + } + } +} diff --git a/lib/src/client/dart_http_client.dart b/lib/src/client/dart_http_client.dart deleted file mode 100644 index ebe1bce..0000000 --- a/lib/src/client/dart_http_client.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; - -class DartHttpClient implements Client { - static const contentType = 'application/vnd.api+json'; - - final HttpClientFactory _factory; - - DartHttpClient({HttpClientFactory factory}) - : _factory = factory ?? (() => http.Client()); - - Future> fetchCollection(Uri uri, - {Map headers}) { - return _get((_) => CollectionDocument.fromJson(_), uri, headers); - } - - Future> fetchResource(Uri uri, - {Map headers}) { - return _get((_) => ResourceDocument.fromJson(_), uri, headers); - } - - Future> fetchToOne(Uri uri, {Map headers}) { - return _get((_) => ToOne.fromJson(_), uri, headers); - } - - Future> fetchToMany(Uri uri, {Map headers}) { - return _get((_) => ToMany.fromJson(_), uri, headers); - } - - Future> createResource(Uri uri, Resource r, - {Map headers}) => - _post((_) => ResourceDocument.fromJson(_), uri, ResourceDocument(r), - headers); - - Future> addToMany(Uri uri, Iterable ids, - {Map headers}) => - _post((_) => ToMany.fromJson(_), uri, ToMany(ids), headers); - - Future> _get( - ResponseParser parse, uri, Map headers) => - _call( - parse, - (_) => _.get(uri, - headers: {} - ..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> _call(ResponseParser parse, - Future fn(http.Client client)) async { - final client = _factory(); - try { - final r = await fn(client); - return Response(r.statusCode, r.body, r.headers, parse); - } finally { - client.close(); - } - } -} - -typedef http.Client HttpClientFactory(); diff --git a/lib/src/document/http.dart b/lib/src/client/response.dart similarity index 51% rename from lib/src/document/http.dart rename to lib/src/client/response.dart index acf8878..82789a5 100644 --- a/lib/src/document/http.dart +++ b/lib/src/client/response.dart @@ -1,40 +1,36 @@ -import 'dart:convert'; +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'; - -abstract class ResourceFetcher { - Future> fetchResource(Uri uri, - {Map headers}); -} - -abstract class CollectionFetcher { - Future> fetchCollection(Uri uri, - {Map headers}); -} - -typedef D ResponseParser(Object j); - -/// A response of JSON:API server +/// A response returned by JSON:API cars_server class Response { /// HTTP status code final int status; - final String body; + + /// Document parsed from the response body. + /// May be null. + final D document; + + /// Headers returned by the server. final Map headers; - final ResponseParser _parse; - Response(this.status, this.body, this.headers, this._parse) { + /// For unsuccessful responses this field will contain the error document. + /// May be null. + final ErrorDocument errorDocument; + + Response(this.status, this.headers, this.document) : errorDocument = null { // TODO: Check for null and content-type } - /// Document parsed from the response body. - /// May be null. - D get document => - (isSuccessful && body.isNotEmpty) ? _parse(json.decode(body)) : null; + Response.error(this.status, this.headers, this.errorDocument) + : document = null { + // TODO: Check for null and content-type + } /// Was the request successful? /// /// For pending (202 Accepted) requests [isSuccessful] is always false. - bool get isSuccessful => status >= 200 && status < 300 && !isPending; + bool get isSuccessful => StatusCode(status).isSuccessful; /// Is a request is accepted but not finished yet (e.g. queued) [isPending] is true. /// HTTP Status 202 Accepted should be returned for pending requests. @@ -42,9 +38,9 @@ class Response { /// [document] should contain a queued job resource object. /// /// See: https://jsonapi.org/recommendations/#asynchronous-processing - bool get isPending => status == 202; + bool get isPending => StatusCode(status).isPending; /// Any non 2** status code is considered a failed operation. /// For failed requests, [document] is expected to contain [ErrorDocument] - bool get isFailed => !isSuccessful && !isPending; + bool get isFailed => StatusCode(status).isFailed; } diff --git a/lib/src/client/status_code.dart b/lib/src/client/status_code.dart new file mode 100644 index 0000000..1e059e9 --- /dev/null +++ b/lib/src/client/status_code.dart @@ -0,0 +1,11 @@ +class StatusCode { + final int code; + + StatusCode(this.code); + + bool get isPending => code == 202; + + bool get isSuccessful => code >= 200 && code < 300 && !isPending; + + bool get isFailed => !isSuccessful && !isPending; +} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart deleted file mode 100644 index 2e8690f..0000000 --- a/lib/src/document/document.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/src/document/http.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/document/validation.dart'; - -abstract class Document implements Validatable {} - -class ResourceDocument implements Document { - final Resource resource; - final included = []; - final Link self; - - ResourceDocument(this.resource, {Iterable included, this.self}) { - this.included.addAll(included ?? []); - } - - toJson() { - final json = {'data': resource}; - - final links = {'self': self}..removeWhere((k, v) => v == null); - if (links.isNotEmpty) { - json['links'] = links; - } - if (included.isNotEmpty) json['included'] = included.toList(); - return json; - } - - List validate(Naming naming) { - return resource.validate(naming); - } - - factory ResourceDocument.fromJson(Object json) { - if (json is Map) { - final data = json['data']; - if (data is Map) { - return ResourceDocument(Resource.fromJson(data)); - } - if (data == null) { - return ResourceDocument(null); - } - } - throw 'Can not parse ResourceDocument from $json'; - } -} - -class CollectionDocument implements Document { - final resources = []; - final included = []; - final Link self; - final PaginationLinks pagination; - - CollectionDocument(Iterable collection, - {Iterable included, this.self, this.pagination}) { - this.resources.addAll(collection ?? []); - this.included.addAll(included ?? []); - } - - Link get first => pagination.first; - - Link get last => pagination.last; - - Link get prev => pagination.prev; - - Link get next => pagination.next; - - toJson() { - final json = {'data': resources}; - - final links = {'self': self} - ..addAll(pagination?.asMap ?? {}) - ..removeWhere((k, v) => v == null); - if (links.isNotEmpty) { - json['links'] = links; - } - if (included?.isNotEmpty == true) json['included'] = included.toList(); - return json; - } - - List validate(Naming naming) { - return resources.expand((_) => _.validate(naming)).toList(); - } - - factory CollectionDocument.fromJson(Object json) { - if (json is Map) { - final data = json['data']; - if (data is List) { - final links = Link.parseMap(json['links'] ?? {}); - return CollectionDocument(data.map((_) => Resource.fromJson(_)), - self: links['self'], pagination: PaginationLinks.fromMap(links)); - } - } - throw 'Parse error'; - } - - Future fetchNext(Client client) => - pagination.fetch('next', client); - - Future fetchPrev(Client client) => - pagination.fetch('prev', client); - - Future fetchFirst(Client client) => - pagination.fetch('first', client); - - Future fetchLast(Client client) => - pagination.fetch('last', client); -} - -class PaginationLinks { - final Link first; - final Link last; - final Link prev; - final Link next; - - PaginationLinks({this.next, this.first, this.last, this.prev}); - - PaginationLinks.fromMap(Map links) - : this( - first: links['first'], - last: links['last'], - next: links['next'], - prev: links['prev']); - - Map get asMap => - {'first': first, 'last': last, 'prev': prev, 'next': next}; - - Future fetch(String name, CollectionFetcher client) async { - final page = asMap[name]; - if (page == null) throw StateError('Page $name is not set'); - final response = await client.fetchCollection(page.uri); - return response.document; - } -} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart deleted file mode 100644 index 4b2a83f..0000000 --- a/lib/src/document/identifier.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:json_api/src/document/validation.dart'; - -/// JSON:API identifier object -/// https://jsonapi.org/format/#document-resource-identifier-objects -class Identifier implements Validatable { - final String type; - final String id; - final meta = {}; - - Identifier(this.type, this.id, {Map meta}) { - ArgumentError.checkNotNull(id, 'id'); - ArgumentError.checkNotNull(type, 'type'); - this.meta.addAll(meta ?? {}); - } - - validate(Naming naming) => (naming.violations('/type', [type]) + - naming.violations('/meta', meta.keys)) - .toList(); - - toJson() { - final json = {'type': type, 'id': id}; - if (meta.isNotEmpty) json['meta'] = meta; - return json; - } - - factory Identifier.fromJson(Map json) => json == null - ? null - : Identifier(json['type'], json['id'], meta: json['meta']); - - @override - String toString() => 'Identifier($type:$id)'; -} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart deleted file mode 100644 index 0763c34..0000000 --- a/lib/src/document/link.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:json_api/src/document/parsing.dart'; -import 'package:json_api/src/document/validation.dart'; - -/// A JSON:API link -/// https://jsonapi.org/format/#document-links -class Link implements Validatable { - final String href; - - Link(String this.href) { - ArgumentError.checkNotNull(href, 'href'); - } - - Uri get uri => Uri.parse(href); - - factory Link.fromJson(Object json) { - if (json is String) return Link(json); - if (json is Map) return LinkObject.fromJson(json); - throw ParseError(Link, json); - } - - static Map parseMap(Map m) { - final links = {}; - m.forEach((k, v) => links[k] = Link.fromJson(v)); - return links; - } - - toJson() => href; - - validate(Naming naming) => []; -} - -/// A JSON:API link object -/// https://jsonapi.org/format/#document-links -class LinkObject extends Link { - final meta = {}; - - LinkObject(String href, {Map meta}) : super(href) { - this.meta.addAll(meta ?? {}); - } - - LinkObject.fromJson(Map json) : this(json['href'], meta: json['meta']); - - toJson() { - final json = {'href': href}; - if (meta != null && meta.isNotEmpty) json['meta'] = meta; - return json; - } - - validate(Naming naming) => - naming.violations('/meta', meta.keys.toList()).toList(); -} diff --git a/lib/src/document/naming.dart b/lib/src/document/naming.dart deleted file mode 100644 index 8b13789..0000000 --- a/lib/src/document/naming.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/src/document/parsing.dart b/lib/src/document/parsing.dart deleted file mode 100644 index ad20478..0000000 --- a/lib/src/document/parsing.dart +++ /dev/null @@ -1,9 +0,0 @@ -class ParseError implements Exception { - final Type type; - final Object json; - - ParseError(this.type, this.json); - - @override - String toString() => 'Can not parse $type from $json'; -} diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart deleted file mode 100644 index 433ac8f..0000000 --- a/lib/src/document/relationship.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/http.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/validation.dart'; - -abstract class Relationship extends Document { - Object get data; - - final Link self; - final Link related; - - Relationship({this.self, this.related}); - - Relationship replace({Link self, Link related}); - - Map toJson() { - final json = {'data': data}; - final links = {'self': self, 'related': related} - ..removeWhere((k, v) => v == null); - if (links.isNotEmpty) { - json['links'] = links; - } - return json; - } - - static Map parseMap(Map map) => - map.map((k, r) => MapEntry(k, Relationship.fromJson(r))); - - factory Relationship.fromJson(Object json) { - if (json is Map) { - final data = json['data']; - if (data is List) { - return ToMany.fromJson(json); - } - return ToOne.fromJson(json); - } - throw 'Can not parse Relationship from $json'; - } -} - -class ToMany extends Relationship { - final identifiers = []; - - ToMany(Iterable identifiers, {Link self, Link related}) - : super(self: self, related: related) { - ArgumentError.checkNotNull(identifiers, 'identifiers'); - this.identifiers.addAll(identifiers); - } - - Object get data => identifiers.toList(); - - validate(Naming naming) => identifiers - .toList() - .asMap() - .entries - .expand((_) => _.value.validate(Prefixed(naming, '/${_.key}'))) - .toList(); - - ToMany replace({Link self, Link related}) => ToMany(this.identifiers, - self: self ?? this.self, related: related ?? this.related); - - Future> fetchRelated(CollectionFetcher client) => - client.fetchCollection(related.uri); - - factory ToMany.fromJson(Object json) { - if (json is Map) { - final links = Link.parseMap(json['links'] ?? {}); - final data = json['data']; - if (data is List) { - return ToMany(data.map((_) => Identifier.fromJson(_)), - self: links['self'], related: links['related']); - } - } - throw 'Can not parse ToMany from $json'; - } -} - -class ToOne extends Relationship { - final Identifier identifier; - - ToOne(this.identifier, {Link self, Link related}) - : super(self: self, related: related) {} - - Object get data => identifier; - - validate(Naming naming) => identifier.validate(naming); - - ToOne replace({Link self, Link related}) => ToOne(this.identifier, - self: self ?? this.self, related: related ?? this.related); - - Future> fetchRelated(ResourceFetcher client) => - client.fetchResource(related.uri); - - factory ToOne.fromJson(Object json) { - if (json is Map) { - final links = Link.parseMap(json['links'] ?? {}); - return ToOne(Identifier.fromJson(json['data']), - self: links['self'], related: links['related']); - } - throw 'Can not parse ToOne from $json'; - } -} diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart deleted file mode 100644 index 36d46cf..0000000 --- a/lib/src/document/resource.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/validation.dart'; - -/// Resource object -class Resource implements Validatable { - final String type; - final String id; - final Link self; - final attributes = {}; - final meta = {}; - final relationships = {}; - - Resource(this.type, this.id, - {Map meta, - Map attributes, - Map relationships, - this.self}) { - ArgumentError.checkNotNull(type, 'type'); - this.attributes.addAll(attributes ?? {}); - this.relationships.addAll(relationships ?? {}); - this.meta.addAll(meta ?? {}); - } - - ToOne toOne(String name) { - final rel = relationships[name]; - if (rel is ToOne) return rel; - throw StateError('No ToOne relationship $name'); - } - - ToMany toMany(String name) { - final rel = relationships[name]; - if (rel is ToMany) return rel; - throw StateError('No ToMany relationship $name'); - } - - Resource replace( - {Map meta, - Map attributes, - Map relationships, - Link self}) => - Resource(this.type, this.id, - meta: meta ?? this.meta, - attributes: attributes ?? this.attributes, - relationships: relationships ?? this.relationships, - self: self ?? this.self); - - /// Violations of the JSON:API standard - validate(Naming naming) { - final fields = Set.of(['type', 'id']); - final rel = Set.of(relationships.keys); - final attr = Set.of(attributes.keys); - final namespaceViolations = fields - .intersection(rel) - .map((_) => NamespaceViolation('/relationships', _)) - .followedBy(fields - .intersection(attr) - .map((_) => NamespaceViolation('/attributes', _))) - .followedBy( - attr.intersection(rel).map((_) => NamespaceViolation('/fields', _))) - .toList(); - - return ([] + - namespaceViolations + - naming.violations('/type', [type]) + - naming.violations('/meta', meta.keys) + - naming.violations('/attributes', attributes.keys) + - naming.violations('/relationships', relationships.keys) + - relationships.entries - .expand((rel) => rel.value - .validate(Prefixed(naming, '/relationships/${rel.key}'))) - .toList()) - .toList(); - } - - toJson() { - final json = {'type': type, 'id': id}; - if (attributes.isNotEmpty) json['attributes'] = attributes; - if (relationships.isNotEmpty) json['relationships'] = relationships; - if (meta.isNotEmpty) json['meta'] = meta; - if (self != null) json['links'] = {'self': self}; - return json; - } - - factory Resource.fromJson(Map json) { - final links = Link.parseMap(json['links'] ?? {}); - return Resource(json['type'], json['id'], - self: links['self'], - meta: json['meta'], - attributes: json['attributes'], - relationships: Relationship.parseMap(json['relationships'] ?? {})); - } -} diff --git a/lib/src/document/validation.dart b/lib/src/document/validation.dart deleted file mode 100644 index cc4583b..0000000 --- a/lib/src/document/validation.dart +++ /dev/null @@ -1,68 +0,0 @@ -/// A violation of the JSON:API standard -abstract class Violation { - String get pointer; - - String get value; -} - -abstract class Validatable { - List validate(Naming naming); -} - -/// JSON:API naming rules -/// https://jsonapi.org/format/#document-member-names -abstract class Naming { - const Naming(); - - List violations(String path, Iterable values); -} - -class Prefixed implements Naming { - final Naming inner; - final String prefix; - - Prefixed(this.inner, this.prefix); - - @override - List violations(String path, Iterable values) => - inner.violations(prefix + path, values); -} - -/// JSON:API standard naming rules -/// https://jsonapi.org/format/#document-member-names -class StandardNaming extends Naming { - static final _disallowFirst = new RegExp(r'^[^_ -]'); - static final _disallowLast = new RegExp(r'[^_ -]$'); - static final _allowGlobally = new RegExp(r'^[a-zA-Z0-9_ \u0080-\uffff-]+$'); - - const StandardNaming(); - - /// Is [name] allowed by the rules - bool allows(String name) => - _disallowFirst.hasMatch(name) && - _disallowLast.hasMatch(name) && - _allowGlobally.hasMatch(name); - - bool disallows(String name) => !allows(name); - - List violations(String path, Iterable values) => - values.where(disallows).map((_) => NamingViolation(path, _)).toList(); -} - -/// A violation of JSON:API naming -/// https://jsonapi.org/format/#document-member-names -class NamingViolation implements Violation { - final String pointer; - final String value; - - NamingViolation(this.pointer, this.value); -} - -/// A violation of JSON:API fields uniqueness -/// https://jsonapi.org/format/#document-resource-object-fields -class NamespaceViolation implements Violation { - final String pointer; - final String value; - - NamespaceViolation(this.pointer, this.value); -} diff --git a/lib/src/identifier.dart b/lib/src/identifier.dart new file mode 100644 index 0000000..faac72c --- /dev/null +++ b/lib/src/identifier.dart @@ -0,0 +1,14 @@ +/// The core of the Resource Identifier object +/// https://jsonapi.org/format/#document-resource-identifier-objects +class Identifier { + /// Resource type + final String type; + + /// Resource id + final String id; + + Identifier(this.type, this.id) { + ArgumentError.checkNotNull(id, 'id'); + ArgumentError.checkNotNull(type, 'type'); + } +} diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart new file mode 100644 index 0000000..3200554 --- /dev/null +++ b/lib/src/nullable.dart @@ -0,0 +1,3 @@ +_Fun nullable(U f(V v)) => (v) => v == null ? null : f(v); + +typedef U _Fun(V v); diff --git a/lib/src/resource.dart b/lib/src/resource.dart new file mode 100644 index 0000000..7f9fc8b --- /dev/null +++ b/lib/src/resource.dart @@ -0,0 +1,32 @@ +import 'package:json_api/src/identifier.dart'; + +/// The core of the Resource object +/// https://jsonapi.org/format/#document-resource-objects +class Resource { + /// Resource type + final String type; + + /// Resource id + /// + /// May be null for resources to be created on the cars_server + final String id; + + /// Resource attributes + final attributes = {}; + + /// to-one relationships + final toOne = {}; + + /// to-many relationships + final toMany = >{}; + + Resource(this.type, this.id, + {Map attributes, + Map toOne, + Map> toMany}) { + ArgumentError.checkNotNull(type, 'type'); + this.attributes.addAll(attributes ?? {}); + this.toOne.addAll(toOne ?? {}); + this.toMany.addAll(toMany ?? {}); + } +} diff --git a/lib/src/server/json_api_controller.dart b/lib/src/server/json_api_controller.dart new file mode 100644 index 0000000..fc51c0f --- /dev/null +++ b/lib/src/server/json_api_controller.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:json_api/src/server/response.dart'; + +/// JSON:API Controller +abstract class JsonApiController { + Future fetchCollection( + String type, Map params); + + Future fetchResource(String type, String id); + + Future fetchRelationship( + String type, String id, String relationship); + + Future fetchRelated( + String type, String id, String relationship); +} diff --git a/lib/src/server/numbered_page.dart b/lib/src/server/numbered_page.dart new file mode 100644 index 0000000..b7a2b11 --- /dev/null +++ b/lib/src/server/numbered_page.dart @@ -0,0 +1,29 @@ +import 'dart:math'; + +import 'package:json_api/src/server/page.dart'; + +class NumberedPage extends Page { + final int number; + final int total; + + NumberedPage(this.number, {this.total}); + + Map get parameters { + if (number > 1) { + return {'page[number]': number.toString()}; + } + return {}; + } + + Page get first => NumberedPage(1, total: total); + + Page get last => NumberedPage(total, total: total); + + Page get next => NumberedPage(min(number + 1, total), total: total); + + Page get prev => NumberedPage(max(number - 1, 1), total: total); + + NumberedPage.fromQueryParameters(Map queryParameters, + {int total}) + : this(int.parse(queryParameters['page[number]'] ?? '1'), total: total); +} diff --git a/lib/src/server/page.dart b/lib/src/server/page.dart new file mode 100644 index 0000000..6605fca --- /dev/null +++ b/lib/src/server/page.dart @@ -0,0 +1,26 @@ +/// An object which can be encoded as URI query parameters +abstract class QueryParameters { + Map get parameters; +} + +/// Pagination +/// https://jsonapi.org/format/#fetching-pagination +abstract class Page implements QueryParameters { + /// Next page or null + Page get next; + + /// Previous page or null + Page get prev; + + /// First page or null + Page get first; + + /// Last page or null + Page get last; + + Map mapPages(T f(Page p)) => + asMap.map((name, page) => MapEntry(name, f(page))); + + Map get asMap => + {'first': first, 'last': last, 'prev': prev, 'next': next}; +} diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart index 2827b3b..9da3519 100644 --- a/lib/src/server/request.dart +++ b/lib/src/server/request.dart @@ -1,23 +1,13 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/response.dart'; - -abstract class JsonApiController { - Future fetchCollection(CollectionRequest rq); - - Future fetchResource(ResourceRequest rq); - - Future createResource(CollectionRequest rq); - - Future fetchRelationship(RelationshipRequest rq); - - Future addRelationship(RelationshipRequest rq); +import 'dart:async'; - Future fetchRelated(RelatedRequest rq); -} +import 'package:json_api/src/server/json_api_controller.dart'; +import 'package:json_api/src/server/response.dart'; abstract class JsonApiRequest { String get type; + String get method; + Future fulfill(JsonApiController controller); } @@ -25,44 +15,52 @@ class CollectionRequest implements JsonApiRequest { final String method; final String body; final String type; - final Map queryParameters; + final Map params; - CollectionRequest(this.method, this.type, {this.body, this.queryParameters}); + CollectionRequest(this.method, this.type, {this.body, this.params}); Future fulfill(JsonApiController controller) async { switch (method.toUpperCase()) { case 'GET': - return controller.fetchCollection(this); - case 'POST': - return controller.createResource(this); + return controller.fetchCollection(type, params); +// case 'POST': +// return controller.createResource(body); } - return ServerResponse(405); + return ServerResponse(405); // TODO: meaningful error } } class ResourceRequest implements JsonApiRequest { + final String method; + final String body; final String type; final String id; - ResourceRequest(this.type, this.id); + ResourceRequest(this.method, this.type, this.id, {this.body}); - Identifier get identifier => Identifier(type, id); - - Future fulfill(JsonApiController controller) => - controller.fetchResource(this); + Future fulfill(JsonApiController controller) async { + switch (method.toUpperCase()) { + case 'GET': + return controller.fetchResource(type, id); +// case 'PATCH': +// return controller.updateResource(type, id, body); + } + return ServerResponse(405); // TODO: meaningful error + } } class RelatedRequest implements JsonApiRequest { + final String method; final String type; final String id; - final String name; + final String relationship; + final Map params; - RelatedRequest(this.type, this.id, this.name); - - Identifier get identifier => Identifier(type, id); + RelatedRequest(this.method, this.type, this.id, this.relationship, + {this.params}); Future fulfill(JsonApiController controller) => - controller.fetchRelated(this); + controller.fetchRelated(type, id, relationship); } class RelationshipRequest implements JsonApiRequest { @@ -70,19 +68,18 @@ class RelationshipRequest implements JsonApiRequest { final String body; final String type; final String id; - final String name; - - RelationshipRequest(this.method, this.type, this.id, this.name, {this.body}); + final String relationship; - Identifier get identifier => Identifier(type, id); + RelationshipRequest(this.method, this.type, this.id, this.relationship, + {this.body}); Future fulfill(JsonApiController controller) async { switch (method.toUpperCase()) { case 'GET': - return controller.fetchRelationship(this); - case 'POST': - return controller.addRelationship(this); + return controller.fetchRelationship(type, id, relationship); +// case 'POST': +// return controller.addToMany(type, id, relationship, body); } - return ServerResponse(405); + return ServerResponse(405); // TODO: meaningful error } } diff --git a/lib/src/server/resource_controller.dart b/lib/src/server/resource_controller.dart new file mode 100644 index 0000000..4933fb8 --- /dev/null +++ b/lib/src/server/resource_controller.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:json_api/src/identifier.dart'; +import 'package:json_api/src/resource.dart'; +import 'package:json_api/src/server/page.dart'; + +class Collection { + Iterable elements; + final Page page; + + Collection(this.elements, {this.page}); +} + +abstract class ResourceController { + bool supports(String type); + + Future> fetchCollection( + String type, Map params); + + Stream fetchResources(Iterable ids); + +// Future createResource(Resource resource); + +// Future addToMany(Identifier id, String rel, Iterable ids); + +// Future updateResource(Identifier id, Resource resource); +} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart index 6b9bf4b..2b7c170 100644 --- a/lib/src/server/response.dart +++ b/lib/src/server/response.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:json_api/src/transport/error_document.dart'; +import 'package:json_api/src/transport/error_object.dart'; + class ServerResponse { final String body; final int status; @@ -8,4 +11,7 @@ class ServerResponse { ServerResponse.ok([Object doc]) : this(200, body: doc != null ? json.encode(doc) : null); + + ServerResponse.notFound({List errors = const []}) + : this(404, body: json.encode(ErrorDocument(errors))); } diff --git a/lib/src/server/routing.dart b/lib/src/server/routing.dart index 4100264..22ec66a 100644 --- a/lib/src/server/routing.dart +++ b/lib/src/server/routing.dart @@ -1,20 +1,27 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; -import 'package:json_api/document.dart'; import 'package:json_api/src/server/request.dart'; +/// Routing defines the design of URLs. abstract class Routing { - Link collection(String type, {Map params}); + /// Builds a URI for a resource collection + Uri collection(String type, {Map params}); - Link resource(String type, String id); + /// Builds a URI for a single resource + Uri resource(String type, String id); - Link related(String type, String id, String name); + /// Builds a URI for a related resource + Uri related(String type, String id, String relationship); - Link relationship(String type, String id, String name); + /// Builds a URI for a relationship object + Uri relationship(String type, String id, String relationship); + /// Resolves HTTP request to [JsonAiRequest] object Future resolve(String method, Uri uri, String body); } -/// Recommended URL design schema: +/// StandardRouting implements the recommended URL design schema: /// /// /photos - for a collection /// /photos/1 - for a resource @@ -29,42 +36,38 @@ class StandardRouting implements Routing { ArgumentError.checkNotNull(base, 'base'); } - collection(String type, {Map params}) => Link(base - .replace( - pathSegments: base.pathSegments + [type], - queryParameters: - _nonEmpty(CombinedMapView([base.queryParameters, params ?? {}]))) - .toString()); + collection(String type, {Map params}) => base.replace( + pathSegments: base.pathSegments + [type], + queryParameters: + _nonEmpty(CombinedMapView([base.queryParameters, params ?? {}]))); - related(String type, String id, String name) => Link(base - .replace(pathSegments: base.pathSegments + [type, id, name]) - .toString()); + related(String type, String id, String relationship) => + base.replace(pathSegments: base.pathSegments + [type, id, relationship]); - relationship(String type, String id, String name) => Link(base - .replace( - pathSegments: base.pathSegments + [type, id, 'relationships', name]) - .toString()); + relationship(String type, String id, String relationship) => base.replace( + pathSegments: + base.pathSegments + [type, id, 'relationships', relationship]); - resource(String type, String id) => Link( - base.replace(pathSegments: base.pathSegments + [type, id]).toString()); + resource(String type, String id) => + base.replace(pathSegments: base.pathSegments + [type, id]); Future resolve(String method, Uri uri, String body) async { final seg = uri.pathSegments; switch (seg.length) { case 1: return CollectionRequest(method, seg[0], - body: body, queryParameters: uri.queryParameters); + body: body, params: uri.queryParameters); case 2: - return ResourceRequest(seg[0], seg[1]); + return ResourceRequest(method, seg[0], seg[1], body: body); case 3: - return RelatedRequest(seg[0], seg[1], seg[2]); + return RelatedRequest(method, seg[0], seg[1], seg[2]); case 4: if (seg[2] == 'relationships') { return RelationshipRequest(method, seg[0], seg[1], seg[3], body: body); } } - return null; + return null; // TODO: replace with a null-object } Map _nonEmpty(Map map) => map.isEmpty ? null : map; diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart new file mode 100644 index 0000000..4f6fee8 --- /dev/null +++ b/lib/src/server/server.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +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/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; + final Routing routing; + + JsonApiServer(this.controller, this.routing); + + Future handle(String method, Uri uri, String body) async { + final jsonApiRequest = await routing.resolve(method, uri, body); + if (jsonApiRequest == null) { + return ServerResponse.notFound( + errors: [ErrorObject(status: '404', detail: 'Unknown route')]); + } + if (!controller.supports(jsonApiRequest.type)) { + return ServerResponse.notFound(errors: [ + 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); + + final pagination = Pagination.fromMap(collection.page.mapPages( + (_) => Link(routing.collection(type, params: _?.parameters)))); + + final doc = CollectionDocument(collection.elements.map(_enclose).toList(), + self: + Link(routing.collection(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 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))); + } + + 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())); + } + + 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)))); + } + 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] +// final doc = ResourceDocument.fromJson(json.decode(body)); +// await controller.updateResource( +// Identifier(type, id), doc.resourceEnvelope.toResource()); +// return ServerResponse(204); +// } +// +// Future addToMany( +// String type, String id, String relationship, String body) async { +// final rel = Relationship.fromJson(json.decode(body)); +// if (rel is ToMany) { +// await controller.addToMany( +// Identifier(type, id), relationship, rel.identifiers); +// final res = await _resource(type, id); +// return ServerResponse.ok(ToMany( +// res.toMany[relationship] +// .map(IdentifierEnvelope.fromIdentifier) +// .toList(), +// self: Link(routing.relationship(res.type, res.id, relationship)), +// related: Link(routing.related(res.type, res.id, relationship)))); +// } +// // TODO: Return a meaningful response +// 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/simple_server.dart b/lib/src/server/simple_server.dart similarity index 57% rename from lib/simple_server.dart rename to lib/src/server/simple_server.dart index d0f8812..5a5ac6c 100644 --- a/lib/simple_server.dart +++ b/lib/src/server/simple_server.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:json_api/server.dart'; +import 'package:json_api/src/server/resource_controller.dart'; +import 'package:json_api/src/server/routing.dart'; +import 'package:json_api/src/server/server.dart'; -/// A simple JSON:API server ot top of Dart's [HttpServer] +/// A simple JSON:API cars_server ot top of Dart's [HttpServer] class SimpleServer { HttpServer _httpServer; final ResourceController _controller; @@ -19,10 +22,12 @@ class SimpleServer { _httpServer.forEach((rq) async { final rs = await jsonApiServer.handle( rq.method, rq.uri, await rq.transform(utf8.decoder).join()); - rq.response - ..statusCode = rs.status - ..write(rs.body) - ..close(); + rq.response.statusCode = rs.status; + rq.response.headers.set('Access-Control-Allow-Origin', '*'); + if (rs.body != null) { + rq.response.write(rs.body); + } + rq.response.close(); }); } diff --git a/lib/src/transport/collection_document.dart b/lib/src/transport/collection_document.dart new file mode 100644 index 0000000..8ce293c --- /dev/null +++ b/lib/src/transport/collection_document.dart @@ -0,0 +1,68 @@ +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'; + +class CollectionDocument implements Document { + final List collection; + final List included; + + final Link self; + final Pagination pagination; + + CollectionDocument(List collection, + {List included, this.self, this.pagination}) + : collection = List.unmodifiable(collection), + included = List.unmodifiable(included ?? []); + + Link get first => pagination.first; + + Link get last => pagination.last; + + Link get prev => pagination.prev; + + Link get next => pagination.next; + + toJson() { + final json = {'data': collection}; + + final links = {'self': self} + ..addAll(pagination?.asMap ?? {}) + ..removeWhere((k, v) => v == null); + if (links.isNotEmpty) { + json['links'] = links; + } + if (included?.isNotEmpty == true) json['included'] = included.toList(); + return json; + } + + static CollectionDocument fromJson(Object json) { + if (json is Map) { + final data = json['data']; + if (data is List) { + final links = Link.parseMap(json['links'] ?? {}); + return CollectionDocument(data.map(ResourceObject.fromJson).toList(), + self: links['self'], pagination: Pagination.fromMap(links)); + } + } + throw 'Can not parse CollectionDocument from $json'; + } +} + +class Pagination { + final Link first; + final Link last; + final Link prev; + final Link next; + + Pagination({this.next, this.first, this.last, this.prev}); + + Pagination.fromMap(Map links) + : this( + first: links['first'], + last: links['last'], + next: links['next'], + prev: links['prev']); + + Map get asMap => + {'first': first, 'last': last, 'prev': prev, 'next': next}; +} diff --git a/lib/src/transport/document.dart b/lib/src/transport/document.dart new file mode 100644 index 0000000..c5f2f85 --- /dev/null +++ b/lib/src/transport/document.dart @@ -0,0 +1 @@ +abstract class Document {} diff --git a/lib/src/transport/error_document.dart b/lib/src/transport/error_document.dart new file mode 100644 index 0000000..9615ebd --- /dev/null +++ b/lib/src/transport/error_document.dart @@ -0,0 +1,24 @@ +import 'package:json_api/src/transport/document.dart'; +import 'package:json_api/src/transport/error_object.dart'; + +class ErrorDocument implements Document { + final errors = []; + + ErrorDocument(Iterable errors) { + this.errors.addAll(errors ?? []); + } + + toJson() { + return {'errors': errors}; + } + + static ErrorDocument fromJson(Object json) { + if (json is Map) { + final errors = json['errors']; + if (errors is List) { + return ErrorDocument(errors.map(ErrorObject.fromJson)); + } + } + throw 'Can not parse ErrorDocument from $json'; + } +} diff --git a/lib/src/transport/error_object.dart b/lib/src/transport/error_object.dart new file mode 100644 index 0000000..cc690cf --- /dev/null +++ b/lib/src/transport/error_object.dart @@ -0,0 +1,89 @@ +import 'package:json_api/src/transport/link.dart'; + +/// Error Object +/// Error objects provide additional information about problems encountered while performing an operation. +class ErrorObject { + /// A unique identifier for this particular occurrence of the problem. + String id; + + /// A link that leads to further details about this particular occurrence of the problem. + Link about; + + /// The HTTP status code applicable to this problem, expressed as a string value. + String status; + + /// An application-specific error code, expressed as a string value. + String code; + + /// A short, human-readable summary of the problem that SHOULD NOT change + /// from occurrence to occurrence of the problem, except for purposes of localization. + String title; + + /// A human-readable explanation specific to this occurrence of the problem. + /// Like title, this field’s value can be localized. + String detail; + + /// A JSON Pointer [RFC6901] to the associated entity in the request document + /// [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute]. + String sourcePointer; + + /// A string indicating which URI query parameter caused the error. + String sourceParameter; + + /// A meta object containing non-standard meta-information about the error. + final meta = {}; + + ErrorObject( + {this.id, + this.about, + this.status, + this.code, + this.title, + this.detail, + this.sourceParameter, + this.sourcePointer, + Map meta}) { + this.meta.addAll(meta ?? {}); + } + + static ErrorObject fromJson(Object json) { + if (json is Map) { + Link about; + if (json['links'] is Map) about = Link.fromJson(json['links']['about']); + + String pointer; + String parameter; + if (json['source'] is Map) { + parameter = json['source']['parameter']; + pointer = json['source']['pointer']; + } + return ErrorObject( + id: json['id'], + about: about, + status: json['status'], + code: json['code'], + title: json['title'], + detail: json['detail'], + sourcePointer: pointer, + sourceParameter: parameter, + meta: json['meta']); + } + throw 'Can not parse ErrorObject from $json'; + } + + toJson() { + final json = {}; + if (id != null) json['id'] = id; + if (status != null) json['status'] = status; + 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 (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; + return json; + } +} diff --git a/lib/src/transport/identifier_object.dart b/lib/src/transport/identifier_object.dart new file mode 100644 index 0000000..5fe49a2 --- /dev/null +++ b/lib/src/transport/identifier_object.dart @@ -0,0 +1,29 @@ +import 'package:json_api/src/identifier.dart'; + +class IdentifierObject { + final String type; + final String id; + final Map meta; + + IdentifierObject(this.type, this.id, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}); + + static IdentifierObject fromIdentifier(Identifier id, + {Map meta}) => + IdentifierObject(id.type, id.id, meta: meta); + + Identifier toIdentifier() => Identifier(type, id); + + toJson() { + final json = {'type': type, 'id': id}; + if (meta.isNotEmpty) json['meta'] = meta; + return json; + } + + static IdentifierObject fromJson(Object json) { + if (json is Map) { + return IdentifierObject(json['type'], json['id'], meta: json['meta']); + } + throw 'Can not parse IdentifierContainer from $json'; + } +} diff --git a/lib/src/transport/link.dart b/lib/src/transport/link.dart new file mode 100644 index 0000000..194424d --- /dev/null +++ b/lib/src/transport/link.dart @@ -0,0 +1,59 @@ +/// A JSON:API link +/// https://jsonapi.org/format/#document-links +class Link { + final Uri uri; + + Link(this.uri) { + ArgumentError.checkNotNull(uri, 'uri'); + } + + static Link fromJson(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return LinkObject(Uri.parse(json['href']), meta: json['meta']); + } + throw 'Can not parse Link from $json'; + } + + static Map parseMap(Map m) { + final links = {}; + m.forEach((k, v) => links[k] = Link.fromJson(v)); + return links; + } + + toJson() => uri.toString(); +} + +/// A JSON:API link object +/// https://jsonapi.org/format/#document-links +class LinkObject extends Link { + final Map meta; + + LinkObject(Uri href, {Map meta}) + : meta = Map.unmodifiable(meta ?? {}), + super(href); + + toJson() { + final json = {'href': uri.toString()}; + if (meta != null && meta.isNotEmpty) json['meta'] = meta; + return json; + } +} + +class Links { + final Map links; + + Links(Map links) : links = Map.unmodifiable(links); + + static Links fromJson(Object json) { + if (json is Map) { + return Links(Map.fromEntries( + json.entries.map((e) => MapEntry(e.key, Link.fromJson(e.value))))); + } + throw 'Can not parse Links from $json'; + } + + toJson() => {} + ..addAll(links) + ..removeWhere((k, v) => v == null); +} diff --git a/lib/src/transport/relationship.dart b/lib/src/transport/relationship.dart new file mode 100644 index 0000000..d34d5e1 --- /dev/null +++ b/lib/src/transport/relationship.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:json_api/src/client/client.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'; + +abstract class Relationship implements Document { + final Link self; + final Link related; + + Object get _data; + + Relationship._({this.self, this.related}); + + Map toJson() { + final json = {'data': _data}; + final links = {'self': self, 'related': related} + ..removeWhere((k, v) => v == null); + if (links.isNotEmpty) { + json['links'] = links; + } + return json; + } + + static Map parseMap(Map map) => + map.map((k, v) => MapEntry(k, Relationship.fromJson(v))); + + static Relationship fromJson(Object json) { + if (json is Map) { + final links = Link.parseMap(json['links'] ?? {}); + + final data = json['data']; + if (data is List) { + return ToMany(data.map(IdentifierObject.fromJson).toList(), + self: links['self'], related: links['related']); + } + return ToOne(nullable(IdentifierObject.fromJson)(json['data']), + self: links['self'], related: links['related']); + } + throw 'Can not parse Relationship from $json'; + } +} + +class ToMany extends Relationship { + final List _data; + + ToMany(Iterable _data, {Link self, Link related}) + : _data = List.unmodifiable(_data), + super._(self: self, related: related); + + static ToMany fromJson(Object json) { + if (json is Map) { + final links = Link.parseMap(json['links'] ?? {}); + + final data = json['data']; + if (data is List) { + return ToMany(data.map(IdentifierObject.fromJson).toList(), + self: links['self'], related: links['related']); + } + } + throw 'Can not parse ToMany from $json'; + } + + List get collection => _data; + + List toIdentifiers() => + collection.map((_) => _.toIdentifier()).toList(); + + Future> fetchRelated(JsonApiClient client) async { + if (related == null) throw StateError('The "related" link is null'); + final response = await client.fetchCollection(related.uri); + if (response.isSuccessful) return response.document.collection; + throw 'Error'; // TODO define exceptions + } +} + +class ToOne extends Relationship { + final IdentifierObject _data; + + ToOne(this._data, {Link self, Link related}) + : super._(self: self, related: related); + + static ToOne fromJson(Object json) { + if (json is Map) { + final links = Link.parseMap(json['links'] ?? {}); + + return ToOne(nullable(IdentifierObject.fromJson)(json['data']), + self: links['self'], related: links['related']); + } + throw 'Can not parse ToOne from $json'; + } + + IdentifierObject get identifierObject => _data; + + Identifier toIdentifier() => identifierObject.toIdentifier(); + + Future fetchRelated(JsonApiClient client) async { + if (related == null) throw StateError('The "related" link is null'); + final response = await client.fetchResource(related.uri); + if (response.isSuccessful) return response.document.resourceObject; + throw 'Error'; // TODO define exceptions + } +} diff --git a/lib/src/transport/resource_document.dart b/lib/src/transport/resource_document.dart new file mode 100644 index 0000000..512a96f --- /dev/null +++ b/lib/src/transport/resource_document.dart @@ -0,0 +1,37 @@ +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'; + +class ResourceDocument implements Document { + final ResourceObject resourceObject; + final List included; + final Link self; + + ResourceDocument(this.resourceObject, + {List included, this.self}) + : included = List.unmodifiable(included ?? []); + + toJson() { + final json = {'data': resourceObject}; + + final links = {'self': self}..removeWhere((k, v) => v == null); + if (links.isNotEmpty) { + json['links'] = links; + } + if (included.isNotEmpty) json['included'] = included.toList(); + return json; + } + + static ResourceDocument fromJson(Object json) { + if (json is Map) { + final data = json['data']; + if (data is Map) { + return ResourceDocument(ResourceObject.fromJson(data)); + } + if (data == null) { + return ResourceDocument(null); + } + } + throw 'Can not parse ResourceDocument from $json'; + } +} diff --git a/lib/src/transport/resource_object.dart b/lib/src/transport/resource_object.dart new file mode 100644 index 0000000..418bc57 --- /dev/null +++ b/lib/src/transport/resource_object.dart @@ -0,0 +1,79 @@ +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 { + final String type; + final String id; + final Link self; + final Map attributes; + final Map meta; + final Map relationships; + + ResourceObject(this.type, this.id, + {this.self, + Map meta, + Map attributes, + Map relationships}) + : meta = Map.unmodifiable(meta ?? {}), + attributes = Map.unmodifiable(attributes ?? {}), + relationships = Map.unmodifiable(relationships ?? {}); + + Resource toResource() { + final toOne = {}; + final toMany = >{}; + relationships.forEach((name, rel) { + if (rel is ToOne) { + toOne[name] = rel.toIdentifier(); + } else if (rel is ToMany) { + toMany[name] = rel.toIdentifiers(); + } + }); + return Resource(type, id, + attributes: attributes, toMany: toMany, toOne: toOne); + } + + static ResourceObject enclose(Resource r) { + final toOne = r.toOne.map((name, v) => + MapEntry(name, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))); + + final toMany = r.toMany.map((name, v) => MapEntry( + name, + ToMany( + v.map(nullable(IdentifierObject.fromIdentifier)).toList(), + ))); + + return ResourceObject(r.type, r.id, + attributes: r.attributes, + relationships: {}..addAll(toOne)..addAll(toMany)); + } + + static ResourceObject fromJson(Object json) { + if (json is Map) { + final links = Link.parseMap(json['links'] ?? {}); + + return ResourceObject( + json['type'], + json['id'], + attributes: json['attributes'], + self: links['self'], + meta: json['meta'], + relationships: Relationship.parseMap(json['relationships'] ?? {}), + ); + } + throw 'Can not parse ResourceContainer from $json'; + } + + toJson() { + final json = {'type': type, 'id': id}; + if (attributes.isNotEmpty) json['attributes'] = attributes; + if (relationships.isNotEmpty) json['relationships'] = relationships; + if (meta.isNotEmpty) json['meta'] = meta; + if (self != null) json['links'] = {'self': self}; + return json; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6bfdf3b..5f2e977 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ author: "Alexey Karapetov " description: "JSON:API v1.0 (http://jsonapi.org) Document, Client, and Server" homepage: "https://github.com/f3ath/json-api-dart" name: "json_api" -version: "0.1.0-alpha1" +version: "0.1.0" dependencies: http: "^0.12.0" dev_dependencies: diff --git a/test/browser_compat_test.dart b/test/browser_compat_test.dart new file mode 100644 index 0000000..ff6e5ed --- /dev/null +++ b/test/browser_compat_test.dart @@ -0,0 +1,18 @@ +@TestOn('browser') +import 'package:http/browser_client.dart'; +import 'package:json_api/client.dart'; +import 'package:test/test.dart'; + +void main() async { + test('can fetch collection', () async { + final channel = spawnHybridUri('test_server.dart'); + final client = JsonApiClient(factory: () => BrowserClient()); + final port = await channel.stream.first; + print('Port: $port'); + final r = await client + .fetchCollection(Uri.parse('http://localhost:$port/brands')); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.document.collection.first.attributes['name'], 'Tesla'); + }, tags: ['browser-only']); +} diff --git a/test/document/identifier_test.dart b/test/document/identifier_test.dart deleted file mode 100644 index e2e5e4f..0000000 --- a/test/document/identifier_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/validation.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('constructor', () { - final id = Identifier('apples', '2'); - expect(id.type, 'apples'); - expect(id.id, '2'); - expect(id.meta, isEmpty); - - expect(() => Identifier(null, '1'), throwsArgumentError); - expect(() => Identifier('foos', null), throwsArgumentError); - }); - - test('.toJson()', () { - expect(Identifier('apples', '2'), - encodesToJson({'type': 'apples', 'id': '2'})); - expect( - Identifier('apples', '2', meta: {'foo': 'bar'}), - encodesToJson({ - 'type': 'apples', - 'id': '2', - 'meta': {'foo': 'bar'} - })); - }); - - test('.fromJson()', () { - final j1 = {'type': 'apples', 'id': '2'}; - expect(Identifier.fromJson(j1), encodesToJson(j1)); - - final j2 = { - 'type': 'apples', - 'id': '2', - 'meta': {'foo': 'bar'} - }; - expect(Identifier.fromJson(j2), encodesToJson(j2)); - }); - - test('naming', () { - expect(Identifier('_moo', '2').validate(StandardNaming()).first.pointer, - '/type'); - expect( - Identifier('_moo', '2').validate(StandardNaming()).first.value, '_moo'); - expect( - Identifier('apples', '2', meta: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .pointer, - '/meta'); - expect( - Identifier('apples', '2', meta: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .value, - '_foo'); - }); -} diff --git a/test/document/link_test.dart b/test/document/link_test.dart deleted file mode 100644 index 0c16654..0000000 --- a/test/document/link_test.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/validation.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - final url = 'http://example.com'; - final json1 = { - 'href': url, - 'meta': {'foo': 'bar'} - }; - final json2 = {'href': url}; - - group('Link', () { - test('can create from url', () { - final link = Link(url); - expect(link.href, url); - }); - - test('href can not be null', () { - expect(() => Link(null), throwsArgumentError); - }); - - test('json conversion', () { - expect(Link.fromJson(url), encodesToJson(url)); - expect(Link.fromJson(json1), encodesToJson(json1)); - }); - - test('naming validation', () { - expect(Link(url).validate(StandardNaming()), []); - }); - }); - - group('LinkObject', () { - test('can create from url and meta', () { - final link = LinkObject(url, meta: {'foo': 'bar'}); - expect(link.href, url); - }); - - test('can create from url', () { - final link = LinkObject(url); - expect(link.href, url); - expect(link.meta, isEmpty); - expect(link, encodesToJson({'href': url})); - }); - test('href can not be null', () { - expect(() => LinkObject(null), throwsArgumentError); - }); - - test('json conversion', () { - expect(LinkObject.fromJson(json1), encodesToJson(json1)); - expect(LinkObject.fromJson(json2), encodesToJson(json2)); - }); - - test('naming validation', () { - final violation = LinkObject(url, meta: {'_invalid': true}) - .validate(StandardNaming()) - .first; - expect(violation.pointer, '/meta'); - expect(violation.value, '_invalid'); - }); - }); -} diff --git a/test/document/relationship_test.dart b/test/document/relationship_test.dart deleted file mode 100644 index aa10630..0000000 --- a/test/document/relationship_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('ToOne can be empty', () { - expect(ToOne(null), encodesToJson({'data': null})); - }); -} diff --git a/test/document/resource_test.dart b/test/document/resource_test.dart deleted file mode 100644 index f1aec9a..0000000 --- a/test/document/resource_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/validation.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('constructor', () { - final id = Resource('apples', '2'); - expect(id.type, 'apples'); - expect(id.id, '2'); - expect(id.meta, isEmpty); - - expect(() => Resource(null, '1'), throwsArgumentError); - expect(Resource('foos', null).id, isNull); - }); - - test('.toJson()', () { - expect( - Resource('apples', '2'), encodesToJson({'type': 'apples', 'id': '2'})); - expect( - Resource('apples', '2', - attributes: {'color': 'red'}, meta: {'foo': 'bar'}), - encodesToJson({ - 'type': 'apples', - 'id': '2', - 'attributes': {'color': 'red'}, - 'meta': {'foo': 'bar'} - })); - }); - - test('.fromJson()', () { - final j1 = {'type': 'apples', 'id': '2'}; - expect(Resource.fromJson(j1), encodesToJson(j1)); - - final j2 = { - 'type': 'apples', - 'id': '2', - 'meta': {'foo': 'bar'} - }; - expect(Resource.fromJson(j2), encodesToJson(j2)); - }); - - test('validation', () { - expect(Resource('_moo', '2').validate(StandardNaming()).first.pointer, - '/type'); - expect( - Resource('_moo', '2').validate(StandardNaming()).first.value, '_moo'); - expect( - Resource('apples', '2', meta: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .pointer, - '/meta'); - expect( - Resource('apples', '2', meta: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .value, - '_foo'); - expect( - Resource('apples', '2', attributes: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .pointer, - '/attributes'); - expect( - Resource('apples', '2', attributes: {'_foo': 'bar'}) - .validate(StandardNaming()) - .first - .value, - '_foo'); - - expect( - Resource('articles', '2', - relationships: {'_author': ToOne(Identifier('people', '9'))}) - .validate(StandardNaming()) - .first - .pointer, - '/relationships'); - - expect( - Resource('articles', '2', - relationships: {'author': ToOne(Identifier('_people', '9'))}) - .validate(StandardNaming()) - .first - .pointer, - '/relationships/author/type'); - - expect( - Resource('articles', '2', relationships: {'_comments': ToMany([])}) - .validate(StandardNaming()) - .first - .pointer, - '/relationships'); - - expect( - Resource('articles', '2', relationships: {'type': ToMany([])}) - .validate(StandardNaming()) - .first - .pointer, - '/relationships'); - - expect( - Resource('articles', '2', - relationships: {'foo': ToMany([])}, attributes: {'foo': 'bar'}) - .validate(StandardNaming()) - .first - .pointer, - '/fields'); - }); -} diff --git a/test/document_test.dart b/test/document_test.dart deleted file mode 100644 index cbdbc47..0000000 --- a/test/document_test.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('Single resource collection', () { - final tesla = Resource('brands', '1', attributes: {'name': 'Tesla'}); - final doc = CollectionDocument([tesla]); - expect( - doc, - encodesToJson({ - 'data': [ - { - 'type': 'brands', - 'id': '1', - 'attributes': {'name': 'Tesla'} - } - ] - })); - }); - - test('Example document', () { - final dan = Resource('people', '9', - attributes: { - 'firstName': 'Dan', - 'lastName': 'Gebhardt', - 'twitter': 'dgeb' - }, - self: Link('http://example.com/people/9')); - - final firstComment = Resource('comments', '5', - attributes: {'body': 'First!'}, - relationships: {'author': ToOne(Identifier('people', '2'))}, - self: Link('http://example.com/comments/5')); - - final secondComment = Resource('comments', '12', - attributes: {'body': 'I like XML better'}, - relationships: {'author': ToOne(Identifier('people', '9'))}, - self: Link('http://example.com/comments/12')); - - final article = Resource( - 'articles', - '1', - attributes: {'title': 'JSON:API paints my bikeshed!'}, - self: Link('http://example.com/articles/1'), - relationships: { - 'author': ToOne(Identifier('people', '9'), - self: Link('http://example.com/articles/1/relationships/author'), - related: Link('http://example.com/articles/1/author')), - 'comments': ToMany( - [Identifier('comments', '5'), Identifier('comments', '12')], - self: Link('http://example.com/articles/1/relationships/comments'), - related: Link('http://example.com/articles/1/comments')), - }, - ); - - final doc = CollectionDocument([article], - included: [dan, firstComment, secondComment], - self: Link('http://example.com/articles'), - pagination: PaginationLinks( - next: Link('http://example.com/articles?page[offset]=2'), - last: Link('http://example.com/articles?page[offset]=10'))); - - expect( - doc, - encodesToJson({ - "links": { - "self": "http://example.com/articles", - "next": "http://example.com/articles?page[offset]=2", - "last": "http://example.com/articles?page[offset]=10" - }, - "data": [ - { - "type": "articles", - "id": "1", - "attributes": {"title": "JSON:API paints my bikeshed!"}, - "relationships": { - "author": { - "links": { - "self": - "http://example.com/articles/1/relationships/author", - "related": "http://example.com/articles/1/author" - }, - "data": {"type": "people", "id": "9"} - }, - "comments": { - "links": { - "self": - "http://example.com/articles/1/relationships/comments", - "related": "http://example.com/articles/1/comments" - }, - "data": [ - {"type": "comments", "id": "5"}, - {"type": "comments", "id": "12"} - ] - } - }, - "links": {"self": "http://example.com/articles/1"} - } - ], - "included": [ - { - "type": "people", - "id": "9", - "attributes": { - "firstName": "Dan", - "lastName": "Gebhardt", - "twitter": "dgeb" - }, - "links": {"self": "http://example.com/people/9"} - }, - { - "type": "comments", - "id": "5", - "attributes": {"body": "First!"}, - "relationships": { - "author": { - "data": {"type": "people", "id": "2"} - } - }, - "links": {"self": "http://example.com/comments/5"} - }, - { - "type": "comments", - "id": "12", - "attributes": {"body": "I like XML better"}, - "relationships": { - "author": { - "data": {"type": "people", "id": "9"} - } - }, - "links": {"self": "http://example.com/comments/12"} - } - ] - })); - }); -} - -//class MockPage implements Page { -// final Map parameters; -// final Page next; -// final Page prev; -// final Page first; -// final Page last; -// -// MockPage(this.parameters, {this.first, this.last, this.prev, this.next}); -//} diff --git a/test/functional/client_fetch_test.dart b/test/functional/client_fetch_test.dart new file mode 100644 index 0000000..c9810e6 --- /dev/null +++ b/test/functional/client_fetch_test.dart @@ -0,0 +1,118 @@ +@TestOn('vm') +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: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'); + }); + }); + }); +} diff --git a/test/functional_test.dart b/test/functional_test.dart deleted file mode 100644 index e2562ee..0000000 --- a/test/functional_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/simple_server.dart'; -import 'package:test/test.dart'; - -import '../example/server/server.dart'; - -void main() { - group('client-server e2e tests', () { - final client = DartHttpClient(); - SimpleServer s; - setUp(() async { - s = createServer(); - await s.start(InternetAddress.loopbackIPv4, 8080); - }); - - tearDown(() async { - await s.stop(); - }); - - test('traversing a collection', () async { - final page = await client - .fetchCollection(Uri.parse('http://localhost:8080/brands')); - - final tesla = page.document.resources.first; - expect(tesla.attributes['name'], 'Tesla'); - - final city = await tesla.toOne('headquarters').fetchRelated(client); - - expect(city.document.resource.attributes['name'], 'Palo Alto'); - }); - - test('fetching relationships', () async { - final hq = await client.fetchToOne(Uri.parse( - 'http://localhost:8080/brands/1/relationships/headquarters')); - - final city = await hq.document.fetchRelated(client); - expect(city.document.resource.attributes['name'], 'Palo Alto'); - - final models = await client.fetchToMany( - Uri.parse('http://localhost:8080/brands/1/relationships/models')); - - final cars = await models.document.fetchRelated(client); - expect(cars.document.resources.length, 4); - expect(cars.document.resources.map((_) => _.attributes['name']), - contains('Model 3')); - }); - - test('fetching pages', () async { - final page1 = (await client - .fetchCollection(Uri.parse('http://localhost:8080/brands'))) - .document; - final page2 = await page1.fetchNext(client); - final first = await page2.fetchFirst(client); - expect(json.encode(page1), json.encode(first)); - expect(json.encode(await page2.fetchPrev(client)), json.encode(first)); - expect(json.encode(await page2.fetchLast(client)), - json.encode(await page1.fetchLast(client))); - }); - - test('creating resources', () async { - final modelY = Resource('cars', '100', attributes: {'name': 'Model Y'}); - final result = await client.createResource( - Uri.parse('http://localhost:8080/cars'), modelY); - - expect(result.isSuccessful, true); - - final models = await client.addToMany( - Uri.parse('http://localhost:8080/brands/1/relationships/models'), - [Identifier('cars', '100')]); - - expect(models.document.identifiers.map((_) => _.id), contains('100')); - }); - - test('get collection - 404', () async { - final res = await client - .fetchCollection(Uri.parse('http://localhost:8080/unicorns')); - - expect(res.status, 404); - }); - }, tags: ['vm-only']); -} diff --git a/test/test_server.dart b/test/test_server.dart new file mode 100644 index 0000000..5d2588d --- /dev/null +++ b/test/test_server.dart @@ -0,0 +1,12 @@ +import 'dart:io'; + +import "package:stream_channel/stream_channel.dart"; + +import '../example/cars_server.dart'; + +hybridMain(StreamChannel channel, Object message) async { + final port = 8080; + final server = createServer(); + await server.start(InternetAddress.loopbackIPv4, port); + channel.sink.add(port); +}