From 6eba655b03b6573989f7a9b40caf3c55c09537d6 Mon Sep 17 00:00:00 2001 From: Alexey Date: Sun, 17 Mar 2019 20:47:20 -0700 Subject: [PATCH] Compound docs (#30) --- CHANGELOG.md | 16 +- README.md | 4 +- example/cars_server.dart | 15 +- example/cars_server/controller.dart | 202 ++++++++-------- lib/document.dart | 6 +- lib/parser.dart | 197 ++++++++++++++++ lib/server.dart | 11 +- lib/src/client/client.dart | 39 ++-- lib/src/document/document.dart | 50 +--- lib/src/document/error.dart | 25 -- lib/src/document/identifier_json.dart | 24 -- lib/src/document/identifier_object.dart | 17 ++ lib/src/document/link.dart | 21 -- lib/src/document/relationship.dart | 75 +----- .../document/resource_collection_data.dart | 35 +++ lib/src/document/resource_data.dart | 32 +++ lib/src/document/resource_json.dart | 158 ------------- lib/src/document/resource_object.dart | 85 +++++++ lib/src/server/contracts/controller.dart | 134 +++++++++++ .../server/contracts/document_builder.dart | 41 ++++ lib/src/server/{ => contracts}/page.dart | 0 lib/src/server/contracts/router.dart | 47 ++++ lib/src/server/controller.dart | 23 -- lib/src/server/numbered_page.dart | 2 +- lib/src/server/request.dart | 170 -------------- lib/src/server/route.dart | 109 --------- lib/src/server/route_resolver.dart | 6 - lib/src/server/routing.dart | 61 ----- lib/src/server/server.dart | 108 ++------- lib/src/server/server_requests.dart | 221 ++++++++++++++++++ lib/src/server/server_routes.dart | 111 +++++++++ lib/src/server/standard_document_builder.dart | 106 +++++++++ lib/src/server/standard_router.dart | 57 +++++ lib/src/server/uri_builder.dart | 15 -- pubspec.yaml | 2 +- test/browser_compat_test.dart | 2 +- test/functional/fetch_test.dart | 38 ++- test/functional/update_test.dart | 8 +- test/unit/document_test.dart | 3 +- test/unit/example.json | 97 ++++++++ test/unit/parser_test.dart | 28 +++ 41 files changed, 1443 insertions(+), 958 deletions(-) create mode 100644 lib/parser.dart delete mode 100644 lib/src/document/identifier_json.dart create mode 100644 lib/src/document/identifier_object.dart create mode 100644 lib/src/document/resource_collection_data.dart create mode 100644 lib/src/document/resource_data.dart delete mode 100644 lib/src/document/resource_json.dart create mode 100644 lib/src/document/resource_object.dart create mode 100644 lib/src/server/contracts/controller.dart create mode 100644 lib/src/server/contracts/document_builder.dart rename lib/src/server/{ => contracts}/page.dart (100%) create mode 100644 lib/src/server/contracts/router.dart delete mode 100644 lib/src/server/controller.dart delete mode 100644 lib/src/server/request.dart delete mode 100644 lib/src/server/route.dart delete mode 100644 lib/src/server/route_resolver.dart delete mode 100644 lib/src/server/routing.dart create mode 100644 lib/src/server/server_requests.dart create mode 100644 lib/src/server/server_routes.dart create mode 100644 lib/src/server/standard_document_builder.dart create mode 100644 lib/src/server/standard_router.dart delete mode 100644 lib/src/server/uri_builder.dart create mode 100644 test/unit/example.json create mode 100644 test/unit/parser_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2624d..893e802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.4.0] - 2019-03-17 +### Changed +- Parsing logic moved out +- Some other BC-breaking changes in the Document +- Huge changes in the Server + +### Added +- Compound documents support in Client (Server-side support is still very limited) + +### Fixed +- Server was not setting links for resources and relationships + ## [0.3.0] - 2019-03-16 ### Changed - Huge BC-breaking refactoring in the Document model which propagated everywhere @@ -23,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Client: fetch resources, collections, related resources and relationships -[Unreleased]: https://github.com/f3ath/json-api-dart/compare/0.3.0...HEAD +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/0.4.0...HEAD +[0.4.0]: https://github.com/f3ath/json-api-dart/compare/0.3.0...0.4.0 [0.3.0]: https://github.com/f3ath/json-api-dart/compare/0.2.0...0.3.0 [0.2.0]: https://github.com/f3ath/json-api-dart/compare/0.1.0...0.2.0 diff --git a/README.md b/README.md index 60f3a69..665275a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The features here are roughly ordered by priority. Feel free to open an issue if - [x] Updating resource's attributes - [x] Updating resource's relationships - [x] Updating relationships -- [ ] Compound documents +- [x] Compound documents - [ ] Related collection pagination - [ ] Asynchronous processing - [ ] Optional check for `Content-Type` header in incoming responses @@ -40,7 +40,7 @@ The features here are roughly ordered by priority. Feel free to open an issue if #### Document - [x] Support relationship objects lacking the `data` member -- [ ] Compound documents +- [x] Compound documents - [ ] Support `meta` members - [ ] Support `jsonapi` members - [ ] Structural Validation including compound documents diff --git a/example/cars_server.dart b/example/cars_server.dart index 4842ddb..681a0b7 100644 --- a/example/cars_server.dart +++ b/example/cars_server.dart @@ -49,22 +49,13 @@ Future createServer(InternetAddress addr, int port) async { final controller = CarsController( {'companies': companies, 'cities': cities, 'models': models}); - final routing = StandardRouting(Uri.parse('http://localhost:$port')); + final router = StandardRouter(Uri.parse('http://localhost:$port')); - final server = JsonApiServer(routing); + final jsonApiServer = JsonApiServer(router, controller); final httpServer = await HttpServer.bind(addr, port); - httpServer.forEach((request) async { - final route = await routing.getRoute(request.requestedUri); - if (route == null) { - request.response.statusCode = 404; - return request.response.close(); - } - route.createRequest(request) - ..bind(server) - ..call(controller); - }); + httpServer.forEach(jsonApiServer.process); return httpServer; } diff --git a/example/cars_server/controller.dart b/example/cars_server/controller.dart index f1c0ba1..d5c474d 100644 --- a/example/cars_server/controller.dart +++ b/example/cars_server/controller.dart @@ -1,7 +1,8 @@ import 'dart:async'; import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; +import 'package:json_api/src/server/contracts/controller.dart'; +import 'package:json_api/src/server/numbered_page.dart'; import 'package:uuid/uuid.dart'; import 'dao.dart'; @@ -12,164 +13,167 @@ class CarsController implements JsonApiController { CarsController(this.dao); @override - Future fetchCollection(FetchCollection r) async { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); - } - final page = NumberedPage.fromQueryParameters(r.queryParameters, - total: dao[r.route.type].length); - return r.collection( - dao[r.route.type] + Future fetchCollection(FetchCollectionRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); + } + final page = NumberedPage.fromQueryParameters(r.uri.queryParameters, + total: dao[r.type].length); + return r.sendCollection( + dao[r.type] .fetchCollection(offset: page.offset) - .map(dao[r.route.type].toResource), + .map(dao[r.type].toResource), page: page); } @override - Future fetchRelated(FetchRelated r) { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future fetchRelated(FetchRelatedRequest r) { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final res = dao[r.route.type].fetchByIdAsResource(r.route.id); + final res = dao[r.type].fetchByIdAsResource(r.id); if (res == null) { - return r.notFound([JsonApiError(detail: 'Resource not found')]); + return r.errorNotFound([JsonApiError(detail: 'Resource not found')]); } - if (res.toOne.containsKey(r.route.relationship)) { - final id = res.toOne[r.route.relationship]; + if (res.toOne.containsKey(r.relationship)) { + final id = res.toOne[r.relationship]; final resource = dao[id.type].fetchByIdAsResource(id.id); - return r.resource(resource); + return r.sendResource(resource); } - if (res.toMany.containsKey(r.route.relationship)) { - final resources = res.toMany[r.route.relationship] + if (res.toMany.containsKey(r.relationship)) { + final resources = res.toMany[r.relationship] .map((id) => dao[id.type].fetchByIdAsResource(id.id)); - return r.collection(resources); + return r.sendCollection(resources); } - return r.notFound([JsonApiError(detail: 'Relationship not found')]); + return r.errorNotFound([JsonApiError(detail: 'Relationship not found')]); } @override - Future fetchResource(FetchResource r) { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future fetchResource(FetchResourceRequest r) { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final res = dao[r.route.type].fetchByIdAsResource(r.route.id); + final res = dao[r.type].fetchByIdAsResource(r.id); if (res == null) { - return r.notFound([JsonApiError(detail: 'Resource not found')]); + return r.errorNotFound([JsonApiError(detail: 'Resource not found')]); } - return r.resource(res); + final fetchById = (Identifier _) => dao[_.type].fetchByIdAsResource(_.id); + + final children = res.toOne.values + .map(fetchById) + .followedBy(res.toMany.values.expand((_) => _.map(fetchById))); + + return r.sendResource(res, included: children); } @override - Future fetchRelationship(FetchRelationship r) { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future fetchRelationship(FetchRelationshipRequest r) { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final res = dao[r.route.type].fetchByIdAsResource(r.route.id); + final res = dao[r.type].fetchByIdAsResource(r.id); if (res == null) { - return r.notFound([JsonApiError(detail: 'Resource not found')]); + return r.errorNotFound([JsonApiError(detail: 'Resource not found')]); } - if (res.toOne.containsKey(r.route.relationship)) { - final id = res.toOne[r.route.relationship]; - return r.toOne(id); + if (res.toOne.containsKey(r.relationship)) { + final id = res.toOne[r.relationship]; + return r.sendToOne(id); } - if (res.toMany.containsKey(r.route.relationship)) { - final ids = res.toMany[r.route.relationship]; - return r.toMany(ids); + if (res.toMany.containsKey(r.relationship)) { + final ids = res.toMany[r.relationship]; + return r.sendToMany(ids); } - return r.notFound([JsonApiError(detail: 'Relationship not found')]); + return r.errorNotFound([JsonApiError(detail: 'Relationship not found')]); } @override - Future deleteResource(DeleteResource r) { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future deleteResource(DeleteResourceRequest r) { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final res = dao[r.route.type].fetchByIdAsResource(r.route.id); + final res = dao[r.type].fetchByIdAsResource(r.id); if (res == null) { - return r.notFound([JsonApiError(detail: 'Resource not found')]); + return r.errorNotFound([JsonApiError(detail: 'Resource not found')]); } - final dependenciesCount = dao[r.route.type].deleteById(r.route.id); + final dependenciesCount = dao[r.type].deleteById(r.id); if (dependenciesCount == 0) { - return r.noContent(); + return r.sendNoContent(); } - return r.meta({'dependenciesCount': dependenciesCount}); + return r.sendMeta({'dependenciesCount': dependenciesCount}); } - Future createResource(CreateResource r) async { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future createResource(CreateResourceRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final resource = await r.resource(); - if (r.route.type != resource.type) { - return r.conflict([JsonApiError(detail: 'Incompatible type')]); + if (r.type != r.resource.type) { + return r.errorConflict([JsonApiError(detail: 'Incompatible type')]); } - if (resource.hasId) { - if (dao[r.route.type].fetchById(resource.id) != null) { - return r.conflict([JsonApiError(detail: 'Resource already exists')]); + if (r.resource.hasId) { + if (dao[r.type].fetchById(r.resource.id) != null) { + return r + .errorConflict([JsonApiError(detail: 'Resource already exists')]); } - final created = dao[r.route.type].create(resource); - dao[r.route.type].insert(created); - return r.noContent(); - } - - final created = dao[r.route.type].create(Resource( - resource.type, Uuid().v4(), - attributes: resource.attributes, - toMany: resource.toMany, - toOne: resource.toOne)); - dao[r.route.type].insert(created); - return r.created(dao[r.route.type].toResource(created)); + final created = dao[r.type].create(r.resource); + dao[r.type].insert(created); + return r.sendNoContent(); + } + + final created = dao[r.type].create(Resource(r.resource.type, Uuid().v4(), + attributes: r.resource.attributes, + toMany: r.resource.toMany, + toOne: r.resource.toOne)); + dao[r.type].insert(created); + return r.sendCreated(dao[r.type].toResource(created)); } @override - Future updateResource(UpdateResource r) async { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future updateResource(UpdateResourceRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final resource = await r.resource(); - if (r.route.type != resource.type) { - return r.conflict([JsonApiError(detail: 'Incompatible type')]); + if (r.type != r.resource.type) { + return r.errorConflict([JsonApiError(detail: 'Incompatible type')]); } - if (dao[r.route.type].fetchById(r.route.id) == null) { - return r.notFound([JsonApiError(detail: 'Resource not found')]); + if (dao[r.type].fetchById(r.id) == null) { + return r.errorNotFound([JsonApiError(detail: 'Resource not found')]); } - final updated = dao[r.route.type].update(r.route.id, resource); + final updated = dao[r.type].update(r.id, r.resource); if (updated == null) { - return r.noContent(); + return r.sendNoContent(); } - return r.updated(updated); + return r.sendUpdated(updated); } @override - Future replaceRelationship(ReplaceRelationship r) async { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future replaceToOne(ReplaceToOneRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final rel = await r.relationshipData(); - if (rel is ToOne) { - dao[r.route.type] - .replaceToOne(r.route.id, r.route.relationship, rel.toIdentifier()); - return r.noContent(); - } - if (rel is ToMany) { - dao[r.route.type] - .replaceToMany(r.route.id, r.route.relationship, rel.identifiers); - return r.noContent(); + dao[r.type].replaceToOne(r.id, r.relationship, r.identifier); + return r.sendNoContent(); + } + + @override + Future replaceToMany(ReplaceToManyRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } + dao[r.type].replaceToMany(r.id, r.relationship, r.identifiers); + return r.sendNoContent(); } @override - Future addToRelationship(AddToRelationship r) async { - if (!dao.containsKey(r.route.type)) { - return r.notFound([JsonApiError(detail: 'Unknown resource type')]); + Future addToMany(AddToManyRequest r) async { + if (!dao.containsKey(r.type)) { + return r.errorNotFound([JsonApiError(detail: 'Unknown resource type')]); } - final result = dao[r.route.type] - .addToMany(r.route.id, r.route.relationship, await r.identifiers()); - return r.toMany(result); + final result = dao[r.type].addToMany(r.id, r.relationship, r.identifiers); + return r.sendToMany(result); } } diff --git a/lib/document.dart b/lib/document.dart index 35ab4ee..6ed66f4 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,9 +1,11 @@ export 'package:json_api/src/document/document.dart'; export 'package:json_api/src/document/error.dart'; export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identifier_json.dart'; +export 'package:json_api/src/document/identifier_object.dart'; export 'package:json_api/src/document/link.dart'; export 'package:json_api/src/document/primary_data.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.dart'; -export 'package:json_api/src/document/resource_json.dart'; +export 'package:json_api/src/document/resource_collection_data.dart'; +export 'package:json_api/src/document/resource_data.dart'; +export 'package:json_api/src/document/resource_object.dart'; diff --git a/lib/parser.dart b/lib/parser.dart new file mode 100644 index 0000000..748a5f0 --- /dev/null +++ b/lib/parser.dart @@ -0,0 +1,197 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/pagination.dart'; + +class ParserException implements Exception { + final String message; + + ParserException(this.message); +} + +class JsonApiParser { + const JsonApiParser(); + + Document parseDocument( + Object json, Data parsePrimaryData(Object json)) { + if (json is Map) { + // TODO: validate `meta` + if (json.containsKey('errors')) { + final errors = json['errors']; + if (errors is List) { + return Document.error(errors.map(parseError), meta: json['meta']); + } + } else if (json.containsKey('data')) { + return Document(parsePrimaryData(json), meta: json['meta']); + } else { + return Document.empty(json['meta']); + } + } + throw ParserException('Can not parse Document from $json'); + } + + JsonApiError parseError(Object json) { + if (json is Map) { + Link about; + if (json['links'] is Map) about = parseLink(json['links']['about']); + + String pointer; + String parameter; + if (json['source'] is Map) { + parameter = json['source']['parameter']; + pointer = json['source']['pointer']; + } + return JsonApiError( + 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 ParserException('Can not parse ErrorObject from $json'); + } + + /// Parses a JSON:API Document or the `relationship` member of a Resource object. + Relationship parseRelationship(Object json) { + if (json is Map) { + if (json.containsKey('data')) { + final data = json['data']; + if (data == null || data is Map) { + return parseToOne(json); + } + if (data is List) { + return parseToMany(json); + } + } else { + final links = parseLinks(json['links']); + return Relationship(self: links['self'], related: links['related']); + } + } + throw ParserException('Can not parse Relationship from $json'); + } + + /// Parses the `relationships` member of a Resource Object + Map parseRelationships(Object json) { + if (json == null) return {}; + if (json is Map) { + return json.map((k, v) => MapEntry(k.toString(), parseRelationship(v))); + } + throw ParserException('Can not parse Relationship map from $json'); + } + + /// Parses the `data` member of a JSON:API Document + ResourceObject parseResourceObject(Object json) { + final mapOrNull = (_) => _ == null || _ is Map; + if (json is Map) { + final relationships = json['relationships']; + final attributes = json['attributes']; + final links = parseLinks(json['links']); + + if (mapOrNull(relationships) && mapOrNull(attributes)) { + return ResourceObject(json['type'], json['id'], + attributes: attributes, + relationships: parseRelationships(relationships), + self: links['self']); + } + } + throw ParserException('Can not parse ResourceObject from $json'); + } + + /// Parse the document + ResourceData parseResourceData(Object json) { + if (json is Map) { + final links = parseLinks(json['links']); + final included = json['included']; + final resources = []; + if (included is List) { + resources.addAll(included.map(parseResourceObject)); + } + final data = parseResourceObject(json['data']); + return ResourceData(data, + self: links['self'], + included: resources.isNotEmpty ? resources : null); + } + throw ParserException('Can not parse SingleResourceObject from $json'); + } + + /// Parse the document + ResourceCollectionData parseResourceCollectionData(Object json) { + if (json is Map) { + final links = parseLinks(json['links']); + final included = json['included']; + final resources = []; + if (included is List) { + resources.addAll(included.map(parseResourceObject)); + } + final data = json['data']; + if (data is List) { + return ResourceCollectionData(data.map(parseResourceObject), + self: links['self'], + pagination: Pagination.fromLinks(links), + included: resources.isNotEmpty ? resources : null); + } + } + throw ParserException('Can not parse ResourceObjectCollection from $json'); + } + + ToOne parseToOne(Object json) { + if (json is Map) { + final links = parseLinks(json['links']); + if (json.containsKey('data')) { + final data = json['data']; + if (data == null) { + return ToOne.empty(self: links['self'], related: links['related']); + } + if (data is Map) { + return ToOne(parseIdentifierObject(data), + self: links['self'], related: links['related']); + } + } + } + throw ParserException('Can not parse ToOne from $json'); + } + + ToMany parseToMany(Object json) { + if (json is Map) { + final links = parseLinks(json['links']); + if (json.containsKey('data')) { + final data = json['data']; + if (data is List) { + return ToMany(data.map(parseIdentifierObject), + self: links['self'], related: links['related']); + } + } + } + throw ParserException('Can not parse ToMany from $json'); + } + + IdentifierObject parseIdentifierObject(Object json) { + if (json is Map) { + return IdentifierObject(json['type'], json['id']); + } + throw ParserException('Can not parse IdentifierObject from $json'); + } + + Link parseLink(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 ParserException('Can not parse Link from $json'); + } + + /// Parses the document's `links` member into a map. + /// The retuning map does not have null values. + /// + /// Details on the `links` member: https://jsonapi.org/format/#document-links + Map parseLinks(Object json) { + if (json == null) return {}; + if (json is Map) { + return (json..removeWhere((_, v) => v == null)) + .map((k, v) => MapEntry(k.toString(), parseLink(v))); + } + throw ParserException('Can not parse links from $json'); + } +} diff --git a/lib/server.dart b/lib/server.dart index 84eb691..ae514df 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,9 +1,6 @@ -export 'package:json_api/src/server/controller.dart'; +export 'package:json_api/src/server/contracts/controller.dart'; +export 'package:json_api/src/server/contracts/page.dart'; +export 'package:json_api/src/server/contracts/router.dart'; export 'package:json_api/src/server/numbered_page.dart'; -export 'package:json_api/src/server/page.dart'; -export 'package:json_api/src/server/request.dart'; -export 'package:json_api/src/server/route.dart'; -export 'package:json_api/src/server/route_resolver.dart'; -export 'package:json_api/src/server/routing.dart'; export 'package:json_api/src/server/server.dart'; -export 'package:json_api/src/server/uri_builder.dart'; +export 'package:json_api/src/server/standard_router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index ed80123..663c0db 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:json_api/document.dart'; +import 'package:json_api/parser.dart'; import 'package:json_api/src/client/response.dart'; import 'package:json_api/src/nullable.dart'; @@ -14,6 +15,8 @@ typedef http.Client HttpClientFactory(); class JsonApiClient { static const contentType = 'application/vnd.api+json'; + JsonApiParser _parser = const JsonApiParser(); + final HttpClientFactory _factory; /// JSON:API client uses Dart's native Http Client internally. @@ -25,31 +28,31 @@ class JsonApiClient { /// Use [headers] to pass extra HTTP headers. Future> fetchCollection(Uri uri, {Map headers}) => - _get(ResourceCollectionData.parseDocument, uri, headers); + _get(_parser.parseResourceCollectionData, uri, headers); /// Fetches a single resource /// Use [headers] to pass extra HTTP headers. Future> fetchResource(Uri uri, {Map headers}) => - _get(ResourceData.parseDocument, uri, headers); + _get(_parser.parseResourceData, uri, headers); /// Fetches a to-one relationship /// Use [headers] to pass extra HTTP headers. Future> fetchToOne(Uri uri, {Map headers}) => - _get(ToOne.parse, uri, headers); + _get(_parser.parseToOne, uri, headers); /// Fetches a to-many relationship /// Use [headers] to pass extra HTTP headers. Future> fetchToMany(Uri uri, {Map headers}) => - _get(ToMany.parse, uri, headers); + _get(_parser.parseToMany, 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.parse, uri, headers); + _get(_parser.parseRelationship, uri, headers); /// Creates a new resource. The resource will be added to a collection /// according to its type. @@ -57,8 +60,8 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-creating Future> createResource(Uri uri, Resource resource, {Map headers}) => - _post(ResourceData.parseDocument, uri, - ResourceData(ResourceJson.fromResource(resource)), headers); + _post(_parser.parseResourceData, uri, + ResourceData(ResourceObject.fromResource(resource)), headers); /// Deletes the resource. /// @@ -71,16 +74,16 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating Future> updateResource(Uri uri, Resource resource, {Map headers}) => - _patch(ResourceData.parseDocument, uri, - ResourceData(ResourceJson.fromResource(resource)), headers); + _patch(_parser.parseResourceData, uri, + ResourceData(ResourceObject.fromResource(resource)), headers); /// Updates a to-one relationship via PATCH request /// /// https://jsonapi.org/format/#crud-updating-to-one-relationships Future> replaceToOne(Uri uri, Identifier id, {Map headers}) => - _patch(ToOne.parse, uri, - ToOne(nullable(IdentifierJson.fromIdentifier)(id)), headers); + _patch(_parser.parseToOne, uri, + ToOne(nullable(IdentifierObject.fromIdentifier)(id)), headers); /// Removes a to-one relationship. This is equivalent to calling [replaceToOne] /// with id = null. @@ -96,8 +99,8 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> replaceToMany(Uri uri, List ids, {Map headers}) => - _patch(ToMany.parse, uri, ToMany(ids.map(IdentifierJson.fromIdentifier)), - headers); + _patch(_parser.parseToMany, uri, + ToMany(ids.map(IdentifierObject.fromIdentifier)), headers); /// Adds the given set of [ids] to a to-many relationship. /// @@ -119,8 +122,8 @@ class JsonApiClient { /// https://jsonapi.org/format/#crud-updating-to-many-relationships Future> addToMany(Uri uri, List ids, {Map headers}) => - _post(ToMany.parse, uri, ToMany(ids.map(IdentifierJson.fromIdentifier)), - headers); + _post(_parser.parseToMany, uri, + ToMany(ids.map(IdentifierObject.fromIdentifier)), headers); Future> _get( D parse(Object _), uri, Map headers) => @@ -136,7 +139,7 @@ class JsonApiClient { _call( parse, (_) => _.post(uri, - body: json.encode(Document.data(data)), + body: json.encode(Document(data)), headers: {} ..addAll(headers ?? {}) ..addAll({ @@ -160,7 +163,7 @@ class JsonApiClient { _call( parse, (_) => _.patch(uri, - body: json.encode(Document.data(data)), + body: json.encode(Document(data)), headers: {} ..addAll(headers ?? {}) ..addAll({ @@ -177,7 +180,7 @@ class JsonApiClient { return Response(r.statusCode, r.headers); } final body = json.decode(r.body); - final document = body == null ? null : Document.parse(body, parse); + final document = body == null ? null : _parser.parseDocument(body, parse); return Response(r.statusCode, r.headers, document: document); } finally { diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart index 34f50ad..bcd87fc 100644 --- a/lib/src/document/document.dart +++ b/lib/src/document/document.dart @@ -1,53 +1,27 @@ import 'package:json_api/src/document/error.dart'; import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/resource_json.dart'; -import 'package:json_api/src/nullable.dart'; class Document { /// The Primary Data final Data data; - /// For Compound Documents this member contains the included resources - final included = []; - final List errors; final Map meta; - Document.data(Data data, {Map meta}) - : this._(data: data, meta: nullable((_) => Map.from(_))(meta)); + Document(this.data, {Map meta}) + : this.errors = null, + this.meta = (meta == null || meta.isEmpty ? null : Map.from(meta)); Document.error(Iterable errors, {Map meta}) - : this._( - errors: List.from(errors), - meta: nullable((_) => Map.from(_))(meta)); - - Document.empty(Map meta) : this._(meta: Map.from(meta)); - - Document._({this.data, this.errors, this.meta}) { - if (data == null && errors == null && meta.isEmpty) { - throw ArgumentError( - 'The `meta` member may not be empty for meta-only documents'); - } - } - - static Document parse( - Object json, Data parsePrimaryData(Object json)) { - if (json is Map) { - // TODO: validate `meta` - if (json.containsKey('errors')) { - final errors = json['errors']; - if (errors is List) { - return Document.error(errors.map(JsonApiError.parse), - meta: json['meta']); - } - } else if (json.containsKey('data')) { - final data = parsePrimaryData(json); - return Document.data(data, meta: json['meta']); - } else { - return Document.empty(json['meta']); - } - } - throw 'Can not parse Document from $json'; + : this.data = null, + this.errors = List.from(errors), + this.meta = (meta == null || meta.isEmpty ? null : Map.from(meta)); + + Document.empty(Map meta) + : this.data = null, + this.errors = null, + this.meta = (meta == null || meta.isEmpty ? null : Map.from(meta)) { + ArgumentError.checkNotNull(meta, 'meta'); } Map toJson() { diff --git a/lib/src/document/error.dart b/lib/src/document/error.dart index d9aacb0..ae97082 100644 --- a/lib/src/document/error.dart +++ b/lib/src/document/error.dart @@ -47,31 +47,6 @@ class JsonApiError { this.meta.addAll(meta ?? {}); } - static JsonApiError parse(Object json) { - if (json is Map) { - Link about; - if (json['links'] is Map) about = Link.parse(json['links']['about']); - - String pointer; - String parameter; - if (json['source'] is Map) { - parameter = json['source']['parameter']; - pointer = json['source']['pointer']; - } - return JsonApiError( - 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'; - } - Map toJson() { final json = {}; if (id != null) json['id'] = id; diff --git a/lib/src/document/identifier_json.dart b/lib/src/document/identifier_json.dart deleted file mode 100644 index de78622..0000000 --- a/lib/src/document/identifier_json.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; - -/// [IdentifierJson] is a JSON representation of the [Identifier]. -/// It carries all JSON-related logic and the Meta-data. -class IdentifierJson { - final String type; - final String id; - - IdentifierJson(this.type, this.id); - - static IdentifierJson parse(Object json) { - if (json is Map) { - return IdentifierJson(json['type'], json['id']); - } - throw 'Can not parse IdentifierObject from $json'; - } - - static IdentifierJson fromIdentifier(Identifier id) => - IdentifierJson(id.type, id.id); - - Identifier toIdentifier() => Identifier(type, id); - - toJson() => {'type': type, 'id': id}; -} diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart new file mode 100644 index 0000000..2f25ca3 --- /dev/null +++ b/lib/src/document/identifier_object.dart @@ -0,0 +1,17 @@ +import 'package:json_api/src/document/identifier.dart'; + +/// [IdentifierObject] is a JSON representation of the [Identifier]. +/// It carries all JSON-related logic and the Meta-data. +class IdentifierObject { + final String type; + final String id; + + IdentifierObject(this.type, this.id); + + static IdentifierObject fromIdentifier(Identifier id) => + IdentifierObject(id.type, id.id); + + Identifier toIdentifier() => Identifier(type, id); + + toJson() => {'type': type, 'id': id}; +} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index ed20d47..ebfcd90 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -7,27 +7,6 @@ class Link { ArgumentError.checkNotNull(uri, 'uri'); } - static Link parse(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'; - } - - /// Parses the document's `links` member into a map. - /// The retuning map does not have null values. - /// - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map parseLinks(Object json) { - if (json == null) return {}; - if (json is Map) { - return (json..removeWhere((_, v) => v == null)) - .map((k, v) => MapEntry(k.toString(), parse(v))); - } - throw 'Can not parse links from $json'; - } - toJson() => uri.toString(); } diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 96a306c..f30150d 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,5 +1,5 @@ import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/identifier_json.dart'; +import 'package:json_api/src/document/identifier_object.dart'; import 'package:json_api/src/document/link.dart'; import 'package:json_api/src/document/pagination.dart'; import 'package:json_api/src/document/primary_data.dart'; @@ -17,37 +17,9 @@ class Relationship extends PrimaryData { Relationship({this.related, Link self}) : super(self: self); - /// Parses a JSON:API Document or the `relationship` member of a Resource object. - static Relationship parse(Object json) { - if (json is Map) { - if (json.containsKey('data')) { - final data = json['data']; - if (data == null || data is Map) { - return ToOne.parse(json); - } - if (data is List) { - return ToMany.parse(json); - } - } else { - final links = Link.parseLinks(json['links']); - return Relationship(self: links['self'], related: links['related']); - } - } - throw 'Can not parse Relationship from $json'; - } - - /// Parses the `relationships` member of a Resource Object - static Map parseRelationships(Object json) { - if (json == null) return {}; - if (json is Map) { - return json.map((k, v) => MapEntry(k.toString(), Relationship.parse(v))); - } - throw 'Can not parse Relationship map from $json'; - } - - Map toLinks() => - related == null ? super.toLinks() : super.toLinks() - ..['related'] = related; + Map toLinks() => related == null + ? super.toLinks() + : (super.toLinks()..['related'] = related); /// Top-level JSON object Map toJson() { @@ -65,7 +37,7 @@ class ToOne extends Relationship { /// Can be null for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final IdentifierJson linkage; + final IdentifierObject linkage; ToOne(this.linkage, {Link self, Link related}) : super(self: self, related: related); @@ -74,23 +46,6 @@ class ToOne extends Relationship { : linkage = null, super(self: self, related: related); - static ToOne parse(Object json) { - if (json is Map) { - final links = Link.parseLinks(json['links']); - if (json.containsKey('data')) { - final data = json['data']; - if (data == null) { - return ToOne.empty(self: links['self'], related: links['related']); - } - if (data is Map) { - return ToOne(IdentifierJson.parse(data), - self: links['self'], related: links['related']); - } - } - } - throw 'Can not parse ToOne from $json'; - } - Map toJson() => super.toJson()..['data'] = linkage; /// Converts to [Identifier]. @@ -105,35 +60,21 @@ class ToMany extends Relationship { /// Can be empty for empty relationships /// /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final linkage = []; + final linkage = []; final Pagination pagination; - ToMany(Iterable linkage, + ToMany(Iterable linkage, {Link self, Link related, this.pagination = const Pagination.empty()}) : super(self: self, related: related) { this.linkage.addAll(linkage); } - static ToMany parse(Object json) { - if (json is Map) { - final links = Link.parseLinks(json['links']); - if (json.containsKey('data')) { - final data = json['data']; - if (data is List) { - return ToMany(data.map(IdentifierJson.parse), - self: links['self'], related: links['related']); - } - } - } - throw 'Can not parse ToMany from $json'; - } - Map toLinks() => super.toLinks()..addAll(pagination.toLinks()); Map toJson() => super.toJson()..['data'] = linkage; /// Converts to List<[Identifier]>. /// For empty relationships returns an empty List. - Iterable get identifiers => linkage.map((_) => _.toIdentifier()); + Iterable toIdentifiers() => linkage.map((_) => _.toIdentifier()); } diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart new file mode 100644 index 0000000..c887860 --- /dev/null +++ b/lib/src/document/resource_collection_data.dart @@ -0,0 +1,35 @@ +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/pagination.dart'; +import 'package:json_api/src/document/primary_data.dart'; +import 'package:json_api/src/document/resource_object.dart'; + +/// Represents a resource collection or a collection of related resources of a to-many relationship +class ResourceCollectionData extends PrimaryData { + final collection = []; + final Pagination pagination; + + /// For Compound Documents this member contains the included resources + final List included; + + ResourceCollectionData(Iterable collection, + {Link self, + Iterable included, + this.pagination = const Pagination.empty()}) + : this.included = + (included == null || included.isEmpty ? null : List.from(included)), + super(self: self) { + this.collection.addAll(collection); + } + + @override + Map toJson() { + final json = {'data': collection}; + if (included != null && included.isNotEmpty) { + json['included'] = included; + } + + final links = toLinks()..addAll(pagination.toLinks()); + if (links.isNotEmpty) json['links'] = links; + return json; + } +} diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart new file mode 100644 index 0000000..c0118f9 --- /dev/null +++ b/lib/src/document/resource_data.dart @@ -0,0 +1,32 @@ +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/primary_data.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/resource_object.dart'; + +/// Represents a single resource or a single related resource of a to-one relationship\\\\\\\\ +class ResourceData extends PrimaryData { + final ResourceObject resourceObject; + + /// For Compound Documents this member contains the included resources + final List included; + + ResourceData(this.resourceObject, + {Link self, Iterable included}) + : this.included = + (included == null || included.isEmpty ? null : List.from(included)), + super(self: self); + + @override + Map toJson() { + final json = {'data': resourceObject}; + if (included != null && included.isNotEmpty) { + json['included'] = included; + } + + final links = toLinks(); + if (links.isNotEmpty) json['links'] = links; + return json; + } + + Resource toResource() => resourceObject.toResource(); +} diff --git a/lib/src/document/resource_json.dart b/lib/src/document/resource_json.dart deleted file mode 100644 index 5c64e96..0000000 --- a/lib/src/document/resource_json.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/identifier_json.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/pagination.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/document/relationship.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/nullable.dart'; - -/// [ResourceJson] is a JSON representation of a [Resource]. -/// -/// It carries all JSON-related logic and the Meta-data. -/// In a JSON:API Document it can be the value of the `data` member (a `data` -/// member element in case of a collection) or a member of the `included` -/// resource collection. -/// -/// More on this: https://jsonapi.org/format/#document-resource-objects -class ResourceJson { - final String type; - final String id; - final attributes = {}; - final relationships = {}; - - ResourceJson(this.type, this.id, - {Map attributes, - Map relationships}) { - this.attributes.addAll(attributes ?? {}); - this.relationships.addAll(relationships ?? {}); - } - - /// Parses the `data` member of a JSON:API Document - static ResourceJson parse(Object json) { - final mapOrNull = (_) => _ == null || _ is Map; - if (json is Map) { - final relationships = json['relationships']; - final attributes = json['attributes']; - - if (mapOrNull(relationships) && mapOrNull(attributes)) { - return ResourceJson(json['type'], json['id'], - attributes: attributes, - relationships: Relationship.parseRelationships(relationships)); - } - } - throw 'Can not parse ResourceObject from $json'; - } - - static ResourceJson fromResource(Resource resource) { - final relationships = {} - ..addAll(resource.toOne.map((k, v) => - MapEntry(k, ToOne(nullable(IdentifierJson.fromIdentifier)(v))))) - ..addAll(resource.toMany.map( - (k, v) => MapEntry(k, ToMany(v.map(IdentifierJson.fromIdentifier))))); - - return ResourceJson(resource.type, resource.id, - attributes: resource.attributes, relationships: relationships); - } - - /// Returns the JSON object to be used in the `data` or `included` members - /// of a JSON:API Document - Map toJson() { - final json = {'type': type, 'id': id}; - if (attributes.isNotEmpty) { - json['attributes'] = attributes; - } - if (relationships.isNotEmpty) { - json['relationships'] = relationships; - } - return json; - } - - /// Converts to [Resource] if possible. The standard allows relationships - /// without `data` member. In this case the original [Resource] can not be - /// recovered and this method will throw a [StateError]. - /// - /// TODO: we probably need `isIncomplete` flag to check for this. - Resource toResource() { - final toOne = {}; - final toMany = >{}; - final incomplete = {}; - relationships.forEach((name, rel) { - if (rel is ToOne) { - toOne[name] = rel.toIdentifier(); - } else if (rel is ToMany) { - toMany[name] = rel.identifiers.toList(); - } else { - incomplete[name] = rel; - } - }); - - if (incomplete.isNotEmpty) { - throw StateError('Can not convert to resource' - ' due to incomplete relationships data: ${incomplete.keys}'); - } - - return Resource(type, id, - attributes: attributes, toOne: toOne, toMany: toMany); - } -} - -/// Represents a single resource or a single related resource of a to-one relationship\\\\\\\\ -class ResourceData extends PrimaryData { - final ResourceJson resourceObject; - - ResourceData(this.resourceObject, {Link self}) : super(self: self); - - /// Parse the document - static ResourceData parseDocument(Object json) { - if (json is Map) { - final links = Link.parseLinks(json['links']); - final data = ResourceJson.parse(json['data']); - return ResourceData(data, self: links['self']); - } - throw 'Can not parse SingleResourceObject from $json'; - } - - @override - Map toJson() { - final json = {'data': resourceObject}; - final links = toLinks(); - if (links.isNotEmpty) json['links'] = links; - return json; - } - - Resource toResource() => resourceObject.toResource(); -} - -/// Represents a resource collection or a collection of related resources of a to-many relationship -class ResourceCollectionData extends PrimaryData { - final resourceObjects = []; - final Pagination pagination; - - ResourceCollectionData(Iterable collection, - {Link self, this.pagination = const Pagination.empty()}) - : super(self: self) { - this.resourceObjects.addAll(collection); - } - - /// Parse the document - static ResourceCollectionData parseDocument(Object json) { - if (json is Map) { - final links = Link.parseLinks(json['links']); - final data = json['data']; - if (data is List) { - return ResourceCollectionData(data.map(ResourceJson.parse), - self: links['self'], pagination: Pagination.fromLinks(links)); - } - } - throw 'Can not parse ResourceObjectCollection from $json'; - } - - @override - Map toJson() { - final json = {'data': resourceObjects}; - final links = toLinks()..addAll(pagination.toLinks()); - if (links.isNotEmpty) json['links'] = links; - return json; - } -} diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart new file mode 100644 index 0000000..c385fe4 --- /dev/null +++ b/lib/src/document/resource_object.dart @@ -0,0 +1,85 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/identifier_object.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/nullable.dart'; + +/// [ResourceObject] is a JSON representation of a [Resource]. +/// +/// It carries all JSON-related logic and the Meta-data. +/// In a JSON:API Document it can be the value of the `data` member (a `data` +/// member element in case of a collection) or a member of the `included` +/// resource collection. +/// +/// More on this: https://jsonapi.org/format/#document-resource-objects +class ResourceObject { + final String type; + final String id; + final Link self; + final attributes = {}; + final relationships = {}; + + ResourceObject(this.type, this.id, + {Map attributes, + Map relationships, + this.self}) { + this.attributes.addAll(attributes ?? {}); + this.relationships.addAll(relationships ?? {}); + } + + static ResourceObject fromResource(Resource resource) { + final relationships = {} + ..addAll(resource.toOne.map((k, v) => + MapEntry(k, ToOne(nullable(IdentifierObject.fromIdentifier)(v))))) + ..addAll(resource.toMany.map((k, v) => + MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier))))); + + return ResourceObject(resource.type, resource.id, + attributes: resource.attributes, relationships: relationships); + } + + /// Returns the JSON object to be used in the `data` or `included` members + /// of a JSON:API Document + Map toJson() { + final json = {'type': type, 'id': id}; + if (attributes.isNotEmpty) { + json['attributes'] = attributes; + } + if (relationships.isNotEmpty) { + json['relationships'] = relationships; + } + if (self != null) { + json['links'] = {'self': self}; + } + return json; + } + + /// Converts to [Resource] if possible. The standard allows relationships + /// without `data` member. In this case the original [Resource] can not be + /// recovered and this method will throw a [StateError]. + /// + /// TODO: we probably need `isIncomplete` flag to check for this. + Resource toResource() { + final toOne = {}; + final toMany = >{}; + final incomplete = {}; + relationships.forEach((name, rel) { + if (rel is ToOne) { + toOne[name] = rel.toIdentifier(); + } else if (rel is ToMany) { + toMany[name] = rel.toIdentifiers().toList(); + } else { + incomplete[name] = rel; + } + }); + + if (incomplete.isNotEmpty) { + throw StateError('Can not convert to resource' + ' due to incomplete relationships data: ${incomplete.keys}'); + } + + return Resource(type, id, + attributes: attributes, toOne: toOne, toMany: toMany); + } +} diff --git a/lib/src/server/contracts/controller.dart b/lib/src/server/contracts/controller.dart new file mode 100644 index 0000000..d2035e8 --- /dev/null +++ b/lib/src/server/contracts/controller.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/contracts/page.dart'; + +abstract class JsonApiController { + Future fetchCollection(FetchCollectionRequest rq); + + Future fetchRelated(FetchRelatedRequest rq); + + Future fetchResource(FetchResourceRequest rq); + + Future fetchRelationship(FetchRelationshipRequest rq); + + Future deleteResource(DeleteResourceRequest rq); + + Future createResource(CreateResourceRequest rq); + + Future updateResource(UpdateResourceRequest rq); + + Future replaceToOne(ReplaceToOneRequest rq); + + Future replaceToMany(ReplaceToManyRequest rq); + + Future addToMany(AddToManyRequest rq); +} + +abstract class JsonApiRequest { + Uri get uri; + + String get type; + + Future errorNotFound(Iterable errors); +} + +abstract class FetchCollectionRequest extends JsonApiRequest { + Future sendCollection(Iterable resources, {Page page}); +} + +abstract class FetchRelatedRequest extends JsonApiRequest { + String get id; + + String get relationship; + + Future sendCollection(Iterable collection); + + Future sendResource(Resource resource); +} + +abstract class FetchRelationshipRequest extends JsonApiRequest { + String get id; + + String get relationship; + + Future sendToMany(Iterable collection); + + Future sendToOne(Identifier id); +} + +abstract class ReplaceToOneRequest extends JsonApiRequest { + String get id; + + String get relationship; + + Identifier get identifier; + + Future sendNoContent(); + + Future sendToMany(Iterable collection); + + Future sendToOne(Identifier id); +} + +abstract class ReplaceToManyRequest extends JsonApiRequest { + String get id; + + String get relationship; + + Iterable get identifiers; + + Future sendNoContent(); + + Future sendToMany(Iterable collection); + + Future sendToOne(Identifier id); +} + +abstract class AddToManyRequest extends JsonApiRequest { + String get id; + + String get relationship; + + Iterable get identifiers; + + Future sendToMany(Iterable collection); +} + +abstract class FetchResourceRequest extends JsonApiRequest { + String get id; + + Future sendResource(Resource resource, {Iterable included}); +} + +abstract class DeleteResourceRequest extends JsonApiRequest { + String get id; + + Future sendNoContent(); + + Future sendMeta(Map meta); +} + +abstract class CreateResourceRequest extends JsonApiRequest { + Resource get resource; + + Future sendCreated(Resource resource); + + Future sendNoContent(); + + Future errorConflict(Iterable errors); +} + +abstract class UpdateResourceRequest extends JsonApiRequest { + String get id; + + Resource get resource; + + Future sendUpdated(Resource resource); + + Future sendNoContent(); + + Future errorConflict(Iterable errors); + + Future errorForbidden(Iterable errors); +} diff --git a/lib/src/server/contracts/document_builder.dart b/lib/src/server/contracts/document_builder.dart new file mode 100644 index 0000000..ba5cf12 --- /dev/null +++ b/lib/src/server/contracts/document_builder.dart @@ -0,0 +1,41 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/server/contracts/page.dart'; + +/// The Document builder is used by JsonApiServer. It abstracts the process +/// of building response documents and is responsible for such aspects as +/// adding `meta` and `jsonapi` attributes and generating links +abstract class DocumentBuilder { + /// Given the [collection] of type [type] return a document. + /// If the collection is paginated, the [page] parameter will contain the + /// current page details. + Document collection( + Iterable collection, String type, Uri self, + {Page page, Iterable included}); + + Document relatedCollection( + Iterable collection, + String type, + String id, + String relationship, + Uri self, + {Page page, + Iterable included}); + + Document resource( + Resource resource, String type, String id, Uri self, + {Iterable included}); + + Document relatedResource( + Resource resource, String type, String id, String relationship, Uri self, + {Iterable included}); + + Document toMany(Iterable collection, String type, + String id, String relationship, Uri self); + + Document toOne(Identifier identifier, String type, String id, + String relationship, Uri self); + + Document meta(Map meta); + + Document error(Iterable errors); +} diff --git a/lib/src/server/page.dart b/lib/src/server/contracts/page.dart similarity index 100% rename from lib/src/server/page.dart rename to lib/src/server/contracts/page.dart diff --git a/lib/src/server/contracts/router.dart b/lib/src/server/contracts/router.dart new file mode 100644 index 0000000..5d8f70e --- /dev/null +++ b/lib/src/server/contracts/router.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +abstract class UriBuilder { + /// Builds a URI for a resource collection + Uri collection(String type, {Map parameters}); + + /// Builds a URI for a single resource + Uri resource(String type, String id, {Map parameters}); + + /// Builds a URI for a related resource + Uri related(String type, String id, String relationship, + {Map parameters}); + + /// Builds a URI for a relationship object + Uri relationship(String type, String id, String relationship, + {Map parameters}); +} + +/// Route resolver detects the type of the route by [Uri] +abstract class RouteResolver { + /// Resolves HTTP request to route object. + /// This function should call one of the methods of the [factory] object depending on the + /// detected route and return the result back. If the route can be matched + /// to neither Collection, Resource, Related Resource nor Relationship, + /// this method should return the Unmatched route. + FutureOr getRoute(Uri uri, RouteFactory factory); +} + +abstract class RouteFactory { + /// Returns a Resource Collection route + R collection(String type); + + /// Returns a Resource route + R resource(String type, String id); + + /// Returns a Relationship route + R relationship(String type, String id, String relationship); + + /// Returns a Related Resource route + R related(String type, String id, String relationship); + + /// Returns the Unmatched route (neither of the above) + R unmatched(); +} + +/// Routing defines the design of URLs. +abstract class Router implements UriBuilder, RouteResolver {} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart deleted file mode 100644 index 2e00e35..0000000 --- a/lib/src/server/controller.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/src/server/request.dart'; - -abstract class JsonApiController { - Future fetchCollection(FetchCollection request); - - Future fetchRelated(FetchRelated request); - - Future fetchResource(FetchResource request); - - Future fetchRelationship(FetchRelationship request); - - Future deleteResource(DeleteResource request); - - Future createResource(CreateResource request); - - Future updateResource(UpdateResource request); - - Future replaceRelationship(ReplaceRelationship request); - - Future addToRelationship(AddToRelationship request); -} diff --git a/lib/src/server/numbered_page.dart b/lib/src/server/numbered_page.dart index 2e494bf..4090c5e 100644 --- a/lib/src/server/numbered_page.dart +++ b/lib/src/server/numbered_page.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:json_api/src/server/page.dart'; +import 'package:json_api/src/server/contracts/page.dart'; class NumberedPage extends Page { final int number; diff --git a/lib/src/server/request.dart b/lib/src/server/request.dart deleted file mode 100644 index 176a0f8..0000000 --- a/lib/src/server/request.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/page.dart'; -import 'package:json_api/src/server/route.dart'; -import 'package:json_api/src/server/server.dart'; - -abstract class JsonApiRequest { - final HttpRequest _request; - JsonApiServer _server; - - JsonApiRequest(this._request); - - Map get queryParameters => - _request.requestedUri.queryParameters; - - HttpResponse get _response => _request.response; - - Future get _body async => - json.decode(await _request.transform(utf8.decoder).join()); - - Future call(JsonApiController controller); - - Future notFound([List errors = const []]) => - _server.error(_response, 404, errors); - - bind(JsonApiServer server) => _server = server; -} - -class FetchCollection extends JsonApiRequest { - final CollectionRoute route; - - FetchCollection(HttpRequest request, this.route) : super(request); - - Future call(JsonApiController controller) => controller.fetchCollection(this); - - Future collection(Iterable resources, {Page page}) => - _server.collection(_response, route, resources, page: page); -} - -class FetchRelated extends JsonApiRequest { - final RelatedRoute route; - - FetchRelated(HttpRequest request, this.route) : super(request); - - Future call(JsonApiController controller) => controller.fetchRelated(this); - - Future collection(Iterable collection) => - _server.relatedCollection(_response, route, collection); - - Future resource(Resource resource) => - _server.relatedResource(_response, route, resource); -} - -class FetchRelationship extends JsonApiRequest { - final RelationshipRoute route; - - FetchRelationship(HttpRequest request, this.route) : super(request); - - Future call(JsonApiController controller) => - controller.fetchRelationship(this); - - Future toMany(Iterable collection) => - _server.toMany(_response, route, collection); - - Future toOne(Identifier id) => _server.toOne(_response, route, id); -} - -class ReplaceRelationship extends JsonApiRequest { - final RelationshipRoute route; - - ReplaceRelationship(HttpRequest request, this.route) : super(request); - - Future relationshipData() async => - Relationship.parse(await _body); - - Future call(JsonApiController controller) => - controller.replaceRelationship(this); - - Future noContent() => _server.write(_response, 204); - - Future toMany(Iterable collection) => - _server.toMany(_response, route, collection); - - Future toOne(Identifier id) => _server.toOne(_response, route, id); -} - -class AddToRelationship extends JsonApiRequest { - final RelationshipRoute route; - - AddToRelationship(HttpRequest request, this.route) : super(request); - - Future> identifiers() async => - ToMany.parse(await _body).identifiers; - - Future call(JsonApiController controller) => - controller.addToRelationship(this); - - Future toMany(Iterable collection) => - _server.toMany(_response, route, collection); -} - -class FetchResource extends JsonApiRequest { - final ResourceRoute route; - - FetchResource(HttpRequest request, this.route) : super(request); - - Future call(JsonApiController controller) => controller.fetchResource(this); - - Future resource(Resource resource) => - _server.resource(_response, route, resource); -} - -class DeleteResource extends JsonApiRequest { - final ResourceRoute route; - - DeleteResource(HttpRequest request, this.route) : super(request); - - Future call(JsonApiController controller) => controller.deleteResource(this); - - Future noContent() => _server.write(_response, 204); - - Future meta(Map meta) => _server.meta(_response, route, meta); -} - -class CreateResource extends JsonApiRequest { - final CollectionRoute route; - - CreateResource(HttpRequest request, this.route) : super(request); - - Future resource() async { - return ResourceData.parseDocument(await _body).resourceObject.toResource(); - } - - Future call(JsonApiController controller) => controller.createResource(this); - - Future created(Resource resource) => - _server.created(_response, route, resource); - - Future conflict(List errors) => - _server.error(_response, 409, errors); - - Future noContent() => _server.write(_response, 204); -} - -class UpdateResource extends JsonApiRequest { - final ResourceRoute route; - - UpdateResource(HttpRequest request, this.route) : super(request); - - Future resource() async { - return ResourceData.parseDocument(await _body).resourceObject.toResource(); - } - - Future call(JsonApiController controller) => controller.updateResource(this); - - Future updated(Resource resource) => - _server.resource(_response, route, resource); - - Future conflict(List errors) => - _server.error(_response, 409, errors); - - Future forbidden(List errors) => - _server.error(_response, 403, errors); - - Future noContent() => _server.write(_response, 204); -} diff --git a/lib/src/server/route.dart b/lib/src/server/route.dart deleted file mode 100644 index 9679d61..0000000 --- a/lib/src/server/route.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:io'; - -import 'package:json_api/src/server/request.dart'; -import 'package:json_api/src/server/uri_builder.dart'; - -abstract class JsonApiRoute { - final Uri uri; - - JsonApiRoute(this.uri); - - /// Returns the `self` link uri - Uri self(UriBuilder schema, {Map parameters = const {}}); - - JsonApiRequest createRequest(HttpRequest httpRequest); - - /// URI parameters - Map get parameters => uri.queryParameters; -} - -class CollectionRoute extends JsonApiRoute { - final String type; - - CollectionRoute(Uri uri, this.type) : super(uri); - - JsonApiRequest createRequest(HttpRequest request) { - switch (request.method) { - case 'GET': - return FetchCollection(request, this); - case 'POST': - return CreateResource(request, this); - } - throw 'Unexpected method ${request.method}'; - } - - @override - Uri self(UriBuilder schema, {Map parameters = const {}}) => - schema.collection(type, params: parameters); -} - -class RelatedRoute extends JsonApiRoute { - final String type; - final String id; - final String relationship; - - RelatedRoute(Uri uri, this.type, this.id, this.relationship) : super(uri); - - JsonApiRequest createRequest(HttpRequest request) { - switch (request.method) { - case 'GET': - return FetchRelated(request, this); - } - throw 'Unexpected method ${request.method}'; - } - - @override - Uri self(UriBuilder schema, {Map parameters = const {}}) => - schema.related(type, id, relationship, params: parameters); -} - -class RelationshipRoute extends JsonApiRoute { - final String type; - final String id; - final String relationship; - - RelationshipRoute(Uri uri, this.type, this.id, this.relationship) - : super(uri); - - JsonApiRequest createRequest(HttpRequest request) { - switch (request.method) { - case 'GET': - return FetchRelationship(request, this); - case 'PATCH': - return ReplaceRelationship(request, this); - case 'POST': - return AddToRelationship(request, this); - } - throw 'Unexpected method ${request.method}'; - } - - @override - Uri self(UriBuilder builder, {Map parameters = const {}}) => - builder.relationship(type, id, relationship, params: parameters); - - Uri related(UriBuilder builder, {Map params = const {}}) => - builder.related(type, id, relationship, params: params); -} - -class ResourceRoute extends JsonApiRoute { - final String type; - final String id; - - ResourceRoute(Uri uri, this.type, this.id) : super(uri); - - JsonApiRequest createRequest(HttpRequest request) { - switch (request.method) { - case 'GET': - return FetchResource(request, this); - case 'DELETE': - return DeleteResource(request, this); - case 'PATCH': - return UpdateResource(request, this); - } - throw 'Unexpected method ${request.method}'; - } - - @override - Uri self(UriBuilder schema, {Map parameters = const {}}) => - schema.resource(type, id, params: parameters); -} diff --git a/lib/src/server/route_resolver.dart b/lib/src/server/route_resolver.dart deleted file mode 100644 index 405b907..0000000 --- a/lib/src/server/route_resolver.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:json_api/src/server/route.dart'; - -abstract class RouteResolver { - /// Resolves HTTP request to [JsonAiRequest] object - JsonApiRoute getRoute(Uri uri); -} diff --git a/lib/src/server/routing.dart b/lib/src/server/routing.dart deleted file mode 100644 index 1fbbb12..0000000 --- a/lib/src/server/routing.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:json_api/src/server/route.dart'; -import 'package:json_api/src/server/route_resolver.dart'; -import 'package:json_api/src/server/uri_builder.dart'; - -/// Routing defines the design of URLs. -abstract class Routing implements UriBuilder, RouteResolver {} - -/// StandardRouting implements the recommended URL design schema: -/// -/// /photos - for a collection -/// /photos/1 - for a resource -/// /photos/1/relationships/author - for a relationship -/// /photos/1/author - for a related resource -/// -/// See https://jsonapi.org/recommendations/#urls -class StandardRouting implements Routing { - final Uri base; - - StandardRouting(this.base) { - ArgumentError.checkNotNull(base, 'base'); - } - - collection(String type, {Map params = const {}}) { - final combined = {} - ..addAll(base.queryParameters) - ..addAll(params); - return base.replace( - pathSegments: base.pathSegments + [type], - queryParameters: combined.isNotEmpty ? combined : null); - } - - related(String type, String id, String relationship, - {Map params = const {}}) => - base.replace(pathSegments: base.pathSegments + [type, id, relationship]); - - relationship(String type, String id, String relationship, - {Map params = const {}}) => - base.replace( - pathSegments: - base.pathSegments + [type, id, 'relationships', relationship]); - - resource(String type, String id, {Map params = const {}}) => - base.replace(pathSegments: base.pathSegments + [type, id]); - - JsonApiRoute getRoute(Uri uri) { - final segments = uri.pathSegments; - switch (segments.length) { - case 1: - return CollectionRoute(uri, segments[0]); - case 2: - return ResourceRoute(uri, segments[0], segments[1]); - case 3: - return RelatedRoute(uri, segments[0], segments[1], segments[2]); - case 4: - if (segments[2] == 'relationships') { - return RelationshipRoute(uri, segments[0], segments[1], segments[3]); - } - } - return null; // TODO: replace with a null-object - } -} diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 0d93b67..eedcc22 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -3,93 +3,35 @@ import 'dart:convert'; import 'dart:io'; import 'package:json_api/document.dart'; -import 'package:json_api/src/document/pagination.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/page.dart'; -import 'package:json_api/src/server/route.dart'; -import 'package:json_api/src/server/uri_builder.dart'; +import 'package:json_api/parser.dart'; +import 'package:json_api/src/server/contracts/controller.dart'; +import 'package:json_api/src/server/contracts/document_builder.dart'; +import 'package:json_api/src/server/contracts/page.dart'; +import 'package:json_api/src/server/contracts/router.dart'; +import 'package:json_api/src/server/standard_document_builder.dart'; + +part 'server_requests.dart'; +part 'server_routes.dart'; class JsonApiServer { - final UriBuilder url; - final String allowOrigin; + final Router router; + final JsonApiController controller; - JsonApiServer(this.url, {this.allowOrigin = '*'}); + JsonApiServer(this.router, this.controller); - Future write(HttpResponse response, int status, - {Document document, Map headers = const {}}) { - response.statusCode = status; - headers.forEach(response.headers.add); - if (allowOrigin != null) { - response.headers.set('Access-Control-Allow-Origin', allowOrigin); - } - if (document != null) { - response.write(json.encode(document)); + Future process(HttpRequest httpRequest) async { + const factory = _JsonApiRouteFactory(); + final route = await router.getRoute(httpRequest.requestedUri, factory); + if (route == null) { + httpRequest.response.statusCode = 404; + return httpRequest.response.close(); } - return response.close(); + final request = route.createRequest(httpRequest); + final body = await httpRequest.transform(utf8.decoder).join(); + request.uri = httpRequest.requestedUri; + if (body.isNotEmpty) request.setBody(json.decode(body)); + request.docBuilder = StandardDocumentBuilder(router); + request.response = httpRequest.response; + return request.call(controller); } - - Future collection(HttpResponse response, CollectionRoute route, - Iterable resource, - {Page page}) => - write(response, 200, - document: Document.data( - ResourceCollectionData(resource.map(ResourceJson.fromResource), - self: Link(route.self(url, parameters: route.parameters)), - pagination: page == null - ? Pagination.empty() - : Pagination.fromLinks(page.map((_) => - Link(route.self(url, parameters: _.parameters))))), - )); - - Future error(HttpResponse response, int status, List errors) => - write(response, status, document: Document.error(errors)); - - Future relatedCollection(HttpResponse response, RelatedRoute route, - Iterable collection) => - write(response, 200, - document: Document.data(ResourceCollectionData( - collection.map(ResourceJson.fromResource), - self: Link(route.self(url))))); - - Future relatedResource( - HttpResponse response, RelatedRoute route, Resource resource) => - write(response, 200, - document: Document.data(ResourceData( - ResourceJson.fromResource(resource), - self: Link(route.self(url))))); - - Future resource( - HttpResponse response, ResourceRoute route, Resource resource) => - write(response, 200, - document: Document.data(ResourceData( - ResourceJson.fromResource(resource), - self: Link(route.self(url))))); - - Future toMany(HttpResponse response, RelationshipRoute route, - Iterable collection) => - write(response, 200, - document: Document.data(ToMany( - collection.map(IdentifierJson.fromIdentifier), - self: Link(route.self(url)), - related: Link(route.related(url))))); - - Future toOne(HttpResponse response, RelationshipRoute route, Identifier id) => - write(response, 200, - document: Document.data(ToOne( - nullable(IdentifierJson.fromIdentifier)(id), - self: Link(route.self(url)), - related: Link(route.related(url))))); - - Future meta(HttpResponse response, ResourceRoute route, - Map meta) => - write(response, 200, document: Document.empty(meta)); - - Future created( - HttpResponse response, CollectionRoute route, Resource resource) => - write(response, 201, - document: - Document.data(ResourceData(ResourceJson.fromResource(resource))), - headers: { - 'Location': url.resource(resource.type, resource.id).toString() - }); } diff --git a/lib/src/server/server_requests.dart b/lib/src/server/server_requests.dart new file mode 100644 index 0000000..c06a8a6 --- /dev/null +++ b/lib/src/server/server_requests.dart @@ -0,0 +1,221 @@ +part of 'server.dart'; + +const _parser = const JsonApiParser(); + +abstract class _BaseRequest { + HttpResponse response; + String allowOrigin = '*'; + Uri uri; + DocumentBuilder docBuilder; + + void setBody(Object body) {} + + Future _error(int status, Iterable errors) => + _write(status, document: docBuilder.error(errors)); + + Future call(JsonApiController controller); + + Future sendNoContent() => _write(204); + + Future errorNotFound([Iterable errors]) => _error(404, errors); + + Future errorConflict(Iterable errors) => _error(409, errors); + + Future errorForbidden(Iterable errors) => _error(403, errors); + + Future _write(int status, + {Document document, Map headers = const {}}) { + response.statusCode = status; + headers.forEach(response.headers.add); + if (allowOrigin != null) { + response.headers.set('Access-Control-Allow-Origin', allowOrigin); + } + if (document != null) { + response.write(json.encode(document)); + } + return response.close(); + } + + Future _collection(_CollectionRoute route, Iterable resource, + {Page page}) => + _write(200, + document: + docBuilder.collection(resource, route.type, uri, page: page)); + + Future _relatedCollection(_RelatedRoute route, Iterable collection, + {Page page}) => + _write(200, + document: docBuilder.relatedCollection( + collection, route.type, route.id, route.relationship, uri, + page: page)); + + Future _relatedResource(_RelatedRoute route, Resource resource) => _write(200, + document: docBuilder.relatedResource( + resource, route.type, route.id, route.relationship, uri)); + + Future _resource(_ResourceRoute route, Resource resource, + {Iterable included}) => + _write(200, + document: docBuilder.resource(resource, route.type, route.id, uri, + included: included)); + + Future _toMany(_RelationshipRoute route, Iterable collection) => + _write(200, + document: docBuilder.toMany( + collection, route.type, route.id, route.relationship, uri)); + + Future _toOne(_RelationshipRoute route, Identifier identifier) => _write(200, + document: docBuilder.toOne( + identifier, route.type, route.id, route.relationship, uri)); + + Future _meta(_ResourceRoute route, Map meta) => + _write(200, document: Document.empty(meta)); + + Future _created(_CollectionRoute route, Resource resource) { + final doc = docBuilder.resource(resource, route.type, resource.id, uri); + return _write(201, + document: doc, + headers: {'Location': doc.data.resourceObject.self.toString()}); + } +} + +abstract class _CollectionRequest extends _BaseRequest { + _CollectionRoute route; + + String get type => route.type; +} + +class _FetchCollection extends _CollectionRequest + implements FetchCollectionRequest { + Future call(JsonApiController controller) => controller.fetchCollection(this); + + Future sendCollection(Iterable resources, {Page page}) => + _collection(route, resources, page: page); +} + +class _CreateResource extends _CollectionRequest + implements CreateResourceRequest { + Resource resource; + + void setBody(Object body) { + resource = _parser.parseResourceData(body).toResource(); + } + + Future call(JsonApiController controller) => controller.createResource(this); + + Future sendCreated(Resource resource) => _created(route, resource); +} + +class _FetchRelated extends _BaseRequest implements FetchRelatedRequest { + _RelatedRoute route; + + String get type => route.type; + + String get id => route.id; + + String get relationship => route.relationship; + + Future call(JsonApiController controller) => controller.fetchRelated(this); + + Future sendCollection(Iterable collection) => + _relatedCollection(route, collection); + + Future sendResource(Resource resource) => _relatedResource(route, resource); +} + +abstract class _RelationshipRequest extends _BaseRequest { + _RelationshipRoute route; + + String get type => route.type; + + String get id => route.id; + + String get relationship => route.relationship; +} + +class _FetchRelationship extends _RelationshipRequest + implements FetchRelationshipRequest { + Future call(JsonApiController controller) => + controller.fetchRelationship(this); + + Future sendToMany(Iterable collection) => + _toMany(route, collection); + + Future sendToOne(Identifier id) => _toOne(route, id); +} + +class _ReplaceRelationship extends _RelationshipRequest + implements ReplaceToOneRequest, ReplaceToManyRequest { + Identifier identifier; + Iterable identifiers; + + @override + void setBody(Object body) { + final r = _parser.parseRelationship(body); + if (r is ToOne) identifier = r.toIdentifier(); + if (r is ToMany) identifiers = r.toIdentifiers(); + } + + Future call(JsonApiController controller) { + if (identifiers != null) return controller.replaceToMany(this); + return controller.replaceToOne(this); + } + + Future sendToMany(Iterable collection) => + _toMany(route, collection); + + Future sendToOne(Identifier id) => _toOne(route, id); +} + +class _AddToMany extends _RelationshipRequest implements AddToManyRequest { + Identifier identifier; + Iterable identifiers; + + @override + void setBody(Object body) { + final r = _parser.parseRelationship(body); + if (r is ToOne) identifier = r.toIdentifier(); + if (r is ToMany) identifiers = r.toIdentifiers(); + } + + Future call(JsonApiController controller) => controller.addToMany(this); + + Future sendToMany(Iterable collection) => + _toMany(route, collection); +} + +abstract class _ResourceRequest extends _BaseRequest { + _ResourceRoute route; + + String get type => route.type; + + String get id => route.id; +} + +class _FetchResource extends _ResourceRequest implements FetchResourceRequest { + Future call(JsonApiController controller) => controller.fetchResource(this); + + Future sendResource(Resource resource, {Iterable included}) => + _resource(route, resource, included: included); +} + +class _DeleteResource extends _ResourceRequest + implements DeleteResourceRequest { + Future call(JsonApiController controller) => controller.deleteResource(this); + + Future sendMeta(Map meta) => _meta(route, meta); +} + +class _UpdateResource extends _ResourceRequest + implements UpdateResourceRequest { + Resource resource; + + @override + void setBody(Object body) { + resource = _parser.parseResourceData(body).resourceObject.toResource(); + } + + Future call(JsonApiController controller) => controller.updateResource(this); + + Future sendUpdated(Resource resource) => _resource(route, resource); +} diff --git a/lib/src/server/server_routes.dart b/lib/src/server/server_routes.dart new file mode 100644 index 0000000..3db24d0 --- /dev/null +++ b/lib/src/server/server_routes.dart @@ -0,0 +1,111 @@ +part of 'server.dart'; + +class _JsonApiRouteFactory implements RouteFactory<_BaseRoute> { + const _JsonApiRouteFactory(); + + _BaseRoute collection(String type) => _CollectionRoute(type); + + _BaseRoute related(String type, String id, String relationship) => + _RelatedRoute(type, id, relationship); + + _BaseRoute relationship(String type, String id, String relationship) => + _RelationshipRoute(type, id, relationship); + + _BaseRoute resource(String type, String id) => _ResourceRoute(type, id); + + _BaseRoute unmatched() => null; +} + +abstract class _BaseRoute { + Uri self(UriBuilder builder, {Map parameters = const {}}); + + _BaseRequest createRequest(HttpRequest httpRequest); +} + +class _CollectionRoute extends _BaseRoute { + final String type; + + _CollectionRoute(this.type); + + _BaseRequest createRequest(HttpRequest request) { + switch (request.method) { + case 'GET': + return _FetchCollection()..route = this; + case 'POST': + return _CreateResource()..route = this; + } + throw 'Unexpected method ${request.method}'; + } + + @override + Uri self(UriBuilder builder, {Map parameters = const {}}) => + builder.collection(type, parameters: parameters); +} + +class _RelatedRoute extends _BaseRoute { + final String type; + final String id; + final String relationship; + + _RelatedRoute(this.type, this.id, this.relationship); + + _BaseRequest createRequest(HttpRequest request) { + switch (request.method) { + case 'GET': + return _FetchRelated()..route = this; + } + throw 'Unexpected method ${request.method}'; + } + + @override + Uri self(UriBuilder builder, {Map parameters = const {}}) => + builder.related(type, id, relationship, parameters: parameters); +} + +class _RelationshipRoute extends _BaseRoute { + final String type; + final String id; + final String relationship; + + _RelationshipRoute(this.type, this.id, this.relationship); + + _BaseRequest createRequest(HttpRequest request) { + switch (request.method) { + case 'GET': + return _FetchRelationship()..route = this; + case 'PATCH': + return _ReplaceRelationship()..route = this; + case 'POST': + return _AddToMany()..route = this; + } + throw 'Unexpected method ${request.method}'; + } + + Uri self(UriBuilder builder, {Map parameters = const {}}) => + builder.relationship(type, id, relationship, parameters: parameters); + + Uri related(UriBuilder builder, {Map params = const {}}) => + builder.related(type, id, relationship, parameters: params); +} + +class _ResourceRoute extends _BaseRoute { + final String type; + final String id; + + _ResourceRoute(this.type, this.id); + + _BaseRequest createRequest(HttpRequest request) { + switch (request.method) { + case 'GET': + return _FetchResource()..route = this; + case 'DELETE': + return _DeleteResource()..route = this; + case 'PATCH': + return _UpdateResource()..route = this; + } + throw 'Unexpected method ${request.method}'; + } + + Uri self(UriBuilder builder, {Map parameters = const {}}) => + builder.resource(type, id, parameters: parameters); +} diff --git a/lib/src/server/standard_document_builder.dart b/lib/src/server/standard_document_builder.dart new file mode 100644 index 0000000..5e6af06 --- /dev/null +++ b/lib/src/server/standard_document_builder.dart @@ -0,0 +1,106 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/pagination.dart'; +import 'package:json_api/src/nullable.dart'; +import 'package:json_api/src/server/contracts/document_builder.dart'; +import 'package:json_api/src/server/contracts/page.dart'; +import 'package:json_api/src/server/contracts/router.dart'; + +class StandardDocumentBuilder implements DocumentBuilder { + final UriBuilder uriBuilder; + + StandardDocumentBuilder(this.uriBuilder); + + Document error(Iterable errors) => Document.error(errors); + + Document collection( + Iterable resource, String type, Uri self, + {Page page, Iterable included}) { + return Document(ResourceCollectionData(resource.map(_resourceObject), + self: Link(self), + pagination: page == null + ? Pagination.empty() + : Pagination.fromLinks(page.map((_) => + Link(uriBuilder.collection(type, parameters: _.parameters)))))); + } + + Document relatedCollection( + Iterable resource, + String type, + String id, + String relationship, + Uri self, + {Page page, + Iterable included}) { + final pagination = _pagination(page, type, id, relationship); + return Document(ResourceCollectionData(resource.map(_resourceObject), + self: Link(self), pagination: pagination)); + } + + Document resource( + Resource resource, String type, String id, Uri self, + {Iterable included}) { + return Document( + ResourceData(_resourceObject(resource), + self: Link(uriBuilder.resource(type, id)), + included: included?.map(_resourceObject)), + ); + } + + Document relatedResource( + Resource resource, String type, String id, String relationship, Uri self, + {Iterable included}) { + return Document( + ResourceData(_resourceObject(resource), + included: included?.map(_resourceObject), + self: Link(uriBuilder.related(type, id, relationship))), + ); + } + + Document toMany(Iterable collection, String type, + String id, String relationship, Uri self) { + return Document(ToMany(collection.map(_rdentifierObject), + self: Link(uriBuilder.relationship(type, id, relationship)), + related: Link(uriBuilder.related(type, id, relationship)))); + } + + Document toOne(Identifier identifier, String type, String id, + String relationship, Uri self) { + return Document(ToOne(nullable(_rdentifierObject)(identifier), + self: Link(uriBuilder.relationship(type, id, relationship)), + related: Link(uriBuilder.related(type, id, relationship)))); + } + + Document meta(Map meta) => Document.empty(meta); + + IdentifierObject _rdentifierObject(Identifier id) => + IdentifierObject(id.type, id.id); + + ResourceObject _resourceObject(Resource resource) { + final relationships = {}; + relationships.addAll(resource.toOne.map((k, v) => MapEntry( + k, + ToOne(nullable(_rdentifierObject)(v), + self: Link(uriBuilder.relationship(resource.type, resource.id, k)), + related: + Link(uriBuilder.related(resource.type, resource.id, k)))))); + relationships.addAll(resource.toMany.map((k, v) => MapEntry( + k, + ToMany(v.map(_rdentifierObject), + self: Link(uriBuilder.relationship(resource.type, resource.id, k)), + related: + Link(uriBuilder.related(resource.type, resource.id, k)))))); + + return ResourceObject(resource.type, resource.id, + attributes: resource.attributes, + relationships: relationships, + self: Link(uriBuilder.resource(resource.type, resource.id))); + } + + Pagination _pagination( + Page page, String type, String id, String relationship) { + return page == null + ? Pagination.empty() + : Pagination.fromLinks(page.map((_) => Link(uriBuilder + .related(type, id, relationship, parameters: _.parameters)))); + } +} diff --git a/lib/src/server/standard_router.dart b/lib/src/server/standard_router.dart new file mode 100644 index 0000000..c7c3ba5 --- /dev/null +++ b/lib/src/server/standard_router.dart @@ -0,0 +1,57 @@ +import 'package:json_api/src/server/contracts/router.dart'; + +/// StandardRouting implements the recommended URL design schema: +/// +/// /photos - for a collection +/// /photos/1 - for a resource +/// /photos/1/relationships/author - for a relationship +/// /photos/1/author - for a related resource +/// +/// See https://jsonapi.org/recommendations/#urls +class StandardRouter implements Router { + final Uri base; + + StandardRouter(this.base) { + ArgumentError.checkNotNull(base, 'base'); + } + + Uri collection(String type, {Map parameters = const {}}) { + final combined = {} + ..addAll(base.queryParameters) + ..addAll(parameters); + return base.replace( + pathSegments: base.pathSegments + [type], + queryParameters: combined.isNotEmpty ? combined : null); + } + + Uri related(String type, String id, String relationship, + {Map parameters = const {}}) => + base.replace(pathSegments: base.pathSegments + [type, id, relationship]); + + Uri relationship(String type, String id, String relationship, + {Map parameters = const {}}) => + base.replace( + pathSegments: + base.pathSegments + [type, id, 'relationships', relationship]); + + Uri resource(String type, String id, + {Map parameters = const {}}) => + base.replace(pathSegments: base.pathSegments + [type, id]); + + R getRoute(Uri uri, RouteFactory route) { + final segments = uri.pathSegments; + switch (segments.length) { + case 1: + return route.collection(segments[0]); + case 2: + return route.resource(segments[0], segments[1]); + case 3: + return route.related(segments[0], segments[1], segments[2]); + case 4: + if (segments[2] == 'relationships') { + return route.relationship(segments[0], segments[1], segments[3]); + } + } + return route.unmatched(); + } +} diff --git a/lib/src/server/uri_builder.dart b/lib/src/server/uri_builder.dart deleted file mode 100644 index 2067f41..0000000 --- a/lib/src/server/uri_builder.dart +++ /dev/null @@ -1,15 +0,0 @@ -abstract class UriBuilder { - /// Builds a URI for a resource collection - Uri collection(String type, {Map params = const {}}); - - /// Builds a URI for a single resource - Uri resource(String type, String id, {Map params = const {}}); - - /// Builds a URI for a related resource - Uri related(String type, String id, String relationship, - {Map params = const {}}); - - /// Builds a URI for a relationship object - Uri relationship(String type, String id, String relationship, - {Map params = const {}}); -} diff --git a/pubspec.yaml b/pubspec.yaml index 7eb7e22..c8457d9 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.3.0" +version: "0.4.0" dependencies: collection: "^1.14.11" http: "^0.12.0" diff --git a/test/browser_compat_test.dart b/test/browser_compat_test.dart index a24afd7..bee47d2 100644 --- a/test/browser_compat_test.dart +++ b/test/browser_compat_test.dart @@ -13,6 +13,6 @@ void main() async { .fetchCollection(Uri.parse('http://localhost:$port/companies')); expect(r.status, 200); expect(r.isSuccessful, true); - expect(r.data.resourceObjects.first.attributes['name'], 'Tesla'); + expect(r.data.collection.first.attributes['name'], 'Tesla'); }); } diff --git a/test/functional/fetch_test.dart b/test/functional/fetch_test.dart index d102f87..046c95d 100644 --- a/test/functional/fetch_test.dart +++ b/test/functional/fetch_test.dart @@ -22,7 +22,13 @@ void main() async { final r = await client.fetchCollection(uri); expect(r.status, 200); expect(r.isSuccessful, true); - expect(r.data.resourceObjects.first.attributes['name'], 'Tesla'); + expect(r.data.collection.first.attributes['name'], 'Tesla'); + expect(r.data.collection.first.self.uri.toString(), + 'http://localhost:8080/companies/1'); + expect(r.data.collection.first.relationships['hq'].related.uri.toString(), + 'http://localhost:8080/companies/1/hq'); + expect(r.data.collection.first.relationships['hq'].self.uri.toString(), + 'http://localhost:8080/companies/1/relationships/hq'); expect(r.data.self.uri, uri); }); @@ -34,23 +40,23 @@ void main() async { final r1 = await client.fetchCollection(somePage.pagination.next.uri); final secondPage = r1.data; - expect(secondPage.resourceObjects.first.attributes['name'], 'BMW'); + expect(secondPage.collection.first.attributes['name'], 'BMW'); expect(secondPage.self.uri, somePage.pagination.next.uri); final r2 = await client.fetchCollection(secondPage.pagination.last.uri); final lastPage = r2.data; - expect(lastPage.resourceObjects.first.attributes['name'], 'Toyota'); + expect(lastPage.collection.first.attributes['name'], 'Toyota'); expect(lastPage.self.uri, secondPage.pagination.last.uri); final r3 = await client.fetchCollection(lastPage.pagination.prev.uri); final secondToLastPage = r3.data; - expect(secondToLastPage.resourceObjects.first.attributes['name'], 'Audi'); + expect(secondToLastPage.collection.first.attributes['name'], 'Audi'); expect(secondToLastPage.self.uri, lastPage.pagination.prev.uri); final r4 = await client.fetchCollection(secondToLastPage.pagination.first.uri); final firstPage = r4.data; - expect(firstPage.resourceObjects.first.attributes['name'], 'Tesla'); + expect(firstPage.collection.first.attributes['name'], 'Tesla'); expect(firstPage.self.uri, secondToLastPage.pagination.first.uri); }); @@ -59,7 +65,7 @@ void main() async { final r = await client.fetchCollection(uri); expect(r.status, 200); expect(r.isSuccessful, true); - expect(r.data.resourceObjects.first.attributes['name'], 'Roadster'); + expect(r.data.collection.first.attributes['name'], 'Roadster'); expect(r.data.self.uri, uri); }); @@ -81,6 +87,20 @@ void main() async { expect(r.data.self.uri, uri); }); + test('single resource compound document', () async { + final uri = Url.resource('companies', '1'); + final r = await client.fetchResource(uri); + expect(r.status, 200); + expect(r.isSuccessful, true); + expect(r.data.toResource().attributes['name'], 'Tesla'); + expect(r.data.self.uri, uri); + expect(r.data.included.length, 5); + expect(r.data.included.first.type, 'cities'); + expect(r.data.included.first.attributes['name'], 'Palo Alto'); + expect(r.data.included.last.type, 'models'); + expect(r.data.included.last.attributes['name'], 'Model 3'); + }); + test('404 on type', () async { final r = await client.fetchResource(Url.resource('unicorns', '1')); expect(r.status, 404); @@ -164,7 +184,7 @@ void main() async { final r = await client.fetchToMany(uri); expect(r.status, 200); expect(r.isSuccessful, true); - expect(r.data.identifiers.first.type, 'models'); + expect(r.data.toIdentifiers().first.type, 'models'); expect(r.data.self.uri, uri); expect(r.data.related.uri.toString(), 'http://localhost:8080/companies/1/models'); @@ -175,7 +195,7 @@ void main() async { final r = await client.fetchToMany(uri); expect(r.status, 200); expect(r.isSuccessful, true); - expect(r.data.identifiers, isEmpty); + expect(r.data.toIdentifiers(), isEmpty); expect(r.data.self.uri, uri); expect(r.data.related.uri.toString(), 'http://localhost:8080/companies/3/models'); @@ -187,7 +207,7 @@ void main() async { expect(r.status, 200); expect(r.isSuccessful, true); expect(r.data, TypeMatcher()); - expect((r.data as ToMany).identifiers.first.type, 'models'); + expect((r.data as ToMany).toIdentifiers().first.type, 'models'); expect(r.data.self.uri, uri); expect(r.data.related.uri.toString(), 'http://localhost:8080/companies/1/models'); diff --git a/test/functional/update_test.dart b/test/functional/update_test.dart index c9e2390..55bf9cf 100644 --- a/test/functional/update_test.dart +++ b/test/functional/update_test.dart @@ -211,7 +211,7 @@ void main() async { test('204 No Content', () async { final url = Url.relationship('companies', '1', 'models'); final r0 = await client.fetchToMany(url); - final original = r0.data.identifiers.map((_) => _.id); + final original = r0.data.toIdentifiers().map((_) => _.id); expect(original, ['1', '2', '3', '4']); final r1 = await client.replaceToMany( @@ -219,7 +219,7 @@ void main() async { expect(r1.status, 204); final r2 = await client.fetchToMany(url); - final updated = r2.data.identifiers.map((_) => _.id); + final updated = r2.data.toIdentifiers().map((_) => _.id); expect(updated, ['5', '6']); }); }); @@ -243,14 +243,14 @@ void main() async { test('200 OK', () async { final url = Url.relationship('companies', '1', 'models'); final r0 = await client.fetchToMany(url); - final original = r0.data.identifiers.map((_) => _.id); + final original = r0.data.toIdentifiers().map((_) => _.id); expect(original, ['1', '2', '3', '4']); final r1 = await client.addToMany( url, [Identifier('models', '1'), Identifier('models', '5')]); expect(r1.status, 200); - final updated = r1.data.identifiers.map((_) => _.id); + final updated = r1.data.toIdentifiers().map((_) => _.id); expect(updated, ['1', '2', '3', '4', '5']); }); }); diff --git a/test/unit/document_test.dart b/test/unit/document_test.dart index dfe6ca6..8aae120 100644 --- a/test/unit/document_test.dart +++ b/test/unit/document_test.dart @@ -1,3 +1,4 @@ +@TestOn('vm') import 'package:json_api/document.dart'; import 'package:json_matcher/json_matcher.dart'; import 'package:test/test.dart'; @@ -6,7 +7,7 @@ void main() { group('Document', () { group('JSON Conversion', () { test('Can convert a single resource', () { - final doc = Document.data(ResourceData(ResourceJson('foo', 'bar'))); + final doc = Document(ResourceData(ResourceObject('foo', 'bar'))); expect( doc, diff --git a/test/unit/example.json b/test/unit/example.json new file mode 100644 index 0000000..3ccb7fc --- /dev/null +++ b/test/unit/example.json @@ -0,0 +1,97 @@ +{ + "links": { + "self": "http://example.com/articles", + "next": "http://example.com/articles?page=2", + "last": "http://example.com/articles?page=10" + }, + "data": [ + { + "type": "articles", + "id": "1", + "attributes": { + "title": "JSON:API paints my bikeshed!" + }, + "relationships": { + "author": { + "links": { + "self": "http://example.com/articles/1/relationships/author", + "related": "http://example.com/articles/1/author" + }, + "data": { + "type": "people", + "id": "9" + } + }, + "comments": { + "links": { + "self": "http://example.com/articles/1/relationships/comments", + "related": "http://example.com/articles/1/comments" + }, + "data": [ + { + "type": "comments", + "id": "5" + }, + { + "type": "comments", + "id": "12" + } + ] + } + }, + "links": { + "self": "http://example.com/articles/1" + } + } + ], + "included": [ + { + "type": "people", + "id": "9", + "attributes": { + "firstName": "Dan", + "lastName": "Gebhardt", + "twitter": "dgeb" + }, + "links": { + "self": "http://example.com/people/9" + } + }, + { + "type": "comments", + "id": "5", + "attributes": { + "body": "First!" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "2" + } + } + }, + "links": { + "self": "http://example.com/comments/5" + } + }, + { + "type": "comments", + "id": "12", + "attributes": { + "body": "I like XML better" + }, + "relationships": { + "author": { + "data": { + "type": "people", + "id": "9" + } + } + }, + "links": { + "self": "http://example.com/comments/12" + } + } + ] +} \ No newline at end of file diff --git a/test/unit/parser_test.dart b/test/unit/parser_test.dart new file mode 100644 index 0000000..acf0037 --- /dev/null +++ b/test/unit/parser_test.dart @@ -0,0 +1,28 @@ +@TestOn('vm') +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_api/parser.dart'; +import 'package:json_matcher/json_matcher.dart'; +import 'package:test/test.dart'; + +void main() { + final parser = JsonApiParser(); + group('Parser', () { + try { + test('Can parse the example document', () { + // This is a slightly modified example from the JSON:API site + // See: https://jsonapi.org/ + final jsonString = + new File('test/unit/example.json').readAsStringSync(); + final jsonObject = json.decode(jsonString); + final doc = parser.parseDocument( + jsonObject, parser.parseResourceCollectionData); + + expect(doc, encodesToJson(jsonObject)); + }); + } catch (e, s) { + print(s); + } + }); +}