diff --git a/.cider.yaml b/.cider.yaml deleted file mode 100644 index 443e15cd..00000000 --- a/.cider.yaml +++ /dev/null @@ -1,2 +0,0 @@ -changelog: - diff_link_template: 'https://github.com/f3ath/json-api-dart/compare/%from%...%to%' diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 446315aa..532f2059 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -15,12 +15,14 @@ jobs: image: google/dart:latest steps: - - uses: actions/checkout@v2 - - name: Install dependencies - run: pub get - - name: Formatting - run: dartfmt -n --set-exit-if-changed . - - name: Analyzer - run: dartanalyzer --fatal-infos --fatal-warnings lib test example - - name: Run tests - run: pub run test + - uses: actions/checkout@v2 + - name: Install dependencies + run: dart pub get + - name: Format + run: dartfmt --dry-run --set-exit-if-changed lib test + - name: Analyzer + run: dart analyze --fatal-infos --fatal-warnings + - name: Tests + run: dart test --coverage=.coverage -j1 + - name: Coverage + run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib --packages=.packages | dart run check_coverage:check_coverage diff --git a/.gitignore b/.gitignore index 0f158446..d30eaee0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,5 @@ pubspec.lock # If you don't generate documentation locally you can remove this line. doc/api/ -# Generated by test_coverage -test/.test_coverage.dart -coverage -coverage_badge.svg +# Generated by coverage +/.coverage/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2b9eacee..00000000 --- a/.travis.yml +++ /dev/null @@ -1,11 +0,0 @@ -language: dart -dart: - - stable - - dev - - "2.7.0" -dart_task: - - test: --platform vm - - test: --platform chrome - - test: --platform firefox - - dartfmt: true - - dartanalyzer: --fatal-infos --fatal-warnings lib test example diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f1068f..bd152942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Sound null-safety support. + +### Changed +- Everything. Again. This is another major **BC-breaking** rework. Please refer to + the API documentation, examples and tests. + ## [4.3.0] - 2020-07-30 ### Added - `meta` parameter for createResourceAt() @@ -26,24 +33,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Filtering support for collections ([pr](https://github.com/f3ath/json-api-dart/pull/97)) ### Changed -- The client will not attempt to decode the body of the HTTP response with error status if the correct Content-Type -is missing. Before in such cases a `FormatException` would be thrown ([pr](https://github.com/f3ath/json-api-dart/pull/98)) +- The client will not attempt to decode the body of the HTTP response with error status if the correct Content-Type + is missing. Before in such cases a `FormatException` would be thrown ([pr](https://github.com/f3ath/json-api-dart/pull/98)) ## [4.1.0] - 2020-05-28 ### Changed - `DartHttp` now defaults to utf8 if no encoding is specified in the response. -## [4.0.0] - 2020-05-26 +## [4.0.0] - 2020-02-29 ### Changed -- This is a major **BC-breaking** rework which affected pretty much all areas. +- Everything. This is a major **BC-breaking** rework which affected pretty much all areas. Please refer to the documentation. + +## [3.2.3] - 2020-08-06 +### Fixed +- Call toJson() on resourceObject when serializing ([#84](https://github.com/f3ath/json-api-dart/pull/84)) + ## [3.2.2] - 2020-01-07 ### Fixed -- Can not decode related resource which is null ([issue](https://github.com/f3ath/json-api-dart/issues/77)) +- Can not decode related resource which is null ([#77](https://github.com/f3ath/json-api-dart/issues/77)) ## [3.2.1] - 2020-01-01 ### Fixed -- Incorrect URL in the example in the Client documentation ([issue](https://github.com/f3ath/json-api-dart/issues/74)) +- Incorrect URL in the example in the Client documentation ([#74](https://github.com/f3ath/json-api-dart/issues/74)) ## [3.2.0] - 2019-12-30 ### Added @@ -69,12 +81,13 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https ## [3.0.0] - 2019-12-17 ### Added -- Support for custom non-standard links ([issue](https://github.com/f3ath/json-api-dart/issues/61)) +- Support for custom non-standard links ([#61](https://github.com/f3ath/json-api-dart/issues/61)) - Client supports `jsonapi` key in outgoing requests. - `Document.contentType` constant. - `IdentifierObject.fromIdentifier` factory method ### Changed +Most of the changes are **BC-BREAKING**. - `URLBuilder` was renamed to `UrlFactory`. - `DocumentBuilder` was split into `ServerDocumentFactory` and `ClientDocumentFactory`. Some methods were renamed. - Static `decodeJson` methods were renamed to `fromJson`. @@ -84,7 +97,7 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https - The signature of `Controller`. - `Server` was renamed to `JsonApiServer`. - `Pagination` was renamed to `PaginationStrategy`. - + ### Removed - (Server) `ResourceTarget`, `CollectionTarget`, `RelationshipTarget` classes. - `QueryParameters` interface. @@ -93,7 +106,7 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https ## [2.1.0] - 2019-12-04 ### Added -- `onHttpCall` hook to enable raw http request/response logging ([issue](https://github.com/f3ath/json-api-dart/issues/60)). +- `onHttpCall` hook to enable raw http request/response logging ([#60](https://github.com/f3ath/json-api-dart/issues/60)). ## [2.0.3] - 2019-09-29 ### Fixed @@ -101,27 +114,29 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https ## [2.0.2] - 2019-08-01 ### Fixed -- Meta members have incorrect type ([issue](https://github.com/f3ath/json-api-dart/issues/54)). +- Meta members have incorrect type ([#54](https://github.com/f3ath/json-api-dart/issues/54)). ## [2.0.1] - 2019-07-12 ### Fixed - Readme example was outdated. ## [2.0.0] - 2019-07-12 + ### Changed - This package now consolidates the Client, the Server and the Document in one single library. -It does not depend on `json_api_document` and `json_api_server` anymore, please remove these packages -from your `pubspec.yaml`. + It does not depend on `json_api_document` and `json_api_server` anymore, please remove these packages + from your `pubspec.yaml`. - The min Dart SDK version bumped to `2.3.0` - The Client requires an instance of HttpClient to be passed to the constructor explicitly. - Both the Document and the Server have been refactored with lots of **BREAKING CHANGES**. -See the examples and the functional tests for details. + See the examples and the functional tests for details. - Meta properties are not defensively copied, but set directly. Meta property behavior is unified across -the Document model. + the Document model. ### Removed - `JsonApiParser` is removed. Use the static `decodeJson` methods in the corresponding classes instead. + ## [1.0.1] - 2019-04-05 ### Fixed - Bumped the dependencies versions due to a bug in `json_api_document`. @@ -136,54 +151,55 @@ the Document model. - Renamed `client.removeToOne(...)` to `client.deleteToOne(...)` ## [0.5.0] - 2019-03-21 -### Added -- Related collection pagination -- Async operations support - ### Changed - More BC-breaking changes in the Server ### Fixed -- Location headers generated incorrectly +- Location headers were incorrectly generated by Server -## [0.4.0] - 2019-03-17 ### Added -- Compound documents support in Client (Server-side support is still very limited) +- Related collection pagination +- Async operations support +## [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 + ### Added - Resource attributes update - Resource relationships update -### Changed -- Huge BC-breaking refactoring in the Document model which propagated everywhere - ## [0.2.0] - 2019-03-01 ### Added - Improved ResourceController error handling - Resource creation - Resource deletion -## 0.1.0 - 2019-02-27 +## [0.1.0] - 2019-02-27 ### Added - Client: fetch resources, collections, related resources and relationships -[Unreleased]: https://github.com/f3ath/json-api-dart/compare/4.3.0...HEAD +[Unreleased]: https://github.com/f3ath/json-api-dart/compare/4.3.0..HEAD [4.3.0]: https://github.com/f3ath/json-api-dart/compare/4.2.2...4.3.0 [4.2.2]: https://github.com/f3ath/json-api-dart/compare/4.2.1...4.2.2 [4.2.1]: https://github.com/f3ath/json-api-dart/compare/4.2.0...4.2.1 [4.2.0]: https://github.com/f3ath/json-api-dart/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/f3ath/json-api-dart/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.2...4.0.0 -[3.2.2]: https://github.com/f3ath/json-api-dart/compare/3.2.1...3.2.2 +[3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2..3.2.3 +[3.2.2]: https://github.com/f3ath/json-api-dart/compare/3.2.1..3.2.2 [3.2.1]: https://github.com/f3ath/json-api-dart/compare/3.2.0...3.2.1 [3.2.0]: https://github.com/f3ath/json-api-dart/compare/3.1.0...3.2.0 [3.1.0]: https://github.com/f3ath/json-api-dart/compare/3.0.0...3.1.0 @@ -199,4 +215,5 @@ the Document model. [0.5.0]: https://github.com/f3ath/json-api-dart/compare/0.4.0...0.5.0 [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 \ No newline at end of file +[0.2.0]: https://github.com/f3ath/json-api-dart/compare/0.1.0...0.2.0 +[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0 diff --git a/LICENSE b/LICENSE index 4742f3e8..0683a86c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Alexey Karapetov +Copyright (c) 2019-2021 Alexey Karapetov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index a13d2c0b..7ab02beb 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,6 @@ -# JSON:API for Dart/Flutter +# JSON:API Client and Server for Dart/Flutter. Version 5. -[JSON:API] is a specification for building APIs in JSON. +[JSON:API] is a specification for building JSON APIs. The documentation is a work-in-progress. -This package consists of several libraries: -- The [Document library] is the core of this package. It describes the JSON:API document structure -- The [Client library] is a JSON:API Client for Flutter, Web and Server-side -- The [Server library] is a framework-agnostic JSON:API server implementation -- The [HTTP library] is a thin abstraction of HTTP requests and responses -- The [Query library] builds and parses the query parameters (page, sorting, filtering, etc) -- The [Routing library] builds and matches URIs for resources, collections, and relationships - -## Document model -The main concept of JSON:API model is the [Resource]. -Resources are passed between the client and the server in the form of a JSON-encodable [Document]. - -## Client -[JsonApiClient] is an implementation of the JSON:API client supporting all features of the JSON:API standard: -- fetching resources and collections (both primary and related) -- creating resources -- deleting resources -- updating resource attributes and relationships -- direct modification of relationships (both to-one and to-many) -- [async processing](https://jsonapi.org/recommendations/#asynchronous-processing) - -The client returns back a [Response] which contains the HTTP status code, headers and the JSON:API [Document]. - -Sometimes the request URIs can be inferred from the context. -For such cases you may use the [RoutingClient] which is a wrapper over the [JsonApiClient] capable of inferring the URIs. -The [RoutingClient] requires an instance of [RouteFactory] to be provided. - -[JsonApiClient] itself does not make actual HTTP calls. -Instead, it calls the underlying [HttpHandler] which acts as an HTTP client (must be passed to the constructor). -The library comes with an implementation of [HttpHandler] called [DartHttp] which uses the Dart's native http client. - -## Server -This is a framework-agnostic library for implementing a JSON:API server. -It may be used on its own (a fully functional server implementation is included) or as a set of independent components. - -### Request lifecycle -#### HTTP request -The server receives an incoming [HttpRequest] containing the HTTP headers and the body represented as a String. -When this request is received, your server may decide to check for authentication or other non-JSON:API concerns -to prepare for the request processing, or it may decide to fail out with an error response. - -#### JSON:API request -The [RequestConverter] is then used to convert the HTTP request to a [JsonApiRequest]. -[JsonApiRequest] abstracts the JSON:API specific details, -such as the request target (a collection, a resource or a relationship) and the decoded body (e.g. [Resource] or [Identifier]). -At this point it is possible to determine whether the request is a valid JSON:API request and to read the decoded payload. -You may perform some application-specific logic, e.g. check for authentication. -Each implementation of [JsonApiRequest] has the `handleWith()` method to dispatch a call to the right method of the [Controller]. - -#### Controller -The [Controller] consolidates all methods to process JSON:API requests. -Every controller method must return an instance of [JsonApiResponse] (or another type, the controller is generic). -This library comes with a particular implementation of the [Controller] called [RepositoryController]. -The [RepositoryController] takes care of all JSON:API specific logic (e.g. validation, filtering, resource -inclusion) and translates the JSON:API requests to calls to a resource [Repository]. - -#### Repository (optional) -The [Repository] is an interface separating the data storage concerns from the specifics of the API. - -#### JSON:API response -When an instance of [JsonApiResponse] is returned from the controller, the [ResponseConverter] -converts it to an [HttpResponse]. -The converter takes care of JSON:API transport-layer concerns. -In particular, it: -- generates a proper [Document], including the HATEOAS links or meta-data -- encodes the document to JSON string -- sets the response headers - -#### HTTP response -The generated [HttpResponse] is sent to the underlying HTTP system. -This is the final step. - -## HTTP -This library is used by both the Client and the Server to abstract out the HTTP protocol specifics. -The [HttpHandler] interface turns an [HttpRequest] to an [HttpResponse]. -The Client consumes an implementation of [HttpHandler] as a low-level HTTP client. -The Server is itself an implementation of [HttpHandler]. - -## Query -This is a set of classes for building avd parsing some URL query parameters defined in the standard. -- [Fields] for [Sparse fieldsets] -- [Include] for [Inclusion of Related Resources] -- [Page] for [Collection Pagination] -- [Sort] for [Collection Sorting] - -## Routing -Defines the logic for constructing and matching URLs for resources, collections and relationships. -The URL construction is used by both the Client (See [RoutingClient] for instance) and the Server libraries. -The [StandardRouting] implements the [Recommended URL design]. [JSON:API]: https://jsonapi.org -[Sparse fieldsets]: https://jsonapi.org/format/#fetching-sparse-fieldsets -[Inclusion of Related Resources]: https://jsonapi.org/format/#fetching-includes -[Collection Pagination]: https://jsonapi.org/format/#fetching-pagination -[Collection Sorting]: https://jsonapi.org/format/#fetching-sorting -[Recommended URL design]: https://jsonapi.org/recommendations/#urls - -[Client library]: https://pub.dev/documentation/json_api/latest/client/client-library.html -[Server library]: https://pub.dev/documentation/json_api/latest/server/server-library.html -[Document library]: https://pub.dev/documentation/json_api/latest/document/document-library.html -[Query library]: https://pub.dev/documentation/json_api/latest/query/query-library.html -[Routing library]: https://pub.dev/documentation/json_api/latest/uri_design/uri_design-library.html -[HTTP library]: https://pub.dev/documentation/json_api/latest/http/http-library.html - - -[Resource]: https://pub.dev/documentation/json_api/latest/document/Resource-class.html -[Identifier]: https://pub.dev/documentation/json_api/latest/document/Identifier-class.html -[Document]: https://pub.dev/documentation/json_api/latest/document/Document-class.html -[JsonApiClient]: https://pub.dev/documentation/json_api/latest/client/JsonApiClient-class.html - - -[Response]: https://pub.dev/documentation/json_api/latest/client/Response-class.html -[RoutingClient]: https://pub.dev/documentation/json_api/latest/client/RoutingClient-class.html -[DartHttp]: https://pub.dev/documentation/json_api/latest/client/DartHttp-class.html - - -[RequestConverter]: https://pub.dev/documentation/json_api/latest/server/RequestConverter-class.html -[JsonApiResponse]: https://pub.dev/documentation/json_api/latest/server/JsonApiResponse-class.html -[ResponseConverter]: https://pub.dev/documentation/json_api/latest/server/ResponseConverter-class.html -[JsonApiRequest]: https://pub.dev/documentation/json_api/latest/server/JsonApiRequest-class.html -[Controller]: https://pub.dev/documentation/json_api/latest/server/Controller-class.html -[Repository]: https://pub.dev/documentation/json_api/latest/server/Repository-class.html -[RepositoryController]: https://pub.dev/documentation/json_api/latest/server/RepositoryController-class.html - - -[HttpHandler]: https://pub.dev/documentation/json_api/latest/http/HttpHandler-class.html -[HttpRequest]: https://pub.dev/documentation/json_api/latest/http/HttpRequest-class.html -[HttpResponse]: https://pub.dev/documentation/json_api/latest/http/HttpResponse-class.html - - -[Fields]: https://pub.dev/documentation/json_api/latest/query/Fields-class.html -[Include]: https://pub.dev/documentation/json_api/latest/query/Include-class.html -[Page]: https://pub.dev/documentation/json_api/latest/query/Page-class.html -[Sort]: https://pub.dev/documentation/json_api/latest/query/Sort-class.html - - -[RouteFactory]: https://pub.dev/documentation/json_api/latest/routing/RouteFactory-class.html -[StandardRouting]: https://pub.dev/documentation/json_api/latest/routing/StandardRouting-class.html diff --git a/doc/schema.json b/doc/schema.json index a06f524b..615a1a11 100644 --- a/doc/schema.json +++ b/doc/schema.json @@ -13,7 +13,6 @@ "$ref": "#/definitions/info" } ], - "definitions": { "success": { "type": "object", @@ -95,7 +94,6 @@ }, "additionalProperties": false }, - "meta": { "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", "type": "object", @@ -196,7 +194,6 @@ } ] }, - "attributes": { "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", "type": "object", @@ -207,7 +204,6 @@ }, "additionalProperties": false }, - "relationships": { "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", "type": "object", @@ -233,9 +229,21 @@ } }, "anyOf": [ - {"required": ["data"]}, - {"required": ["meta"]}, - {"required": ["links"]} + { + "required": [ + "data" + ] + }, + { + "required": [ + "meta" + ] + }, + { + "required": [ + "links" + ] + } ], "additionalProperties": false } @@ -291,34 +299,53 @@ "first": { "description": "The first page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "last": { "description": "The last page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "prev": { "description": "The previous page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] }, "next": { "description": "The next page of data", "oneOf": [ - { "type": "string", "format": "uri-reference" }, - { "type": "null" } + { + "type": "string", + "format": "uri-reference" + }, + { + "type": "null" + } ] } } }, - "jsonapi": { "description": "An object describing the server's implementation", "type": "object", @@ -332,7 +359,6 @@ }, "additionalProperties": false }, - "error": { "type": "object", "properties": { diff --git a/example/README.md b/example/README.md deleted file mode 100644 index 9205e925..00000000 --- a/example/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Client-server interaction example -Run the server: -``` -$ dart example/server.dart -Listening on http://localhost:8080 - -``` -This will start a simple JSON:API server at localhost:8080. It supports 2 resource types: -- [writers](http://localhost:8080/writers) -- [books](http://localhost:8080/books) - -Try opening these links in your browser, you should see empty collections. - -While the server is running, try the client script: -``` -$ dart example/client.dart -POST http://localhost:8080/writers -204 -POST http://localhost:8080/books -204 -GET http://localhost:8080/books/2?include=authors -200 -Book: Resource(books:2 {title: Refactoring}) -Author: Resource(writers:1 {name: Martin Fowler}) -``` -This will create resources in those collections. Try the the following links: - -- [writer](http://localhost:8080/writers/1) -- [book](http://localhost:8080/books/2) -- [book and its author](http://localhost:8080/books/2?include=authors) \ No newline at end of file diff --git a/example/client.dart b/example/client.dart index 35a0defd..da5f693f 100644 --- a/example/client.dart +++ b/example/client.dart @@ -1,52 +1,14 @@ -import 'package:http/http.dart'; import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; -/// This example shows how to use the JSON:API client. -/// Run the server first! +// START THE SERVER FIRST! void main() async { - /// Use the standard routing - final routing = StandardRouting(Uri.parse('http://localhost:8080')); - - /// Create the HTTP client. We're using Dart's native client. - /// Do not forget to call [Client.close] when you're done using it. - final httpClient = Client(); - - /// We'll use a logging handler to show the requests and responses. - final httpHandler = LoggingHttpHandler(DartHttp(httpClient), - onRequest: (r) => print('${r.method} ${r.uri}'), - onResponse: (r) => print('${r.statusCode}')); - - /// The JSON:API client - final client = RoutingClient(JsonApiClient(httpHandler), routing); - - /// Create the first resource. - await client.createResource( - Resource('writers', '1', attributes: {'name': 'Martin Fowler'})); - - /// Create the second resource. - await client.createResource(Resource('books', '2', attributes: { - 'title': 'Refactoring' - }, toMany: { - 'authors': [Identifier('writers', '1')] - })); - - /// Fetch the book, including its authors. - final response = await client.fetchResource('books', '2', - parameters: Include(['authors'])); - - /// Extract the primary resource. - final book = response.data.unwrap(); - - /// Extract the included resource. - final author = response.data.included.first.unwrap(); - - print('Book: $book'); - print('Author: $author'); - - /// Do not forget to always close the HTTP client. - httpClient.close(); + final host = 'localhost'; + final port = 8080; + final uri = Uri(scheme: 'http', host: host, port: port); + final client = RoutingClient(StandardUriDesign(uri)); + final response = await client.fetchCollection('colors'); + response.collection.map((resource) => resource.attributes).forEach((attr) { + print('${attr['name']} - ${attr['red']}:${attr['green']}:${attr['blue']}'); + }); } diff --git a/example/server.dart b/example/server.dart index 8d975a9a..fbb647bd 100644 --- a/example/server.dart +++ b/example/server.dart @@ -1,39 +1,83 @@ import 'dart:io'; +import 'package:json_api/document.dart'; import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; import 'package:json_api/server.dart'; +import 'package:uuid/uuid.dart'; -/// This example shows how to run a simple JSON:API server using the built-in -/// HTTP server (dart:io). -/// Run it: `dart example/server.dart` -void main() async { - /// Listening on this port - final port = 8080; - - /// Listening on the localhost - final address = 'localhost'; +import 'server/in_memory_repo.dart'; +import 'server/json_api_server.dart'; +import 'server/repository.dart'; +import 'server/repository_controller.dart'; +import 'server/try_catch_handler.dart'; - /// Resource repository supports two kind of entities: writers and books - final repo = InMemoryRepository({'writers': {}, 'books': {}}); - - /// Controller provides JSON:API interface to the repository - final controller = RepositoryController(repo); +Future main() async { + final host = 'localhost'; + final port = 8080; + final resources = ['colors']; + final repo = InMemoryRepo(resources); + await addColors(repo); + final controller = RepositoryController(repo, Uuid().v4); + HttpHandler handler = Router(controller, StandardUriDesign.matchTarget); + handler = TryCatchHandler(handler, onError: convertError); + handler = LoggingHandler(handler, + onRequest: (r) => print('${r.method.toUpperCase()} ${r.uri}'), + onResponse: (r) => print('${r.statusCode}')); + final server = JsonApiServer(handler, host: host, port: port); - /// The JSON:API server routes requests to the controller - final jsonApiServer = JsonApiServer(controller); + ProcessSignal.sigint.watch().listen((event) async { + await server.stop(); + exit(0); + }); - /// We will be logging the requests and responses to the console - final loggingJsonApiServer = LoggingHttpHandler(jsonApiServer, - onRequest: (r) => print('${r.method} ${r.uri}'), - onResponse: (r) => print('${r.statusCode}')); + await server.start(); - /// The handler for the built-in HTTP server - final serverHandler = DartServer(loggingJsonApiServer); + print('The server is listening at $host:$port.' + ' Try opening the following URL(s) in your browser:'); + resources.forEach((resource) { + print('http://$host:$port/$resource'); + }); +} - /// Start the server - final server = await HttpServer.bind(address, port); - print('Listening on ${Uri(host: address, port: port, scheme: 'http')}'); +Future addColors(Repository repo) async { + final models = { + {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114}, + {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203}, + {'name': 'Lime', 'r': 0, 'g': 255, 'b': 0}, + {'name': 'Peru', 'r': 205, 'g': 133, 'b': 63}, + }.map((color) => Model(Uuid().v4()) + ..attributes['name'] = color['name'] + ..attributes['red'] = color['r'] + ..attributes['green'] = color['g'] + ..attributes['blue'] = color['b']); + for (final model in models) { + await repo.persist('colors', model); + } +} - /// Each HTTP request will be processed by the handler - await server.forEach(serverHandler); +Future convertError(dynamic error) async { + if (error is MethodNotAllowed) { + return Response.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return Response.badRequest(); + } + if (error is CollectionNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); + } + if (error is ResourceNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); + } + if (error is RelationshipNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); + } + return Response(500, + document: OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); } diff --git a/example/server/cors_handler.dart b/example/server/cors_handler.dart new file mode 100644 index 00000000..e42f2efa --- /dev/null +++ b/example/server/cors_handler.dart @@ -0,0 +1,31 @@ +import 'package:json_api/http.dart'; + +class CorsHandler implements HttpHandler { + CorsHandler(this._inner); + + final HttpHandler _inner; + + @override + Future handle(HttpRequest request) async { + final headers = { + 'Access-Control-Allow-Origin': request.headers['origin'] ?? '*', + 'Access-Control-Expose-Headers': 'Location', + }; + + if (request.isOptions) { + const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']; + return HttpResponse(204) + ..headers.addAll({ + ...headers, + 'Access-Control-Allow-Methods': + // TODO: Make it work for all browsers. Why is toUpperCase() needed? + request.headers['Access-Control-Request-Method']?.toUpperCase() ?? + methods.join(', '), + 'Access-Control-Allow-Headers': + request.headers['Access-Control-Request-Headers'] ?? '*', + }); + } + return await _inner.handle(request) + ..headers.addAll(headers); + } +} diff --git a/example/server/demo_handler.dart b/example/server/demo_handler.dart new file mode 100644 index 00000000..9f973cd8 --- /dev/null +++ b/example/server/demo_handler.dart @@ -0,0 +1,53 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; + +import 'in_memory_repo.dart'; +import 'repository.dart'; +import 'repository_controller.dart'; +import 'try_catch_handler.dart'; + +class DemoHandler extends LoggingHandler { + DemoHandler({ + Iterable types = const ['users', 'posts', 'comments'], + Function(HttpRequest request)? onRequest, + Function(HttpResponse response)? onResponse, + }) : super( + TryCatchHandler( + Router(RepositoryController(InMemoryRepo(types), _id), + StandardUriDesign.matchTarget), + onError: _onError), + onRequest: onRequest, + onResponse: onResponse); + + static Future _onError(dynamic error) async { + if (error is MethodNotAllowed) { + return Response.methodNotAllowed(); + } + if (error is UnmatchedTarget) { + return Response.badRequest(); + } + if (error is CollectionNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'CollectionNotFound')])); + } + if (error is ResourceNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'ResourceNotFound')])); + } + if (error is RelationshipNotFound) { + return Response.notFound( + OutboundErrorDocument([ErrorObject(title: 'RelationshipNotFound')])); + } + return Response(500, + document: OutboundErrorDocument([ + ErrorObject( + title: 'Error: ${error.runtimeType}', detail: error.toString()) + ])); + } +} + +int _counter = 0; + +String _id() => (_counter++).toString(); diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart new file mode 100644 index 00000000..d6639514 --- /dev/null +++ b/example/server/in_memory_repo.dart @@ -0,0 +1,74 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/nullable.dart'; + +import 'repository.dart'; + +class InMemoryRepo implements Repository { + InMemoryRepo(Iterable types) { + types.forEach((_) { + _storage[_] = {}; + }); + } + + final _storage = >{}; + + @override + Stream fetchCollection(String type) { + return Stream.fromIterable(_collection(type).values); + } + + @override + Future fetch(String type, String id) async { + return _model(type, id); + } + + @override + Future persist(String type, Model model) async { + _collection(type)[model.id] = model; + } + + @override + Stream addMany( + String type, String id, String rel, Iterable ids) { + final many = _many(type, id, rel); + many.addAll(ids.map(Ref.of)); + return Stream.fromIterable(many); + } + + @override + Future delete(String type, String id) async { + _collection(type).remove(id); + } + + @override + Future update(String type, String id, ModelProps props) async { + _model(type, id).setFrom(props); + } + + @override + Future replaceOne( + String type, String id, String rel, Identity? one) async { + _model(type, id).one[rel] = nullable(Ref.of)(one); + } + + @override + Stream deleteMany( + String type, String id, String rel, Iterable many) => + Stream.fromIterable(_many(type, id, rel)..removeAll(many.map(Ref.of))); + + @override + Stream replaceMany( + String type, String id, String rel, Iterable many) => + Stream.fromIterable(_many(type, id, rel) + ..clear() + ..addAll(many.map(Ref.of))); + + Map _collection(String type) => + (_storage[type] ?? (throw CollectionNotFound())); + + Model _model(String type, String id) => + _collection(type)[id] ?? (throw ResourceNotFound()); + + Set _many(String type, String id, String rel) => + _model(type, id).many[rel] ?? (throw RelationshipNotFound()); +} diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart new file mode 100644 index 00000000..f53b7e83 --- /dev/null +++ b/example/server/json_api_server.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:json_api/http.dart'; + +class JsonApiServer { + JsonApiServer( + this._handler, { + this.host = 'localhost', + this.port = 8080, + }); + + /// Server host name + final String host; + + /// Server port + final int port; + + final HttpHandler _handler; + HttpServer? _server; + + /// Server uri + Uri get uri => Uri(scheme: 'http', host: host, port: port); + + /// starts the server + Future start() async { + if (_server != null) return; + try { + _server = await _createServer(); + } on Exception { + await stop(); + rethrow; + } + } + + /// Stops the server + Future stop({bool force = false}) async { + await _server?.close(force: force); + _server = null; + } + + Future _createServer() async { + final server = await HttpServer.bind(host, port); + server.listen((request) async { + final headers = {}; + request.headers.forEach((k, v) => headers[k] = v.join(',')); + final response = await _handler.handle(HttpRequest( + request.method, request.requestedUri, + body: await request.cast>().transform(utf8.decoder).join()) + ..headers.addAll(headers)); + response.headers.forEach(request.response.headers.add); + request.response.statusCode = response.statusCode; + request.response.write(response.body); + await request.response.close(); + }); + return server; + } +} diff --git a/example/server/relationship_node.dart b/example/server/relationship_node.dart new file mode 100644 index 00000000..44bf41f4 --- /dev/null +++ b/example/server/relationship_node.dart @@ -0,0 +1,26 @@ +/// Relationship tree node +class RelationshipNode { + RelationshipNode(this.name); + + static Iterable forest(Iterable relationships) { + final root = RelationshipNode(''); + relationships + .map((rel) => rel.trim().split('.').map((e) => e.trim())) + .forEach(root.add); + return root.children; + } + + /// The name of the relationship + final String name; + + Iterable get children => _map.values; + + final _map = {}; + + /// Adds the chain to the tree + void add(Iterable chain) { + if (chain.isEmpty) return; + final key = chain.first; + _map[key] = (_map[key] ?? RelationshipNode(key))..add(chain.skip(1)); + } +} diff --git a/example/server/repository.dart b/example/server/repository.dart new file mode 100644 index 00000000..2cd63ed6 --- /dev/null +++ b/example/server/repository.dart @@ -0,0 +1,121 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/nullable.dart'; + +abstract class Repository { + /// Fetches a collection. + /// Throws [CollectionNotFound]. + Stream fetchCollection(String type); + + /// Throws [ResourceNotFound] + Future fetch(String type, String id); + + /// Throws [CollectionNotFound]. + Future persist(String type, Model model); + + /// Add refs to a to-many relationship + /// Throws [CollectionNotFound]. + /// Throws [ResourceNotFound]. + /// Throws [RelationshipNotFound]. + Stream addMany( + String type, String id, String rel, Iterable refs); + + /// Delete the resource + Future delete(String type, String id); + + /// Updates the model + Future update(String type, String id, ModelProps props); + + Future replaceOne(String type, String id, String rel, Identity? ref); + + /// Deletes refs from the to-many relationship. + /// Returns the new actual refs. + Stream deleteMany( + String type, String id, String rel, Iterable refs); + + /// Replaces refs in the to-many relationship. + /// Returns the new actual refs. + Stream replaceMany( + String type, String id, String rel, Iterable refs); +} + +class CollectionNotFound implements Exception {} + +class ResourceNotFound implements Exception {} + +class RelationshipNotFound implements Exception {} + +class Ref with Identity { + Ref(this.type, this.id); + + static Ref of(Identity identity) => Ref(identity.type, identity.id); + + @override + final String type; + @override + final String id; + + @override + final hashCode = 0; + + @override + bool operator ==(Object other) => + other is Ref && type == other.type && id == other.id; +} + +class ModelProps { + static ModelProps fromResource(ResourceProperties res) { + final props = ModelProps(); + res.attributes.forEach((key, value) { + props.attributes[key] = value; + }); + res.relationships.forEach((key, value) { + if (value is ToOne) { + props.one[key] = nullable(Ref.of)(value.identifier); + return; + } + if (value is ToMany) { + props.many[key] = Set.of(value.map(Ref.of)); + return; + } + }); + return props; + } + + final attributes = {}; + final one = {}; + final many = >{}; + + void setFrom(ModelProps other) { + other.attributes.forEach((key, value) { + attributes[key] = value; + }); + other.one.forEach((key, value) { + one[key] = value; + }); + other.many.forEach((key, value) { + many[key] = {...value}; + }); + } +} + +/// A model of a resource. Essentially, this is the core of a resource object. +class Model extends ModelProps { + Model(this.id); + + final String id; + + Resource toResource(String type) { + final res = Resource(type, id); + attributes.forEach((key, value) { + res.attributes[key] = value; + }); + one.forEach((key, value) { + res.relationships[key] = + (value == null ? ToOne.empty() : ToOne(Identifier.of(value))); + }); + many.forEach((key, value) { + res.relationships[key] = ToMany(value.map(Identifier.of)); + }); + return res; + } +} diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart new file mode 100644 index 00000000..5e4100d1 --- /dev/null +++ b/example/server/repository_controller.dart @@ -0,0 +1,205 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/server.dart'; +import 'package:json_api/src/nullable.dart'; + +import 'relationship_node.dart'; +import 'repository.dart'; + +class RepositoryController implements Controller { + RepositoryController(this.repo, this.getId); + + final Repository repo; + + final IdGenerator getId; + + final design = StandardUriDesign.pathOnly; + + @override + Future fetchCollection(HttpRequest request, Target target) async { + final resources = await _fetchAll(target.type).toList(); + final doc = OutboundDataDocument.collection(resources) + ..links['self'] = Link(design.collection(target.type)); + final forest = RelationshipNode.forest(Include.fromUri(request.uri)); + for (final r in resources) { + await for (final r in _getAllRelated(r, forest)) { + doc.included.add(r); + } + } + return Response.ok(doc); + } + + @override + Future fetchResource( + HttpRequest request, ResourceTarget target) async { + final resource = await _fetchLinkedResource(target.type, target.id); + final doc = OutboundDataDocument.resource(resource) + ..links['self'] = Link(design.resource(target.type, target.id)); + final forest = RelationshipNode.forest(Include.fromUri(request.uri)); + await for (final r in _getAllRelated(resource, forest)) { + doc.included.add(r); + } + return Response.ok(doc); + } + + @override + Future createResource(HttpRequest request, Target target) async { + final res = (await _decode(request)).dataAsNewResource(); + final ref = Ref(res.type, res.id ?? getId()); + await repo.persist( + res.type, Model(ref.id)..setFrom(ModelProps.fromResource(res))); + if (res.id != null) { + return Response.noContent(); + } + final self = Link(design.resource(ref.type, ref.id)); + final resource = (await _fetchResource(ref.type, ref.id)) + ..links['self'] = self; + return Response.created( + OutboundDataDocument.resource(resource)..links['self'] = self, + self.uri.toString()); + } + + @override + Future addMany( + HttpRequest request, RelationshipTarget target) async { + final many = (await _decode(request)).asRelationship(); + final refs = await repo + .addMany(target.type, target.id, target.relationship, many) + .toList(); + return Response.ok( + OutboundDataDocument.many(ToMany(refs.map(Identifier.of)))); + } + + @override + Future deleteResource( + HttpRequest request, ResourceTarget target) async { + await repo.delete(target.type, target.id); + return Response.noContent(); + } + + @override + Future updateResource( + HttpRequest request, ResourceTarget target) async { + await repo.update(target.type, target.id, + ModelProps.fromResource((await _decode(request)).dataAsResource())); + return Response.noContent(); + } + + @override + Future replaceRelationship( + HttpRequest request, RelationshipTarget target) async { + final rel = (await _decode(request)).asRelationship(); + if (rel is ToOne) { + final ref = rel.identifier; + await repo.replaceOne(target.type, target.id, target.relationship, ref); + return Response.ok(OutboundDataDocument.one( + ref == null ? ToOne.empty() : ToOne(Identifier.of(ref)))); + } + if (rel is ToMany) { + final ids = await repo + .replaceMany(target.type, target.id, target.relationship, rel) + .map(Identifier.of) + .toList(); + return Response.ok(OutboundDataDocument.many(ToMany(ids))); + } + throw FormatException('Incomplete relationship'); + } + + @override + Future deleteMany( + HttpRequest request, RelationshipTarget target) async { + final rel = (await _decode(request)).asToMany(); + final ids = await repo + .deleteMany(target.type, target.id, target.relationship, rel) + .map(Identifier.of) + .toList(); + return Response.ok(OutboundDataDocument.many(ToMany(ids))); + } + + @override + Future fetchRelationship( + HttpRequest request, RelationshipTarget target) async { + final model = (await repo.fetch(target.type, target.id)); + + if (model.one.containsKey(target.relationship)) { + return Response.ok(OutboundDataDocument.one( + ToOne(nullable(Identifier.of)(model.one[target.relationship])))); + } + final many = model.many[target.relationship]; + if (many != null) { + final doc = OutboundDataDocument.many(ToMany(many.map(Identifier.of))); + return Response.ok(doc); + } + throw RelationshipNotFound(); + } + + @override + Future fetchRelated( + HttpRequest request, RelatedTarget target) async { + final model = await repo.fetch(target.type, target.id); + if (model.one.containsKey(target.relationship)) { + final related = + await nullable(_fetchRelatedResource)(model.one[target.relationship]); + final doc = OutboundDataDocument.resource(related); + return Response.ok(doc); + } + if (model.many.containsKey(target.relationship)) { + final many = model.many[target.relationship] ?? {}; + final doc = OutboundDataDocument.collection( + await _fetchRelatedCollection(many).toList()); + return Response.ok(doc); + } + throw RelationshipNotFound(); + } + + /// Returns a stream of related resources recursively + Stream _getAllRelated( + Resource resource, Iterable nodes) async* { + for (final node in nodes) { + await for (final r in _getRelated(resource, node.name)) { + yield r; + yield* _getAllRelated(r, node.children); + } + } + } + + /// Returns a stream of related resources + Stream _getRelated(Resource resource, String relationship) async* { + for (final _ in resource.relationships[relationship] ?? + (throw RelationshipNotFound())) { + yield await _fetchLinkedResource(_.type, _.id); + } + } + + /// Fetches and builds a resource object with a "self" link + Future _fetchLinkedResource(String type, String id) async { + return (await _fetchResource(type, id)) + ..links['self'] = Link(design.resource(type, id)); + } + + Stream _fetchAll(String type) => + repo.fetchCollection(type).map((_) => _.toResource(type)); + + /// Fetches and builds a resource object + Future _fetchResource(String type, String id) async { + return (await repo.fetch(type, id)).toResource(type); + } + + Future _fetchRelatedResource(Ref ref) { + return _fetchLinkedResource(ref.type, ref.id); + } + + Stream _fetchRelatedCollection(Iterable refs) async* { + for (final ref in refs) { + final r = await _fetchRelatedResource(ref); + if (r != null) yield r; + } + } + + Future _decode(HttpRequest r) async => + InboundDocument(await PayloadCodec().decode(r.body)); +} + +typedef IdGenerator = String Function(); diff --git a/example/server/try_catch_handler.dart b/example/server/try_catch_handler.dart new file mode 100644 index 00000000..a9a02405 --- /dev/null +++ b/example/server/try_catch_handler.dart @@ -0,0 +1,22 @@ +import 'package:json_api/http.dart'; + +class TryCatchHandler implements HttpHandler { + TryCatchHandler(this._inner, {this.onError = sendInternalServerError}); + + final HttpHandler _inner; + final Future Function(dynamic error) onError; + + static Future sendInternalServerError(dynamic e) async => + HttpResponse(500); + + @override + Future handle(HttpRequest request) async { + try { + return await _inner.handle(request); + } on HttpResponse catch (response) { + return response; + } catch (error) { + return await onError(error); + } + } +} diff --git a/lib/client.dart b/lib/client.dart index 38a2080f..56e69047 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -1,7 +1,14 @@ +/// JSON:API client for Flutter, browsers and vm. library client; -export 'package:json_api/src/client/dart_http.dart'; -export 'package:json_api/src/client/json_api_client.dart'; -export 'package:json_api/src/client/response.dart'; +export 'package:json_api/src/client/client.dart'; +export 'package:json_api/src/client/request.dart'; +export 'package:json_api/src/client/response/collection_fetched.dart'; +export 'package:json_api/src/client/response/related_resource_fetched.dart'; +export 'package:json_api/src/client/response/relationship_fetched.dart'; +export 'package:json_api/src/client/response/relationship_updated.dart'; +export 'package:json_api/src/client/response/request_failure.dart'; +export 'package:json_api/src/client/response/resource_created.dart'; +export 'package:json_api/src/client/response/resource_fetched.dart'; +export 'package:json_api/src/client/response/resource_updated.dart'; export 'package:json_api/src/client/routing_client.dart'; -export 'package:json_api/src/client/status_code.dart'; diff --git a/lib/document.dart b/lib/document.dart index ce38bee2..03bac6c9 100644 --- a/lib/document.dart +++ b/lib/document.dart @@ -1,15 +1,16 @@ +/// JSON:API Document model. library document; -export 'package:json_api/src/document/api.dart'; -export 'package:json_api/src/document/document.dart'; -export 'package:json_api/src/document/document_exception.dart'; export 'package:json_api/src/document/error_object.dart'; export 'package:json_api/src/document/identifier.dart'; -export 'package:json_api/src/document/identifier_object.dart'; +export 'package:json_api/src/document/identity.dart'; +export 'package:json_api/src/document/inbound_document.dart'; export 'package:json_api/src/document/link.dart'; -export 'package:json_api/src/document/primary_data.dart'; +export 'package:json_api/src/document/many.dart'; +export 'package:json_api/src/document/new_resource.dart'; +export 'package:json_api/src/document/one.dart'; +export 'package:json_api/src/document/outbound_document.dart'; export 'package:json_api/src/document/relationship.dart'; export 'package:json_api/src/document/resource.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'; +export 'package:json_api/src/document/resource_collection.dart'; +export 'package:json_api/src/document/resource_properties.dart'; diff --git a/lib/http.dart b/lib/http.dart index b62b5ec3..724805be 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -1,7 +1,12 @@ -/// This is a thin HTTP layer abstraction used by the client and the server. +/// This is a thin HTTP layer abstraction used by the client and the server library http; export 'package:json_api/src/http/http_handler.dart'; +export 'package:json_api/src/http/http_headers.dart'; +export 'package:json_api/src/http/http_message.dart'; export 'package:json_api/src/http/http_request.dart'; export 'package:json_api/src/http/http_response.dart'; -export 'package:json_api/src/http/logging_http_handler.dart'; +export 'package:json_api/src/http/logging_handler.dart'; +export 'package:json_api/src/http/media_type.dart'; +export 'package:json_api/src/http/payload_codec.dart'; +export 'package:json_api/src/http/status_code.dart'; diff --git a/lib/query.dart b/lib/query.dart index 9fa7b08b..1265a94c 100644 --- a/lib/query.dart +++ b/lib/query.dart @@ -1,8 +1,8 @@ +/// A set of builders/parsers for special query parameters used in JSON:API. library query; export 'package:json_api/src/query/fields.dart'; export 'package:json_api/src/query/filter.dart'; export 'package:json_api/src/query/include.dart'; export 'package:json_api/src/query/page.dart'; -export 'package:json_api/src/query/query_parameters.dart'; export 'package:json_api/src/query/sort.dart'; diff --git a/lib/routing.dart b/lib/routing.dart index a440830f..4f267e51 100644 --- a/lib/routing.dart +++ b/lib/routing.dart @@ -1,6 +1,7 @@ -export 'package:json_api/src/routing/composite_routing.dart'; -export 'package:json_api/src/routing/route_factory.dart'; -export 'package:json_api/src/routing/route_matcher.dart'; -export 'package:json_api/src/routing/routes.dart'; -export 'package:json_api/src/routing/standard_routes.dart'; -export 'package:json_api/src/routing/standard_routing.dart'; +/// Routing describes the design of URLs on the server. +/// See https://jsonapi.org/recommendations/#urls +library routing; + +export 'package:json_api/src/routing/standard_uri_design.dart'; +export 'package:json_api/src/routing/target.dart'; +export 'package:json_api/src/routing/uri_design.dart'; diff --git a/lib/server.dart b/lib/server.dart index 66c1532e..f3913a8b 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,16 +1,8 @@ +/// JSON:API server on top of dart:io. library server; export 'package:json_api/src/server/controller.dart'; -export 'package:json_api/src/server/dart_server.dart'; -export 'package:json_api/src/server/document_factory.dart'; -export 'package:json_api/src/server/http_response_converter.dart'; -export 'package:json_api/src/server/in_memory_repository.dart'; -export 'package:json_api/src/server/json_api_request.dart'; -export 'package:json_api/src/server/json_api_response.dart'; -export 'package:json_api/src/server/json_api_server.dart'; -export 'package:json_api/src/server/links/links_factory.dart'; -export 'package:json_api/src/server/links/no_links.dart'; -export 'package:json_api/src/server/links/standard_links.dart'; -export 'package:json_api/src/server/pagination.dart'; -export 'package:json_api/src/server/repository.dart'; -export 'package:json_api/src/server/repository_controller.dart'; +export 'package:json_api/src/server/errors/method_not_allowed.dart'; +export 'package:json_api/src/server/errors/unmatched_target.dart'; +export 'package:json_api/src/server/response.dart'; +export 'package:json_api/src/server/router.dart'; diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart new file mode 100644 index 00000000..fe361fa3 --- /dev/null +++ b/lib/src/client/client.dart @@ -0,0 +1,53 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/disposable_handler.dart'; +import 'package:json_api/src/client/request.dart'; +import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/client/response/request_failure.dart'; + +/// A basic JSON:API client +class Client { + const Client( + {PayloadCodec codec = const PayloadCodec(), + HttpHandler handler = const DisposableHandler()}) + : _codec = codec, + _http = handler; + + final HttpHandler _http; + final PayloadCodec _codec; + + /// Sends the [request] to the server. + /// Throws a [RequestFailure] if the server responds with an error. + Future send(Uri uri, Request request) async { + final body = await _encode(request.document); + final response = await _http.handle(HttpRequest( + request.method, + request.query.isEmpty + ? uri + : uri.replace(queryParameters: request.query), + body: body) + ..headers.addAll({ + 'Accept': MediaType.jsonApi, + if (body.isNotEmpty) 'Content-Type': MediaType.jsonApi, + ...request.headers + })); + + final json = await _decode(response); + if (StatusCode(response.statusCode).isFailed) { + throw RequestFailure(response, json); + } + return Response(response, json); + } + + Future _encode(Object? doc) async => + doc == null ? '' : await _codec.encode(doc); + + Future _decode(HttpResponse response) async => + _isJsonApi(response) ? await _codec.decode(response.body) : null; + + /// True if body is not empty and Content-Type is application/vnd.api+json + bool _isJsonApi(HttpResponse response) => + response.body.isNotEmpty && + (response.headers['Content-Type'] ?? '') + .toLowerCase() + .startsWith(MediaType.jsonApi); +} diff --git a/lib/src/client/dart_http.dart b/lib/src/client/dart_http.dart deleted file mode 100644 index 516d27bc..00000000 --- a/lib/src/client/dart_http.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http_parser/http_parser.dart'; -import 'package:json_api/http.dart'; - -/// A handler using the Dart's built-in http client -class DartHttp implements HttpHandler { - DartHttp(this._client, [this._defaultEncoding = utf8]) - : assert(_defaultEncoding != null, "Default encoding can't be null"); - - @override - Future call(HttpRequest request) async { - final response = await _send(Request(request.method, request.uri) - ..headers.addAll(request.headers) - ..body = request.body); - final responseBody = - _encodingForHeaders(response.headers).decode(response.bodyBytes); - return HttpResponse( - response.statusCode, - body: responseBody, - headers: response.headers, - ); - } - - final Client _client; - final Encoding _defaultEncoding; - - Future _send(Request dartRequest) async => - Response.fromStream(await _client.send(dartRequest)); - - /// Returns the encoding to use for a response with the given headers. - /// - /// Defaults to [_defaultEncoding] if the headers don't specify a charset or if that - /// charset is unknown. - Encoding _encodingForHeaders(Map headers) => - _encodingForCharset( - _contentTypeForHeaders(headers).parameters['charset']); - - /// Returns the [Encoding] that corresponds to [charset]. - /// - /// Returns [_defaultEncoding] if [charset] is null or if no [Encoding] was found that - /// corresponds to [charset]. - Encoding _encodingForCharset(String charset) { - if (charset == null) return _defaultEncoding; - return Encoding.getByName(charset) ?? _defaultEncoding; - } - - /// Returns the [MediaType] object for the given headers's content-type. - /// - /// Defaults to `application/octet-stream`. - MediaType _contentTypeForHeaders(Map headers) { - var contentType = headers['content-type']; - if (contentType != null) return MediaType.parse(contentType); - return MediaType('application', 'octet-stream'); - } -} diff --git a/lib/src/client/disposable_handler.dart b/lib/src/client/disposable_handler.dart new file mode 100644 index 00000000..79a0ade3 --- /dev/null +++ b/lib/src/client/disposable_handler.dart @@ -0,0 +1,19 @@ +import 'package:http/http.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/client/persistent_handler.dart'; + +/// This HTTP handler creates a new instance of the [Client] for every request +/// end disposes the client after the request completes. +class DisposableHandler implements HttpHandler { + const DisposableHandler(); + + @override + Future handle(HttpRequest request) async { + final client = Client(); + try { + return await PersistentHandler(client).call(request); + } finally { + client.close(); + } + } +} diff --git a/lib/src/client/json_api_client.dart b/lib/src/client/json_api_client.dart deleted file mode 100644 index 3195eced..00000000 --- a/lib/src/client/json_api_client.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/client/response.dart'; -import 'package:json_api/src/client/status_code.dart'; - -/// The JSON:API Client. -class JsonApiClient { - /// Creates an instance of JSON:API client. - /// Provide instances of [HttpHandler] (e.g. [DartHttp]) - JsonApiClient(this._httpHandler); - - final HttpHandler _httpHandler; - - /// Fetches a resource collection at the [uri]. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchCollectionAt(Uri uri, - {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ResourceCollectionData.fromJson); - - /// Fetches a single resource at the [uri]. - /// Use [headers] to pass extra HTTP headers. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchResourceAt(Uri uri, - {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ResourceData.fromJson); - - /// Fetches a to-one relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToOneAt(Uri uri, - {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ToOne.fromJson); - - /// Fetches a to-many relationship - /// Use [headers] to pass extra HTTP headers. - /// Use [queryParameters] to specify extra request parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchToManyAt(Uri uri, - {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), ToMany.fromJson); - - /// 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. - /// Use [parameters] to specify extra query parameters, such as: - /// - [Include] to request inclusion of related resources (@see https://jsonapi.org/format/#fetching-includes) - /// - [Fields] to specify a sparse fieldset (@see https://jsonapi.org/format/#fetching-sparse-fieldsets) - Future> fetchRelationshipAt(Uri uri, - {Map headers, QueryParameters parameters}) => - _call(_get(uri, headers, parameters), Relationship.fromJson); - - /// Creates a new resource. The resource will be added to a collection - /// according to its type. - /// - /// https://jsonapi.org/format/#crud-creating - Future> createResourceAt(Uri uri, Resource resource, - {Map headers, Map meta}) => - _call(_post(uri, headers, _resourceDoc(resource, meta: meta)), - ResourceData.fromJson); - - /// Deletes the resource. - /// - /// https://jsonapi.org/format/#crud-deleting - Future deleteResourceAt(Uri uri, {Map headers}) => - _call(_delete(uri, headers), null); - - /// Updates the resource via PATCH query. - /// - /// https://jsonapi.org/format/#crud-updating - Future> updateResourceAt(Uri uri, Resource resource, - {Map headers}) => - _call( - _patch(uri, headers, _resourceDoc(resource)), ResourceData.fromJson); - - /// Updates a to-one relationship via PATCH query - /// - /// https://jsonapi.org/format/#crud-updating-to-one-relationships - Future> replaceToOneAt(Uri uri, Identifier identifier, - {Map headers}) => - _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson); - - /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt] - /// with id = null. - Future> deleteToOneAt(Uri uri, - {Map headers}) => - replaceToOneAt(uri, null, headers: headers); - - /// Removes the [identifiers] from the to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> deleteFromToManyAt( - Uri uri, Iterable identifiers, - {Map headers}) => - _call(_deleteWithBody(uri, headers, _toManyDoc(identifiers)), - ToMany.fromJson); - - /// Replaces a to-many relationship with the given set of [identifiers]. - /// - /// The server MUST either completely replace every member of the relationship, - /// return an appropriate error response if some resources can not be found or accessed, - /// or return a 403 Forbidden response if complete replacement is not allowed by the server. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> replaceToManyAt( - Uri uri, Iterable identifiers, - {Map headers}) => - _call(_patch(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); - - /// Adds the given set of [identifiers] to a to-many relationship. - /// - /// https://jsonapi.org/format/#crud-updating-to-many-relationships - Future> addToRelationshipAt( - Uri uri, Iterable identifiers, - {Map headers}) => - _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson); - - final _api = Api(version: '1.0'); - - Document _resourceDoc(Resource resource, - {Map meta}) => - Document(ResourceData.fromResource(resource), meta: meta, api: _api); - - Document _toManyDoc(Iterable identifiers) => - Document(ToMany.fromIdentifiers(identifiers), api: _api); - - Document _toOneDoc(Identifier identifier) => - Document(ToOne.fromIdentifier(identifier), api: _api); - - HttpRequest _get(Uri uri, Map headers, - QueryParameters queryParameters) => - HttpRequest('GET', (queryParameters ?? QueryParameters({})).addToUri(uri), - headers: { - ...headers ?? {}, - 'Accept': Document.contentType, - }); - - HttpRequest _post(Uri uri, Map headers, Document doc) => - HttpRequest('POST', uri, - headers: { - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - HttpRequest _delete(Uri uri, Map headers) => - HttpRequest('DELETE', uri, headers: { - ...headers ?? {}, - 'Accept': Document.contentType, - }); - - HttpRequest _deleteWithBody( - Uri uri, Map headers, Document doc) => - HttpRequest('DELETE', uri, - headers: { - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - HttpRequest _patch(uri, Map headers, Document doc) => - HttpRequest('PATCH', uri, - headers: { - ...headers ?? {}, - 'Accept': Document.contentType, - 'Content-Type': Document.contentType, - }, - body: jsonEncode(doc)); - - Future> _call( - HttpRequest request, D Function(Object _) decodePrimaryData) async { - final response = await _httpHandler(request); - final status = StatusCode(response.statusCode); - if (response.body.isEmpty || - (status.isFailed && - response.headers['content-type']?.contains(Document.contentType) != - true)) { - return Response(response.statusCode, response.headers); - } - final document = response.body.isEmpty ? null : jsonDecode(response.body); - if (StatusCode(response.statusCode).isPending) { - return Response(response.statusCode, response.headers, - asyncDocument: document == null - ? null - : Document.fromJson(document, ResourceData.fromJson)); - } - return Response(response.statusCode, response.headers, - document: document == null - ? null - : Document.fromJson(document, decodePrimaryData)); - } -} diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart new file mode 100644 index 00000000..b2b62ea3 --- /dev/null +++ b/lib/src/client/persistent_handler.dart @@ -0,0 +1,22 @@ +import 'package:http/http.dart'; +import 'package:json_api/http.dart'; + +/// Handler which relies on the built-in Dart HTTP client. +/// It is the developer's responsibility to instantiate the client and +/// call `close()` on it in the end pf the application lifecycle. +class PersistentHandler { + /// Creates a new instance of the handler. Do not forget to call `close()` on + /// the [client] when it's not longer needed. + PersistentHandler(this.client); + + final Client client; + + Future call(HttpRequest request) async { + final response = await Response.fromStream( + await client.send(Request(request.method, request.uri) + ..headers.addAll(request.headers) + ..body = request.body)); + return HttpResponse(response.statusCode, body: response.body) + ..headers.addAll(response.headers); + } +} diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart new file mode 100644 index 00000000..35b10aaf --- /dev/null +++ b/lib/src/client/request.dart @@ -0,0 +1,53 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/query.dart'; + +/// JSON:API request consumed by the client +class Request with HttpHeaders { + Request(this.method, [this.document]); + + Request.get() : this('get'); + + Request.post([Object? document]) : this('post', document); + + Request.delete([Object? document]) : this('delete', document); + + Request.patch([Object? document]) : this('patch', document); + + /// HTTP method + final String method; + + final Object? document; + + /// Query parameters + final query = {}; + + /// Requests inclusion of related resources. + /// See https://jsonapi.org/format/#fetching-includes + void include(Iterable include) { + query.addAll(Include(include).asQueryParameters); + } + + /// Sets sorting parameters. + /// See https://jsonapi.org/format/#fetching-sorting + void sort(Iterable sort) { + query.addAll(Sort(sort).asQueryParameters); + } + + /// Requests sparse fieldsets. + /// See https://jsonapi.org/format/#fetching-sparse-fieldsets + void fields(Map> fields) { + query.addAll(Fields(fields).asQueryParameters); + } + + /// Sets pagination parameters. + /// See https://jsonapi.org/format/#fetching-pagination + void page(Map page) { + query.addAll(Page(page).asQueryParameters); + } + + /// Response filtering. + /// https://jsonapi.org/format/#fetching-filtering + void filter(Map filter) { + query.addAll(Filter(filter).asQueryParameters); + } +} diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart index 8fb34cff..4e8705fb 100644 --- a/lib/src/client/response.dart +++ b/lib/src/client/response.dart @@ -1,67 +1,11 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/client/status_code.dart'; -import 'package:json_api/src/nullable.dart'; +import 'package:json_api/http.dart' as h; -/// A response returned by JSON:API client -class Response { - const Response(this.statusCode, this.headers, - {this.document, this.asyncDocument}); +class Response { + Response(this.http, this.json); - /// HTTP status code - final int statusCode; + /// HTTP response + final h.HttpResponse http; - /// Document parsed from the response body. - /// May be null. - final Document document; - - /// The document received with `202 Accepted` response (if any) - /// https://jsonapi.org/recommendations/#asynchronous-processing - final Document asyncDocument; - - /// Headers returned by the server. - final Map headers; - - /// Primary Data from the document (if any). For unsuccessful operations - /// this property will be null, the error details may be found in [Document.errors]. - Data get data => document?.data; - - /// List of errors (if any) returned by the server in case of an unsuccessful - /// operation. May be empty. Will be null if the operation was successful. - List get errors => document?.errors; - - /// Primary Data from the async document (if any) - ResourceData get asyncData => asyncDocument?.data; - - /// Was the query successful? - /// - /// For pending (202 Accepted) requests both [isSuccessful] and [isFailed] - /// are always false. - bool get isSuccessful => StatusCode(statusCode).isSuccessful; - - /// This property is an equivalent of `202 Accepted` HTTP status. - /// It indicates that the query is accepted but not finished yet (e.g. queued). - /// If the response is async, the [data] and [document] properties will be null - /// and the returned primary data (usually representing a job queue) will be - /// in [asyncData] and [asyncDocument]. - /// The [contentLocation] will point to the job queue resource. - /// You can fetch the job queue resource periodically and check the type of - /// the returned resource. Once the operation is complete, the request will - /// return the created resource. - /// - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - bool get isAsync => StatusCode(statusCode).isPending; - - /// Any non 2** status code is considered a failed operation. - /// For failed requests, [document] is expected to contain [ErrorDocument] - bool get isFailed => StatusCode(statusCode).isFailed; - - /// The `Location` HTTP header value. For `201 Created` responses this property - /// contains the location of a newly created resource. - Uri get location => nullable(Uri.parse)(headers['location']); - - /// The `Content-Location` HTTP header value. For `202 Accepted` responses - /// this property contains the location of the Job Queue resource. - /// - /// More details: https://jsonapi.org/recommendations/#asynchronous-processing - Uri get contentLocation => nullable(Uri.parse)(headers['content-location']); + /// Raw JSON response + final Map? json; } diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart new file mode 100644 index 00000000..99c94e4e --- /dev/null +++ b/lib/src/client/response/collection_fetched.dart @@ -0,0 +1,26 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +class CollectionFetched { + CollectionFetched(this.http, Map json) { + final document = InboundDocument(json); + collection.addAll(document.dataAsCollection()); + included.addAll(document.included()); + meta.addAll(document.meta()); + links.addAll(document.links()); + } + + final HttpResponse http; + + /// The resource collection fetched from the server + final collection = ResourceCollection(); + + /// Included resources + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart new file mode 100644 index 00000000..a6fb4ad3 --- /dev/null +++ b/lib/src/client/response/related_resource_fetched.dart @@ -0,0 +1,29 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A related resource response. +/// +/// https://jsonapi.org/format/#fetching-resources-responses +class RelatedResourceFetched { + RelatedResourceFetched(this.http, Map json) + : resource = InboundDocument(json).dataAsResourceOrNull() { + final document = InboundDocument(json); + included.addAll(document.included()); + meta.addAll(document.meta()); + links.addAll(document.links()); + } + + final HttpResponse http; + + /// Related resource. May be null + final Resource? resource; + + /// Included resources + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart new file mode 100644 index 00000000..fa6ff0e4 --- /dev/null +++ b/lib/src/client/response/relationship_fetched.dart @@ -0,0 +1,19 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a relationship fetch request. +class RelationshipFetched { + RelationshipFetched(this.http, this.relationship); + + static RelationshipFetched many(HttpResponse http, Map json) => + RelationshipFetched(http, InboundDocument(json).asToMany()) + ..included.addAll(InboundDocument(json).included()); + + static RelationshipFetched one(HttpResponse http, Map json) => + RelationshipFetched(http, InboundDocument(json).asToOne()) + ..included.addAll(InboundDocument(json).included()); + + final HttpResponse http; + final R relationship; + final included = ResourceCollection(); +} diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart new file mode 100644 index 00000000..e596d925 --- /dev/null +++ b/lib/src/client/response/relationship_updated.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a relationship request. +class RelationshipUpdated { + RelationshipUpdated(this.http, this.relationship); + + static RelationshipUpdated many(HttpResponse http, Map? json) => + RelationshipUpdated( + http, json == null ? null : InboundDocument(json).asToMany()); + + static RelationshipUpdated one(HttpResponse http, Map? json) => + RelationshipUpdated( + http, json == null ? null : InboundDocument(json).asToOne()); + + final HttpResponse http; + + /// Updated relationship. Null if "204 No Content" is returned. + final R? relationship; +} diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart new file mode 100644 index 00000000..72934a9a --- /dev/null +++ b/lib/src/client/response/request_failure.dart @@ -0,0 +1,24 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// Thrown when the server returns a non-successful response. +class RequestFailure implements Exception { + RequestFailure(this.http, Map? json) { + if (json != null) { + errors.addAll(InboundDocument(json).errors()); + meta.addAll(InboundDocument(json).meta()); + } + } + + final HttpResponse http; + + /// Error objects returned by the server + final errors = []; + + /// Top-level meta data + final meta = {}; + + @override + String toString() => + 'JSON:API request failed with HTTP status ${http.statusCode}'; +} diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart new file mode 100644 index 00000000..ad3c88c8 --- /dev/null +++ b/lib/src/client/response/resource_created.dart @@ -0,0 +1,23 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to a new resource creation request. +/// This is always a "201 Created" response. +/// +/// https://jsonapi.org/format/#crud-creating-responses-201 +class ResourceCreated { + ResourceCreated(this.http, Map json) + : resource = InboundDocument(json).dataAsResource() { + links.addAll(InboundDocument(json).links()); + included.addAll(InboundDocument(json).included()); + } + + final HttpResponse http; + + /// Created resource. + final Resource resource; + final links = {}; + + /// Included resources + final included = ResourceCollection(); +} diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart new file mode 100644 index 00000000..76d656ef --- /dev/null +++ b/lib/src/client/response/resource_fetched.dart @@ -0,0 +1,22 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +/// A response to fetch a primary resource request +class ResourceFetched { + ResourceFetched(this.http, Map json) + : resource = InboundDocument(json).dataAsResource() { + included.addAll(InboundDocument(json).included()); + meta.addAll(InboundDocument(json).meta()); + links.addAll(InboundDocument(json).links()); + } + + final HttpResponse http; + final Resource resource; + final included = ResourceCollection(); + + /// Top-level meta data + final meta = {}; + + /// Top-level links object + final links = {}; +} diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart new file mode 100644 index 00000000..cfd6c95c --- /dev/null +++ b/lib/src/client/response/resource_updated.dart @@ -0,0 +1,20 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; + +class ResourceUpdated { + ResourceUpdated(this.http, Map? json) : resource = _resource(json); + + static Resource? _resource(Map? json) { + if (json != null) { + final doc = InboundDocument(json); + if (doc.hasData) { + return doc.dataAsResource(); + } + } + } + + final HttpResponse http; + + /// The created resource. Null for "204 No Content" responses. + late final Resource? resource; +} diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart index 819dc283..13533832 100644 --- a/lib/src/client/routing_client.dart +++ b/lib/src/client/routing_client.dart @@ -1,119 +1,317 @@ -import 'package:json_api/client.dart'; import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; import 'package:json_api/routing.dart'; +import 'package:json_api/src/client/client.dart'; +import 'package:json_api/src/client/request.dart'; +import 'package:json_api/src/client/response.dart'; +import 'package:json_api/src/client/response/collection_fetched.dart'; +import 'package:json_api/src/client/response/related_resource_fetched.dart'; +import 'package:json_api/src/client/response/relationship_fetched.dart'; +import 'package:json_api/src/client/response/relationship_updated.dart'; +import 'package:json_api/src/client/response/resource_created.dart'; +import 'package:json_api/src/client/response/resource_fetched.dart'; +import 'package:json_api/src/client/response/resource_updated.dart'; -import 'response.dart'; - -/// This is a wrapper over [JsonApiClient] capable of building the -/// request URIs by itself. +/// A routing JSON:API client class RoutingClient { - RoutingClient(this._client, this._routes); - - final JsonApiClient _client; - final RouteFactory _routes; - - /// Fetches a primary resource collection by [type]. - Future> fetchCollection(String type, - {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_routes.collection(type), - headers: headers, parameters: parameters); - - /// Fetches a related resource collection. Guesses the URI by [type], [id], [relationship]. - Future> fetchRelatedCollection( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchCollectionAt(_routes.related(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a primary resource by [type] and [id]. - Future> fetchResource(String type, String id, - {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_routes.resource(type, id), - headers: headers, parameters: parameters); - - /// Fetches a related resource by [type], [id], [relationship]. - Future> fetchRelatedResource( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchResourceAt(_routes.related(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a to-one relationship by [type], [id], [relationship]. - Future> fetchToOne( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToOneAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a to-many relationship by [type], [id], [relationship]. - Future> fetchToMany( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchToManyAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Fetches a [relationship] of [type] : [id]. - Future> fetchRelationship( - String type, String id, String relationship, - {Map headers, QueryParameters parameters}) => - _client.fetchRelationshipAt(_routes.relationship(type, id, relationship), - headers: headers, parameters: parameters); - - /// Creates the [resource] on the server. - Future> createResource(Resource resource, - {Map headers}) => - _client.createResourceAt(_routes.collection(resource.type), resource, - headers: headers); - - /// Deletes the resource by [type] and [id]. - Future deleteResource(String type, String id, - {Map headers}) => - _client.deleteResourceAt(_routes.resource(type, id), headers: headers); - - /// Updates the [resource]. - Future> updateResource(Resource resource, - {Map headers}) => - _client.updateResourceAt( - _routes.resource(resource.type, resource.id), resource, - headers: headers); - - /// Replaces the to-one [relationship] of [type] : [id]. - Future> replaceToOne( - String type, String id, String relationship, Identifier identifier, - {Map headers}) => - _client.replaceToOneAt( - _routes.relationship(type, id, relationship), identifier, - headers: headers); - - /// Deletes the to-one [relationship] of [type] : [id]. - Future> deleteToOne( - String type, String id, String relationship, - {Map headers}) => - _client.deleteToOneAt(_routes.relationship(type, id, relationship), - headers: headers); - - /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id]. - Future> deleteFromToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.deleteFromToManyAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); - - /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers]. - Future> replaceToMany(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.replaceToManyAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); - - /// Adds the [identifiers] to the to-many [relationship] of [type] : [id]. - Future> addToRelationship(String type, String id, - String relationship, Iterable identifiers, - {Map headers}) => - _client.addToRelationshipAt( - _routes.relationship(type, id, relationship), identifiers, - headers: headers); + RoutingClient(this._uri, {Client client = const Client()}) : _client = client; + + final Client _client; + final UriDesign _uri; + + /// Adds [identifiers] to a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future> addMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.post(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + return RelationshipUpdated.many(response.http, response.json); + } + + /// Creates a new resource in the collection of type [type]. + /// The server is responsible for assigning the resource id. + /// + /// Optional arguments: + /// - [attributes] - resource attributes + /// - [one] - resource to-one relationships + /// - [many] - resource to-many relationships + /// - [meta] - resource meta data + /// - [headers] - any extra HTTP headers + Future createNew( + String type, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.post(OutboundDataDocument.newResource(NewResource(type) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + + return ResourceCreated( + response.http, response.json ?? (throw FormatException())); + } + + /// Deletes [identifiers] from a to-many relationship + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + Future deleteFromMany( + String type, + String id, + String relationship, + List identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.delete(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + + return RelationshipUpdated.many(response.http, response.json); + } + + /// Fetches a primary collection of type [type]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchCollection( + String type, { + Map headers = const {}, + Map query = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..page(page) + ..filter(filter) + ..include(include) + ..sort(sort) + ..fields(fields)); + return CollectionFetched( + response.http, response.json ?? (throw FormatException())); + } + + /// Fetches a related resource collection + /// identified by [type], [id], [relationship]. + /// + /// Optional arguments: + /// - [headers] - any extra HTTP headers + /// - [query] - any extra query parameters + /// - [page] - pagination options + /// - [filter] - filtering options + /// - [include] - request to include related resources + /// - [sort] - collection sorting options + /// - [fields] - sparse fields options + Future fetchRelatedCollection( + String type, + String id, + String relationship, { + Map headers = const {}, + Map page = const {}, + Map filter = const {}, + Iterable include = const [], + Iterable sort = const [], + Map> fields = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..page(page) + ..filter(filter) + ..include(include) + ..sort(sort) + ..fields(fields)); + return CollectionFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future> fetchToOne( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.get()..headers.addAll(headers)..query.addAll(query)); + return RelationshipFetched.one( + response.http, response.json ?? (throw FormatException())); + } + + Future> fetchToMany( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.get()..headers.addAll(headers)..query.addAll(query)); + return RelationshipFetched.many( + response.http, response.json ?? (throw FormatException())); + } + + Future fetchRelatedResource( + String type, + String id, + String relationship, { + Map headers = const {}, + Map query = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + }) async { + final response = await _client.send( + _uri.related(type, id, relationship), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..filter(filter) + ..include(include) + ..fields(fields)); + return RelatedResourceFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future fetchResource( + String type, + String id, { + Map headers = const {}, + Map filter = const {}, + Iterable include = const [], + Map> fields = const {}, + Map query = const {}, + }) async { + final response = await _client.send( + _uri.resource(type, id), + Request.get() + ..headers.addAll(headers) + ..query.addAll(query) + ..filter(filter) + ..include(include) + ..fields(fields)); + + return ResourceFetched( + response.http, response.json ?? (throw FormatException())); + } + + Future updateResource(String type, String id, + {Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}}) async { + final response = await _client.send( + _uri.resource(type, id), + Request.patch(OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + return ResourceUpdated(response.http, response.json); + } + + /// Creates a new resource with the given id on the server. + Future create( + String type, + String id, { + Map attributes = const {}, + Map one = const {}, + Map> many = const {}, + Map meta = const {}, + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.collection(type), + Request.post(OutboundDataDocument.resource(Resource(type, id) + ..attributes.addAll(attributes) + ..relationships.addAll({ + ...one.map((key, value) => MapEntry(key, ToOne(value))), + ...many.map((key, value) => MapEntry(key, ToMany(value))), + }) + ..meta.addAll(meta))) + ..headers.addAll(headers)); + return ResourceUpdated(response.http, response.json); + } + + Future> replaceToOne( + String type, + String id, + String relationship, + Identifier identifier, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.one(ToOne(identifier))) + ..headers.addAll(headers)); + return RelationshipUpdated.one(response.http, response.json); + } + + Future> replaceToMany( + String type, + String id, + String relationship, + Iterable identifiers, { + Map headers = const {}, + }) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.many(ToMany(identifiers))) + ..headers.addAll(headers)); + return RelationshipUpdated.many(response.http, response.json); + } + + Future> deleteToOne( + String type, String id, String relationship, + {Map headers = const {}}) async { + final response = await _client.send( + _uri.relationship(type, id, relationship), + Request.patch(OutboundDataDocument.one(ToOne.empty())) + ..headers.addAll(headers)); + return RelationshipUpdated.one(response.http, response.json); + } + + Future deleteResource(String type, String id) => + _client.send(_uri.resource(type, id), Request.delete()); } diff --git a/lib/src/client/status_code.dart b/lib/src/client/status_code.dart deleted file mode 100644 index e2971bfe..00000000 --- a/lib/src/client/status_code.dart +++ /dev/null @@ -1,17 +0,0 @@ -/// The status code in the HTTP response -class StatusCode { - const StatusCode(this.code); - - /// The code - final int code; - - /// True for the requests processed asynchronously. - /// @see https://jsonapi.org/recommendations/#asynchronous-processing). - bool get isPending => code == 202; - - /// True for successfully processed requests - bool get isSuccessful => code >= 200 && code < 300 && !isPending; - - /// True for failed requests - bool get isFailed => !isSuccessful && !isPending; -} diff --git a/lib/src/document/api.dart b/lib/src/document/api.dart deleted file mode 100644 index a4d4dc79..00000000 --- a/lib/src/document/api.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; - -/// Details: https://jsonapi.org/format/#document-jsonapi-object -class Api implements JsonEncodable { - Api({String version, Map meta}) - : meta = Map.unmodifiable(meta ?? const {}), - version = version ?? ''; - - /// The JSON:API version. May be null. - final String version; - - /// Meta data. May be empty or null. - final Map meta; - - bool get isNotEmpty => version.isEmpty && meta.isNotEmpty; - - static Api fromJson(Object json) { - if (json is Map) { - return Api(version: json['version'], meta: json['meta']); - } - throw DocumentException("The 'jsonapi' member must be a JSON object"); - } - - @override - Map toJson() => { - if (version.isNotEmpty) 'version': version, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/document.dart b/lib/src/document/document.dart deleted file mode 100644 index 2d7e9ba1..00000000 --- a/lib/src/document/document.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:json_api/src/document/api.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/error_object.dart'; -import 'package:json_api/src/document/json_encodable.dart'; -import 'package:json_api/src/document/primary_data.dart'; -import 'package:json_api/src/nullable.dart'; - -class Document implements JsonEncodable { - /// Create a document with primary data - Document(this.data, {Map meta, Api api}) - : errors = const [], - meta = Map.unmodifiable(meta ?? const {}), - api = api ?? Api(), - isError = false, - isMeta = false { - ArgumentError.checkNotNull(data); - } - - /// Create a document with errors (no primary data) - Document.error(Iterable errors, - {Map meta, Api api}) - : data = null, - meta = Map.unmodifiable(meta ?? const {}), - errors = List.unmodifiable(errors ?? const []), - api = api ?? Api(), - isError = true, - isMeta = false; - - /// Create an empty document (no primary data and no errors) - Document.empty(Map meta, {Api api}) - : data = null, - meta = Map.unmodifiable(meta ?? const {}), - errors = const [], - api = api ?? Api(), - isError = false, - isMeta = true { - ArgumentError.checkNotNull(meta); - } - - /// The Primary Data. May be null. - final Data data; - - /// List of errors. May be empty or null. - final List errors; - - /// Meta data. May be empty. - final Map meta; - - /// The `jsonapi` object. - final Api api; - - /// True for error documents. - final bool isError; - - /// True for non-error meta-only documents. - final bool isMeta; - - /// Reconstructs a document with the specified primary data - static Document fromJson( - Object json, Data Function(Object json) primaryData) { - if (json is Map) { - final api = nullable(Api.fromJson)(json['jsonapi']); - if (json.containsKey('errors')) { - final errors = json['errors']; - if (errors is List) { - return Document.error(errors.map(ErrorObject.fromJson), - meta: json['meta'], api: api); - } - } else if (primaryData != null) { - return Document(primaryData(json), meta: json['meta'], api: api); - } else if (json['meta'] != null) { - return Document.empty(json['meta'], api: api); - } - throw DocumentException('Unrecognized JSON:API document structure'); - } - throw DocumentException('A JSON:API document must be a JSON object'); - } - - static const contentType = 'application/vnd.api+json'; - - @override - Map toJson() => { - if (data != null) ...data.toJson() else if (isError) 'errors': errors, - if (isMeta || meta.isNotEmpty) 'meta': meta, - if (api.isNotEmpty) 'jsonapi': api, - }; -} diff --git a/lib/src/document/document_exception.dart b/lib/src/document/document_exception.dart deleted file mode 100644 index 65e02d6c..00000000 --- a/lib/src/document/document_exception.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Indicates a violation of JSON:API Document structure or data constraints. -class DocumentException implements Exception { - DocumentException(this.message); - - /// Human-readable text explaining the issue. - final String message; -} diff --git a/lib/src/document/error_object.dart b/lib/src/document/error_object.dart index 38c15390..07ef02c9 100644 --- a/lib/src/document/error_object.dart +++ b/lib/src/document/error_object.dart @@ -1,107 +1,56 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; +import 'package:json_api/src/document/error_source.dart'; import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/nullable.dart'; /// [ErrorObject] represents an error occurred on the server. /// /// More on this: https://jsonapi.org/format/#errors -class ErrorObject implements JsonEncodable { +class ErrorObject { /// Creates an instance of a JSON:API Error. /// The [links] map may contain custom links. The about link - /// passed through the [about] argument takes precedence and will overwrite + /// passed through the [links['about']] argument takes precedence and will overwrite /// the `about` key in [links]. - ErrorObject({ - String id, - String status, - String code, - String title, - String detail, - Map meta, - Map source, - Map links, - }) : id = id ?? '', - status = status ?? '', - code = code ?? '', - title = title ?? '', - detail = detail ?? '', - source = Map.unmodifiable(source ?? const {}), - links = Map.unmodifiable(links ?? const {}), - meta = Map.unmodifiable(meta ?? const {}); - - static ErrorObject fromJson(Object json) { - if (json is Map) { - final source = json['source']; - return ErrorObject( - id: json['id'], - status: json['status'], - code: json['code'], - title: json['title'], - detail: json['detail'], - source: source is Map - ? source.map( - (key, value) => MapEntry(key.toString(), value.toString())) - : {}, - meta: json['meta'], - links: nullable(Link.mapFromJson)(json['links']) ?? const {}); - } - throw DocumentException('A JSON:API error must be a JSON object'); - } + ErrorObject( + {this.id = '', + this.status = '', + this.code = '', + this.title = '', + this.detail = '', + this.source = const ErrorSource()}); /// A unique identifier for this particular occurrence of the problem. - /// May be empty. final String id; - /// A link that leads to further details about this particular occurrence of the problem. - /// May be empty. - Link get about => links['about']; - /// The HTTP status code applicable to this problem, expressed as a string value. - /// May be empty. final String status; /// An application-specific error code, expressed as a string value. - /// May be empty. final String code; /// A short, human-readable summary of the problem that SHOULD NOT change /// from occurrence to occurrence of the problem, except for purposes of localization. - /// May be empty. final String title; /// A human-readable explanation specific to this occurrence of the problem. /// Like title, this field’s value can be localized. - /// May be empty. final String detail; - /// A meta object containing non-standard meta-information about the error. - /// May be empty. - final Map meta; - - /// The `links` object. - /// May be empty. - /// https://jsonapi.org/format/#document-links - final Map links; - - /// The `source` object. - /// An object containing references to the source of the error, optionally including any of the following members: - /// - pointer: a JSON Pointer [RFC6901] to the associated entity in the request document, - /// e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute. - /// - parameter: a string indicating which URI query parameter caused the error. - final Map source; - - @override - Map toJson() { - return { - if (id.isNotEmpty) 'id': id, - if (status.isNotEmpty) 'status': status, - if (code.isNotEmpty) 'code': code, - if (title.isNotEmpty) 'title': title, - if (detail.isNotEmpty) 'detail': detail, - if (meta.isNotEmpty) 'meta': meta, - if (links.isNotEmpty) 'links': links, - if (source.isNotEmpty) 'source': source, - }; - } + /// Error source. + final ErrorSource source; + + /// Error links. + final links = {}; + + /// Meta data. + final meta = {}; + + Map toJson() => { + if (id.isNotEmpty) 'id': id, + if (status.isNotEmpty) 'status': status, + if (code.isNotEmpty) 'code': code, + if (title.isNotEmpty) 'title': title, + if (detail.isNotEmpty) 'detail': detail, + if (source.isNotEmpty) 'source': source, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/document/error_source.dart b/lib/src/document/error_source.dart new file mode 100644 index 00000000..ae8c6cee --- /dev/null +++ b/lib/src/document/error_source.dart @@ -0,0 +1,19 @@ +/// An object containing references to the source of the error. +class ErrorSource { + const ErrorSource({this.pointer = '', this.parameter = ''}); + + /// A JSON Pointer [RFC6901] to the associated entity in the request document. + final String pointer; + + /// A string indicating which URI query parameter caused the error. + final String parameter; + + bool get isEmpty => pointer.isEmpty && parameter.isEmpty; + + bool get isNotEmpty => !isEmpty; + + Map toJson() => { + if (parameter.isNotEmpty) 'parameter': parameter, + if (pointer.isNotEmpty) 'pointer': pointer + }; +} diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart index 68500f3e..76a0c137 100644 --- a/lib/src/document/identifier.dart +++ b/lib/src/document/identifier.dart @@ -1,39 +1,20 @@ -import 'package:json_api/document.dart'; +import 'package:json_api/src/document/identity.dart'; -/// Resource identifier -/// -/// Together with [Resource] forms the core of the Document model. -/// Identifiers are passed between the server and the client in the form -/// of [IdentifierObject]s. -class Identifier { - /// Neither [type] nor [id] can be null or empty. - Identifier(this.type, this.id) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - } +/// A Resource Identifier object +class Identifier with Identity { + Identifier(this.type, this.id); - static Identifier of(Resource resource) => - Identifier(resource.type, resource.id); + static Identifier of(Identity identity) => + Identifier(identity.type, identity.id); - /// Resource type + @override final String type; - - /// Resource id - final String id; - - /// Returns true if the two identifiers have the same [type] and [id] - bool equals(Identifier other) => - other != null && - other.runtimeType == Identifier && - other.type == type && - other.id == id; - @override - String toString() => 'Identifier($type:$id)'; + final String id; - @override - bool operator ==(other) => equals(other); + /// Identifier meta-data. + final meta = {}; - @override - int get hashCode => 0; + Map toJson() => + {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta}; } diff --git a/lib/src/document/identifier_object.dart b/lib/src/document/identifier_object.dart deleted file mode 100644 index 88883f5f..00000000 --- a/lib/src/document/identifier_object.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_encodable.dart'; - -/// [IdentifierObject] is a JSON representation of the [Identifier]. -/// It carries all JSON-related logic and the Meta-data. -class IdentifierObject implements JsonEncodable { - /// Creates an instance of [IdentifierObject]. - /// [type] and [id] can not be null. - IdentifierObject(this.type, this.id, {Map meta}) - : meta = Map.unmodifiable(meta ?? const {}) { - ArgumentError.checkNotNull(type); - ArgumentError.checkNotNull(id); - } - - /// Resource type - final String type; - - /// Resource id - final String id; - - /// Meta data. May be empty or null. - final Map meta; - - static IdentifierObject fromIdentifier(Identifier identifier, - {Map meta}) => - IdentifierObject(identifier.type, identifier.id, meta: meta); - - static IdentifierObject fromJson(Object json) { - if (json is Map) { - return IdentifierObject(json['type'], json['id'], meta: json['meta']); - } - throw DocumentException('A JSON:API identifier must be a JSON object'); - } - - Identifier unwrap() => Identifier(type, id); - - @override - Map toJson() => { - 'type': type, - 'id': id, - if (meta.isNotEmpty) 'meta': meta, - }; -} diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart new file mode 100644 index 00000000..9a16a541 --- /dev/null +++ b/lib/src/document/identity.dart @@ -0,0 +1,9 @@ +mixin Identity { + static bool same(Identity a, Identity b) => a.type == b.type && a.id == b.id; + + String get type; + + String get id; + + String get key => '$type:$id'; +} diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart new file mode 100644 index 00000000..1c169524 --- /dev/null +++ b/lib/src/document/inbound_document.dart @@ -0,0 +1,152 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/error_object.dart'; +import 'package:json_api/src/document/error_source.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/new_resource.dart'; +import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/nullable.dart'; + +/// Inbound JSON:API document +class InboundDocument { + InboundDocument(this.json); + + static const _parse = _Parser(); + + /// Raw JSON object. + final Map json; + + bool get hasData => json.containsKey('data'); + + /// Included resources + Iterable included() => json + .get('included', orGet: () => []) + .whereType() + .map(_parse.resource); + + /// Top-level meta data. + Map meta() => _parse.meta(json); + + /// Top-level links object. + Map links() => _parse.links(json); + + /// Errors (for an Error Document) + Iterable errors() => json + .get('errors', orGet: () => []) + .whereType() + .map(_parse.errorObject); + + Iterable dataAsCollection() => + _data().whereType().map(_parse.resource); + + Resource dataAsResource() => _parse.resource(_data()); + + NewResource dataAsNewResource() => _parse.newResource(_data()); + + Resource? dataAsResourceOrNull() => nullable(_parse.resource)(_data()); + + ToMany asToMany() => asRelationship(); + + ToOne asToOne() => asRelationship(); + + R asRelationship() { + final rel = _parse.relationship(json); + if (rel is R) return rel; + throw FormatException('Invalid relationship type'); + } + + T _data() => json.get('data'); +} + +class _Parser { + const _Parser(); + + Map meta(Map json) => + json.get>('meta', orGet: () => {}); + + Map links(Map json) => json + .get('links', orGet: () => {}) + .map((k, v) => MapEntry(k.toString(), _link(v))); + + Relationship relationship(Map json) { + final rel = json.containsKey('data') ? _rel(json['data']) : Relationship(); + rel.links.addAll(links(json)); + rel.meta.addAll(meta(json)); + return rel; + } + + Resource resource(Map json) => + Resource(json.get('type'), json.get('id')) + ..attributes.addAll(_getAttributes(json)) + ..relationships.addAll(_getRelationships(json)) + ..links.addAll(links(json)) + ..meta.addAll(meta(json)); + + NewResource newResource(Map json) => NewResource(json.get('type'), + json.containsKey('id') ? json.get('id') : null) + ..attributes.addAll(_getAttributes(json)) + ..relationships.addAll(_getRelationships(json)) + ..meta.addAll(meta(json)); + + /// Decodes Identifier from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + Identifier identifier(Map json) => + Identifier(json.get('type'), json.get('id')) + ..meta.addAll(meta(json)); + + ErrorObject errorObject(Map json) => ErrorObject( + id: json.get('id', orGet: () => ''), + status: json.get('status', orGet: () => ''), + code: json.get('code', orGet: () => ''), + title: json.get('title', orGet: () => ''), + detail: json.get('detail', orGet: () => ''), + source: errorSource(json.get('source', orGet: () => {}))) + ..meta.addAll(meta(json)) + ..links.addAll(links(json)); + + /// Decodes ErrorSource from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + ErrorSource errorSource(Map json) => ErrorSource( + pointer: json.get('pointer', orGet: () => ''), + parameter: json.get('parameter', orGet: () => '')); + + /// Decodes Link from [json]. Returns the decoded object. + /// If the [json] has incorrect format, throws [FormatException]. + Link _link(Object json) { + if (json is String) return Link(Uri.parse(json)); + if (json is Map) { + return Link(Uri.parse(json['href']))..meta.addAll(meta(json)); + } + throw FormatException('Invalid JSON'); + } + + Map _getAttributes(Map json) => + json.get>('attributes', orGet: () => {}); + + Map _getRelationships(Map json) => json + .get('relationships', orGet: () => {}) + .map((key, value) => MapEntry(key, relationship(value))); + + Relationship _rel(data) { + if (data == null) return ToOne.empty(); + if (data is Map) return ToOne(identifier(data)); + if (data is List) return ToMany(data.whereType().map(identifier)); + throw FormatException('Invalid relationship object'); + } +} + +extension _TypedGeter on Map { + T get(String key, {T Function()? orGet}) { + if (containsKey(key)) { + final val = this[key]; + if (val is T) return val; + throw FormatException( + 'Key "$key": expected $T, found ${val.runtimeType}'); + } + if (orGet != null) return orGet(); + throw FormatException('Key "$key" does not exist'); + } +} diff --git a/lib/src/document/json_encodable.dart b/lib/src/document/json_encodable.dart deleted file mode 100644 index 78477116..00000000 --- a/lib/src/document/json_encodable.dart +++ /dev/null @@ -1,4 +0,0 @@ -abstract class JsonEncodable { - /// Converts the object to a json-encodable representation - Object toJson(); -} diff --git a/lib/src/document/link.dart b/lib/src/document/link.dart index aafcb124..6bd34807 100644 --- a/lib/src/document/link.dart +++ b/lib/src/document/link.dart @@ -1,57 +1,17 @@ -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/json_encodable.dart'; - /// A JSON:API link /// https://jsonapi.org/format/#document-links -class Link implements JsonEncodable { - Link(this.uri) { - ArgumentError.checkNotNull(uri, 'uri'); - } +class Link { + Link(this.uri); + /// Link URL final Uri uri; - /// Reconstructs the link from the [json] object - static Link fromJson(Object json) { - if (json is String) return Link(Uri.parse(json)); - if (json is Map) { - final href = json['href']; - if (href is String) { - return LinkObject(Uri.parse(href), meta: json['meta']); - } - } - throw DocumentException( - 'A JSON:API link must be a JSON string or a JSON object'); - } - - /// Reconstructs the document's `links` member into a map. - /// Details on the `links` member: https://jsonapi.org/format/#document-links - static Map mapFromJson(Object json) { - if (json is Map) { - return Map.unmodifiable(({...json}..removeWhere((_, v) => v == null)) - .map((k, v) => MapEntry(k.toString(), Link.fromJson(v)))); - } - throw DocumentException('A JSON:API links object must be a JSON object'); - } - - @override - Object toJson() => uri.toString(); + /// Link meta data + final meta = {}; @override String toString() => uri.toString(); -} -/// A JSON:API link object -/// https://jsonapi.org/format/#document-links -class LinkObject extends Link { - LinkObject(Uri href, {Map meta}) - : meta = Map.unmodifiable(meta ?? const {}), - super(href); - - final Map meta; - - @override - Object toJson() => { - 'href': uri.toString(), - if (meta.isNotEmpty) 'meta': meta, - }; + Object toJson() => + meta.isEmpty ? uri.toString() : {'href': uri.toString(), 'meta': meta}; } diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart new file mode 100644 index 00000000..05696739 --- /dev/null +++ b/lib/src/document/many.dart @@ -0,0 +1,26 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource_collection.dart'; + +class ToMany extends Relationship { + ToMany(Iterable identifiers) { + identifiers.forEach((_) => _map[_.key] = _); + } + + final _map = {}; + + @override + Map toJson() => + {'data': _map.values.toList(), ...super.toJson()}; + + @override + Iterator get iterator => _map.values.iterator; + + /// Finds the referenced elements which are found in the [collection]. + /// The resulting [Iterable] may contain fewer elements than referred by the + /// relationship if the [collection] does not have all of them. + Iterable findIn(ResourceCollection collection) { + return _map.keys.map((key) => collection[key]).whereType(); + } +} diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart new file mode 100644 index 00000000..b8ee227c --- /dev/null +++ b/lib/src/document/new_resource.dart @@ -0,0 +1,22 @@ +import 'package:json_api/src/document/resource_properties.dart'; + +/// A set of properties for a to-be-created resource which does not have the id yet. +class NewResource with ResourceProperties { + NewResource(this.type, [this.id]) { + ArgumentError.checkNotNull(type); + } + + /// Resource type + final String type; + + /// Nullable. Resource id. + final String? id; + + Map toJson() => { + 'type': type, + if (id != null) 'id': id!, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart new file mode 100644 index 00000000..99680bd3 --- /dev/null +++ b/lib/src/document/one.dart @@ -0,0 +1,23 @@ +import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/relationship.dart'; +import 'package:json_api/src/document/resource.dart'; +import 'package:json_api/src/document/resource_collection.dart'; + +class ToOne extends Relationship { + ToOne(this.identifier); + + ToOne.empty() : this(null); + + @override + Map toJson() => {'data': identifier, ...super.toJson()}; + + final Identifier? identifier; + + @override + Iterator get iterator => + identifier == null ? [].iterator : [identifier!].iterator; + + /// Finds the referenced resource in the [collection]. + Resource? findIn(ResourceCollection collection) => + collection[identifier?.key]; +} diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart new file mode 100644 index 00000000..aded1a14 --- /dev/null +++ b/lib/src/document/outbound_document.dart @@ -0,0 +1,68 @@ +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource.dart'; + +/// An empty outbound document. +class OutboundDocument { + /// The document "meta" object. + final meta = {}; + + Map toJson() => {'meta': meta}; +} + +/// An outbound error document. +class OutboundErrorDocument extends OutboundDocument { + OutboundErrorDocument(Iterable errors) { + this.errors.addAll(errors); + } + + /// The list of errors. + final errors = []; + + @override + Map toJson() => { + 'errors': errors, + if (meta.isNotEmpty) 'meta': meta, + }; +} + +/// An outbound data document. +class OutboundDataDocument extends OutboundDocument { + /// Creates an instance of a document containing a single resource as the primary data. + OutboundDataDocument.resource(Resource? resource) : _data = resource; + + /// Creates an instance of a document containing a single to-be-created resource as the primary data. Used only in client-to-server requests. + OutboundDataDocument.newResource(NewResource resource) : _data = resource; + + /// Creates an instance of a document containing a collection of resources as the primary data. + OutboundDataDocument.collection(Iterable collection) + : _data = collection.toList(); + + /// Creates an instance of a document containing a to-one relationship. + OutboundDataDocument.one(ToOne one) : _data = one.identifier { + meta.addAll(one.meta); + links.addAll(one.links); + } + + /// Creates an instance of a document containing a to-many relationship. + OutboundDataDocument.many(ToMany many) : _data = many.toList() { + meta.addAll(many.meta); + links.addAll(many.links); + } + + final Object? _data; + + /// Links related to the primary data. + final links = {}; + + /// A list of included resources. + final included = []; + + @override + Map toJson() => { + 'data': _data, + if (links.isNotEmpty) 'links': links, + if (included.isNotEmpty) 'included': included, + if (meta.isNotEmpty) 'meta': meta, + }; +} diff --git a/lib/src/document/primary_data.dart b/lib/src/document/primary_data.dart deleted file mode 100644 index 16e5beb0..00000000 --- a/lib/src/document/primary_data.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:json_api/src/document/json_encodable.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/document/resource_object.dart'; - -/// The top-level Primary Data. This is the essentials of the JSON:API Document. -/// -/// [PrimaryData] may be considered a Document itself with two limitations: -/// - it always has the `data` key (could be `null` for an empty to-one relationship) -/// - it can not have `meta` and `jsonapi` keys -abstract class PrimaryData implements JsonEncodable { - PrimaryData({Iterable included, Map links}) - : isCompound = included != null, - included = List.unmodifiable(included ?? const []), - links = Map.unmodifiable(links ?? const {}); - - /// In a Compound document, this member contains the included resources. - final List included; - - /// True for compound documents. - final bool isCompound; - - /// The top-level `links` object. May be empty or null. - final Map links; - - /// The `self` link. May be null. - Link get self => links['self']; - - @override - Map toJson() => { - if (links.isNotEmpty) 'links': links, - if (isCompound) 'included': included, - }; -} diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart index 5925a07a..59935672 100644 --- a/lib/src/document/relationship.dart +++ b/lib/src/document/relationship.dart @@ -1,140 +1,17 @@ -import 'package:json_api/src/document/document_exception.dart'; +import 'dart:collection'; + 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/primary_data.dart'; -import 'package:json_api/src/document/resource_object.dart'; -import 'package:json_api/src/nullable.dart'; - -/// The Relationship represents the references between the resources. -/// -/// A Relationship can be a JSON:API Document itself when -/// requested separately as described here https://jsonapi.org/format/#fetching-relationships. -/// -/// It can also be a part of [ResourceObject].relationships map. -/// -/// More on this: https://jsonapi.org/format/#document-resource-object-relationships -class Relationship extends PrimaryData { - Relationship({Iterable included, Map links}) - : super(included: included, links: links); - - /// Reconstructs a JSON:API Document or the `relationship` member of a Resource object. - static Relationship fromJson(Object json) { - if (json is Map) { - if (json.containsKey('data')) { - final data = json['data']; - if (data == null || data is Map) { - return ToOne.fromJson(json); - } - if (data is List) { - return ToMany.fromJson(json); - } - } - return Relationship(links: nullable(Link.mapFromJson)(json['links'])); - } - throw DocumentException( - 'A JSON:API relationship object must be a JSON object'); - } - - /// Parses the `relationships` member of a Resource Object - static Map mapFromJson(Object json) { - if (json is Map) { - return json - .map((k, v) => MapEntry(k.toString(), Relationship.fromJson(v))); - } - throw DocumentException("The 'relationships' member must be a JSON object"); - } - - /// The "related" link. May be null. - Link get related => links['related']; -} - -/// Relationship to-one -class ToOne extends Relationship { - ToOne(this.linkage, - {Iterable included, Map links}) - : super(included: included, links: links); - - ToOne.empty({Link self, Map links}) - : linkage = null, - super(links: links); - - static ToOne fromIdentifier(Identifier identifier) => - ToOne(nullable(IdentifierObject.fromIdentifier)(identifier)); - - static ToOne fromJson(Object json) { - if (json is Map && json.containsKey('data')) { - final included = json['included']; - final links = json['links']; - return ToOne(nullable(IdentifierObject.fromJson)(json['data']), - links: (links == null) ? null : Link.mapFromJson(links), - included: - included is List ? included.map(ResourceObject.fromJson) : null); - } - throw DocumentException( - "A to-one relationship must be a JSON object and contain the 'data' member"); - } - /// Resource Linkage - /// - /// Can be null for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final IdentifierObject linkage; +class Relationship with IterableMixin { + final links = {}; + final meta = {}; - @override - Map toJson() => { - ...super.toJson(), - 'data': linkage, + Map toJson() => { + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, }; - /// Converts to [Identifier]. - /// For empty relationships returns null. - Identifier unwrap() => linkage?.unwrap(); - - /// Same as [unwrap] - Identifier get identifier => unwrap(); -} - -/// Relationship to-many -class ToMany extends Relationship { - ToMany(Iterable linkage, - {Iterable included, Map links}) - : linkage = List.unmodifiable(linkage), - super(included: included, links: links); - - static ToMany fromIdentifiers(Iterable identifiers) => - ToMany(identifiers.map(IdentifierObject.fromIdentifier)); - - static ToMany fromJson(Object json) { - if (json is Map && json.containsKey('data')) { - final data = json['data']; - if (data is List) { - final links = json['links']; - return ToMany( - data.map(IdentifierObject.fromJson), - links: (links == null) ? null : Link.mapFromJson(links), - ); - } - } - throw DocumentException( - "A to-many relationship must be a JSON object and contain the 'data' member"); - } - - /// Resource Linkage - /// - /// Can be empty for empty relationships - /// - /// More on this: https://jsonapi.org/format/#document-resource-object-linkage - final List linkage; - @override - Map toJson() => { - ...super.toJson(), - 'data': linkage, - }; - - /// Converts to List. - /// For empty relationships returns an empty List. - List unwrap() => linkage.map((_) => _.unwrap()).toList(); + Iterator get iterator => const [].iterator; } diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart index 60d6207f..1b775dcb 100644 --- a/lib/src/document/resource.dart +++ b/lib/src/document/resource.dart @@ -1,54 +1,24 @@ -import 'package:json_api/src/document/identifier.dart'; +import 'package:json_api/src/document/identity.dart'; +import 'package:json_api/src/document/link.dart'; +import 'package:json_api/src/document/resource_properties.dart'; -/// Resource -/// -/// Together with [Identifier] forms the core of the Document model. -/// Resources are passed between the server and the client in the form -/// of [ResourceObject]s. -class Resource { - /// Creates an instance of [Resource]. - /// The [type] can not be null. - /// The [id] may be null for the resources to be created on the server. - Resource(this.type, this.id, - {Map attributes, - Map toOne, - Map> toMany}) - : attributes = Map.unmodifiable(attributes ?? const {}), - toOne = Map.unmodifiable(toOne ?? const {}), - toMany = Map.unmodifiable( - (toMany ?? {}).map((k, v) => MapEntry(k, Set.of(v).toList()))) { - ArgumentError.notNull(type); - } +class Resource with ResourceProperties, Identity { + Resource(this.type, this.id); - /// Resource type + @override final String type; - - /// Resource id - /// - /// May be null for resources to be created on the server - final String id; - - /// Unmodifiable map of attributes - final Map attributes; - - /// Unmodifiable map of to-one relationships - final Map toOne; - - /// Unmodifiable map of to-many relationships - final Map> toMany; - - /// Resource type and id combined - String get key => '$type:$id'; - @override - String toString() => 'Resource($key $attributes)'; -} + final String id; -/// Resource to be created on the server. Does not have the id yet -class NewResource extends Resource { - NewResource(String type, - {Map attributes, - Map toOne, - Map> toMany}) - : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany); + /// Resource links + final links = {}; + + Map toJson() => { + 'type': type, + 'id': id, + if (attributes.isNotEmpty) 'attributes': attributes, + if (relationships.isNotEmpty) 'relationships': relationships, + if (links.isNotEmpty) 'links': links, + if (meta.isNotEmpty) 'meta': meta, + }; } diff --git a/lib/src/document/resource_collection.dart b/lib/src/document/resource_collection.dart new file mode 100644 index 00000000..95533d85 --- /dev/null +++ b/lib/src/document/resource_collection.dart @@ -0,0 +1,21 @@ +import 'dart:collection'; + +import 'package:json_api/document.dart'; + +/// A collection of resources indexed by key. +class ResourceCollection with IterableMixin { + final _map = {}; + + Resource? operator [](Object? key) => _map[key]; + + void add(Resource resource) { + _map[resource.key] = resource; + } + + void addAll(Iterable resources) { + resources.forEach(add); + } + + @override + Iterator get iterator => _map.values.iterator; +} diff --git a/lib/src/document/resource_collection_data.dart b/lib/src/document/resource_collection_data.dart deleted file mode 100644 index 4b09567e..00000000 --- a/lib/src/document/resource_collection_data.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -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 resource collection or a collection of related resources of a to-many relationship -class ResourceCollectionData extends PrimaryData { - ResourceCollectionData(Iterable collection, - {Iterable included, Map links}) - : collection = List.unmodifiable(collection ?? const []), - super(included: included, links: links); - - static ResourceCollectionData fromJson(Object json) { - if (json is Map) { - final data = json['data']; - if (data is List) { - final included = json['included']; - return ResourceCollectionData(data.map(ResourceObject.fromJson), - links: Link.mapFromJson(json['links'] ?? {}), - included: included is List - ? included.map(ResourceObject.fromJson) - : null); - } - } - throw DocumentException( - "A JSON:API resource collection document must be a JSON object with a JSON array in the 'data' member"); - } - - final List collection; - - /// The link to the last page. May be null. - Link get last => (links ?? {})['last']; - - /// The link to the first page. May be null. - Link get first => (links ?? {})['first']; - - /// The link to the next page. May be null. - Link get next => (links ?? {})['next']; - - /// The link to the prev page. May be null. - Link get prev => (links ?? {})['prev']; - - /// Returns a list of resources contained in the collection - List unwrap() => collection.map((_) => _.unwrap()).toList(); - - /// Returns a map of resources indexed by ids - Map unwrapToMap() => - Map.fromIterable(unwrap(), key: (r) => r.id); - - @override - Map toJson() => { - ...super.toJson(), - ...{'data': collection}, - }; -} diff --git a/lib/src/document/resource_data.dart b/lib/src/document/resource_data.dart deleted file mode 100644 index 5dd6059e..00000000 --- a/lib/src/document/resource_data.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:json_api/src/document/document_exception.dart'; -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'; -import 'package:json_api/src/nullable.dart'; - -/// Represents a single resource or a single related resource of a to-one relationship -class ResourceData extends PrimaryData { - ResourceData(this.resourceObject, - {Iterable included, Map links}) - : super( - included: included, links: {...?resourceObject?.links, ...?links}); - - static ResourceData fromResource(Resource resource) => - ResourceData(ResourceObject.fromResource(resource)); - - static ResourceData fromJson(Object json) { - if (json is Map) { - Iterable resources; - final included = json['included']; - if (included is List) { - resources = included.map(ResourceObject.fromJson); - } else if (included != null) { - throw DocumentException("The 'included' value must be a JSON array"); - } - final data = nullable(ResourceObject.fromJson)(json['data']); - return ResourceData(data, - links: Link.mapFromJson(json['links'] ?? {}), included: resources); - } - throw DocumentException( - "A JSON:API resource document must be a JSON object and contain the 'data' member"); - } - - final ResourceObject resourceObject; - - @override - Map toJson() => { - ...super.toJson(), - 'data': resourceObject.toJson(), - }; - - Resource unwrap() => resourceObject?.unwrap(); -} diff --git a/lib/src/document/resource_object.dart b/lib/src/document/resource_object.dart deleted file mode 100644 index 31e70e27..00000000 --- a/lib/src/document/resource_object.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_api/src/document/identifier.dart'; -import 'package:json_api/src/document/json_encodable.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]. -/// -/// 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 implements JsonEncodable { - ResourceObject(this.type, this.id, - {Map attributes, - Map relationships, - Map meta, - Map links}) - : links = Map.unmodifiable(links ?? const {}), - attributes = Map.unmodifiable(attributes ?? const {}), - meta = Map.unmodifiable(meta ?? const {}), - relationships = Map.unmodifiable(relationships ?? const {}); - - static ResourceObject fromResource(Resource resource) => - ResourceObject(resource.type, resource.id, - attributes: resource.attributes, - relationships: { - ...resource.toOne.map((k, v) => MapEntry( - k, ToOne(nullable(IdentifierObject.fromIdentifier)(v)))), - ...resource.toMany.map((k, v) => - MapEntry(k, ToMany(v.map(IdentifierObject.fromIdentifier)))) - }); - - /// Reconstructs the `data` member of a JSON:API Document. - static ResourceObject fromJson(Object json) { - if (json is Map) { - final relationships = json['relationships']; - final attributes = json['attributes']; - final type = json['type']; - if ((relationships == null || relationships is Map) && - (attributes == null || attributes is Map) && - type is String && - type.isNotEmpty) { - return ResourceObject(json['type'], json['id'], - attributes: attributes, - relationships: nullable(Relationship.mapFromJson)(relationships), - links: Link.mapFromJson(json['links'] ?? {}), - meta: json['meta']); - } - throw DocumentException('Invalid JSON:API resource object'); - } - throw DocumentException('A JSON:API resource must be a JSON object'); - } - - final String type; - final String id; - final Map attributes; - final Map relationships; - final Map meta; - - /// Read-only `links` object. May be empty. - final Map links; - - Link get self => links['self']; - - /// Returns the JSON object to be used in the `data` or `included` members - /// of a JSON:API Document - @override - Map toJson() => { - 'type': type, - if (id != null) 'id': id, - if (meta.isNotEmpty) 'meta': meta, - if (attributes.isNotEmpty) 'attributes': attributes, - if (relationships.isNotEmpty) 'relationships': relationships, - if (links.isNotEmpty) 'links': links, - }; - - /// Extracts the [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]. - /// - /// Example of missing `data`: https://discuss.jsonapi.org/t/relationships-data-node/223 - Resource unwrap() { - final toOne = {}; - final toMany = >{}; - final incomplete = {}; - relationships.forEach((name, rel) { - if (rel is ToOne) { - toOne[name] = rel.unwrap(); - } else if (rel is ToMany) { - toMany[name] = rel.unwrap(); - } else { - incomplete[name] = rel; - } - }); - - if (incomplete.isNotEmpty) { - throw StateError('Can not convert to resource' - ' due to incomplete relationship: ${incomplete.keys}'); - } - - return Resource(type, id, - attributes: attributes, toOne: toOne, toMany: toMany); - } -} diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart new file mode 100644 index 00000000..7e52cc85 --- /dev/null +++ b/lib/src/document/resource_properties.dart @@ -0,0 +1,30 @@ +import 'package:json_api/src/document/many.dart'; +import 'package:json_api/src/document/one.dart'; +import 'package:json_api/src/document/relationship.dart'; + +mixin ResourceProperties { + /// Resource meta data. + final meta = {}; + + /// Resource attributes. + /// + /// See https://jsonapi.org/format/#document-resource-object-attributes + final attributes = {}; + + /// Resource relationships. + /// + /// See https://jsonapi.org/format/#document-resource-object-relationships + final relationships = {}; + + /// Returns a to-one relationship by its [name]. + ToOne? one(String name) => _rel(name); + + /// Returns a to-many relationship by its [name]. + ToMany? many(String name) => _rel(name); + + /// Returns a typed relationship by its [name]. + R? _rel(String name) { + final r = relationships[name]; + if (r is R) return r; + } +} diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart index 7693fd07..234c125e 100644 --- a/lib/src/http/http_handler.dart +++ b/lib/src/http/http_handler.dart @@ -1,23 +1,6 @@ import 'package:json_api/src/http/http_request.dart'; import 'package:json_api/src/http/http_response.dart'; -/// A callable class which converts requests to responses abstract class HttpHandler { - /// Sends the request over the network and returns the received response - Future call(HttpRequest request); - - /// Creates an instance of [HttpHandler] from a function - static HttpHandler fromFunction(HttpHandlerFunc f) => _HandlerFromFunction(f); -} - -/// This typedef is compatible with [HttpHandler] -typedef HttpHandlerFunc = Future Function(HttpRequest request); - -class _HandlerFromFunction implements HttpHandler { - const _HandlerFromFunction(this._f); - - @override - Future call(HttpRequest request) => _f(request); - - final HttpHandlerFunc _f; + Future handle(HttpRequest request); } diff --git a/lib/src/http/http_headers.dart b/lib/src/http/http_headers.dart new file mode 100644 index 00000000..a7111041 --- /dev/null +++ b/lib/src/http/http_headers.dart @@ -0,0 +1,8 @@ +import 'dart:collection'; + +mixin HttpHeaders { + /// Message headers. Case-insensitive. + final headers = LinkedHashMap( + equals: (a, b) => a.toLowerCase() == b.toLowerCase(), + hashCode: (s) => s.toLowerCase().hashCode); +} diff --git a/lib/src/http/http_message.dart b/lib/src/http/http_message.dart new file mode 100644 index 00000000..64142ac9 --- /dev/null +++ b/lib/src/http/http_message.dart @@ -0,0 +1,9 @@ +import 'package:json_api/src/http/http_headers.dart'; + +/// HTTP message. Request or Response. +class HttpMessage with HttpHeaders { + HttpMessage(this.body); + + /// Message body + final String body; +} diff --git a/lib/src/http/http_request.dart b/lib/src/http/http_request.dart index 8fa85ce4..2eef17c0 100644 --- a/lib/src/http/http_request.dart +++ b/lib/src/http/http_request.dart @@ -1,21 +1,24 @@ +import 'package:json_api/src/http/http_message.dart'; + /// The request which is sent by the client and received by the server -class HttpRequest { - HttpRequest(String method, this.uri, - {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - method = method.toUpperCase(), - body = body ?? ''; +class HttpRequest extends HttpMessage { + HttpRequest(String method, this.uri, {String body = ''}) + : method = method.toLowerCase(), + super(body); /// Requested URI final Uri uri; - /// Request method, uppercase + /// Request method, lowercase final String method; - /// Request body - final String body; + bool get isGet => method == 'get'; + + bool get isPost => method == 'post'; + + bool get isDelete => method == 'delete'; + + bool get isPatch => method == 'patch'; - /// Request headers. Unmodifiable. Lowercase keys - final Map headers; + bool get isOptions => method == 'options'; } diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart index dbe90e7e..b0b23a32 100644 --- a/lib/src/http/http_response.dart +++ b/lib/src/http/http_response.dart @@ -1,16 +1,9 @@ +import 'package:json_api/src/http/http_message.dart'; + /// The response sent by the server and received by the client -class HttpResponse { - HttpResponse(this.statusCode, {String body, Map headers}) - : headers = Map.unmodifiable( - (headers ?? {}).map((k, v) => MapEntry(k.toLowerCase(), v))), - body = body ?? ''; +class HttpResponse extends HttpMessage { + HttpResponse(this.statusCode, {String body = ''}) : super(body); /// Response status code final int statusCode; - - /// Response body - final String body; - - /// Response headers. Unmodifiable. Lowercase keys - final Map headers; } diff --git a/lib/src/http/logging_handler.dart b/lib/src/http/logging_handler.dart new file mode 100644 index 00000000..1ae8c38a --- /dev/null +++ b/lib/src/http/logging_handler.dart @@ -0,0 +1,20 @@ +import 'package:json_api/src/http/http_handler.dart'; +import 'package:json_api/src/http/http_request.dart'; +import 'package:json_api/src/http/http_response.dart'; + +/// A wrapper over [HttpHandler] which allows logging +class LoggingHandler implements HttpHandler { + LoggingHandler(this.handler, {this.onRequest, this.onResponse}); + + final HttpHandler handler; + final Function(HttpRequest request)? onRequest; + final Function(HttpResponse response)? onResponse; + + @override + Future handle(HttpRequest request) async { + onRequest?.call(request); + final response = await handler.handle(request); + onResponse?.call(response); + return response; + } +} diff --git a/lib/src/http/logging_http_handler.dart b/lib/src/http/logging_http_handler.dart deleted file mode 100644 index ddc092d6..00000000 --- a/lib/src/http/logging_http_handler.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/src/http/http_handler.dart'; -import 'package:json_api/src/http/http_request.dart'; -import 'package:json_api/src/http/http_response.dart'; - -/// A wrapper over [HttpHandler] which allows logging -class LoggingHttpHandler implements HttpHandler { - LoggingHttpHandler(this.wrapped, {this.onRequest, this.onResponse}); - - /// The wrapped handler - final HttpHandler wrapped; - - /// This function will be called before the request is sent - final void Function(HttpRequest) onRequest; - - /// This function will be called after the response is received - final void Function(HttpResponse) onResponse; - - @override - Future call(HttpRequest request) async { - onRequest?.call(request); - final response = await wrapped(request); - onResponse?.call(response); - return response; - } -} diff --git a/lib/src/http/media_type.dart b/lib/src/http/media_type.dart new file mode 100644 index 00000000..facf0de6 --- /dev/null +++ b/lib/src/http/media_type.dart @@ -0,0 +1,3 @@ +class MediaType { + static const jsonApi = 'application/vnd.api+json'; +} diff --git a/lib/src/http/payload_codec.dart b/lib/src/http/payload_codec.dart new file mode 100644 index 00000000..9c6d272a --- /dev/null +++ b/lib/src/http/payload_codec.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +/// Encodes/decodes JSON payload +class PayloadCodec { + const PayloadCodec(); + + Future decode(String body) async { + final json = jsonDecode(body); + if (json is Map) return json; + throw FormatException('Invalid JSON payload: ${json.runtimeType}'); + } + + Future encode(Object document) async => jsonEncode(document); +} diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart new file mode 100644 index 00000000..c59ca8df --- /dev/null +++ b/lib/src/http/status_code.dart @@ -0,0 +1,23 @@ +class StatusCode { + const StatusCode(this.value); + + static const ok = 200; + static const created = 201; + static const accepted = 202; + static const noContent = 204; + static const badRequest = 400; + static const notFound = 404; + static const methodNotAllowed = 405; + + final int value; + + /// True for the requests processed asynchronously. + /// @see https://jsonapi.org/recommendations/#asynchronous-processing). + bool get isPending => value == accepted; + + /// True for successfully processed requests + bool get isSuccessful => value >= ok && value < 300 && !isPending; + + /// True for failed requests (i.e. neither successful nor pending) + bool get isFailed => !isSuccessful && !isPending; +} diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart index 88675a9c..f6dfb1e4 100644 --- a/lib/src/nullable.dart +++ b/lib/src/nullable.dart @@ -1,3 +1,2 @@ -_Fun nullable(U Function(V v) f) => (v) => v == null ? null : f(v); - -typedef _Fun = U Function(V v); +U? Function(V? v) nullable(U Function(V v) f) => + (v) => v == null ? null : f(v); diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart index e36a5cf7..05d99b17 100644 --- a/lib/src/query/fields.dart +++ b/lib/src/query/fields.dart @@ -1,36 +1,45 @@ -import 'package:json_api/src/query/query_parameters.dart'; +import 'dart:collection'; /// Query parameters defining Sparse Fieldsets /// @see https://jsonapi.org/format/#fetching-sparse-fieldsets -class Fields extends QueryParameters { +class Fields with MapMixin> { /// The [fields] argument maps the resource type to a list of fields. /// /// Example: /// ```dart - /// Fields({'articles': ['title', 'body'], 'people': ['name']}).addTo(url); + /// Fields({'articles': ['title', 'body'], 'people': ['name']}); /// ``` - /// encodes to - /// ``` - /// ?fields[articles]=title,body&fields[people]=name - /// ``` - Fields(Map> fields) - : _fields = {...fields}, - super(fields.map((k, v) => MapEntry('fields[$k]', v.join(',')))); + Fields([Map> fields = const {}]) { + addAll(fields); + } /// Extracts the requested fields from the [uri]. - static Fields fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); - - /// Extracts the requested fields from [queryParameters]. - static Fields fromQueryParameters( - Map> queryParameters) => - Fields(queryParameters.map((k, v) => MapEntry( - _regex.firstMatch(k)?.group(1), + static Fields fromUri(Uri uri) => + Fields(uri.queryParametersAll.map((k, v) => MapEntry( + _regex.firstMatch(k)?.group(1) ?? '', v.expand((_) => _.split(',')).toList())) - ..removeWhere((k, v) => k == null)); - - List operator [](String key) => _fields[key]; + ..removeWhere((k, v) => k.isEmpty)); static final _regex = RegExp(r'^fields\[(.+)\]$'); - final Map> _fields; + final _map = >{}; + + /// Converts to a map of query parameters + Map get asQueryParameters => + _map.map((k, v) => MapEntry('fields[$k]', v.join(','))); + + @override + void operator []=(String key, Iterable value) => _map[key] = value; + + @override + void clear() => _map.clear(); + + @override + Iterable get keys => _map.keys; + + @override + Iterable? remove(Object? key) => _map.remove(key); + + @override + Iterable? operator [](Object? key) => _map[key]; } diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart index 105a6fe8..85852a41 100644 --- a/lib/src/query/filter.dart +++ b/lib/src/query/filter.dart @@ -1,30 +1,42 @@ -import 'package:json_api/src/query/query_parameters.dart'; +import 'dart:collection'; -/// Query parameters defining Filter -/// @see https://jsonapi.org/recommendations/#filtering -class Filter extends QueryParameters { - /// The [filter] argument maps the resource type to a list of filters. - /// +class Filter with MapMixin { /// Example: /// ```dart - /// Filter({'articles': ['title', 'body'], 'people': ['name']}).addTo(url); + /// Filter({'post': '1,2', 'author': '12'}).addTo(url); /// ``` - /// encodes to + /// encodes into /// ``` - /// ?filter[articles]=title,body&filter[people]=name + /// ?filter[post]=1,2&filter[author]=12 /// ``` - Filter(Map> filter) - : _filter = {...filter}, - super(filter.map((k, v) => MapEntry('filter[$k]', v.join(',')))); + Filter([Map parameters = const {}]) { + addAll(parameters); + } - /// Extracts the requested filter from the [uri]. - static Filter fromUri(Uri uri) => Filter(uri.queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.split(','))) - ..removeWhere((k, v) => k == null)); - - List operator [](String key) => _filter[key]; + static Filter fromUri(Uri uri) => Filter(uri.queryParametersAll + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty)); static final _regex = RegExp(r'^filter\[(.+)\]$'); - final Map> _filter; + final _ = {}; + + /// Converts to a map of query parameters + Map get asQueryParameters => + _.map((k, v) => MapEntry('filter[$k]', v)); + + @override + String? operator [](Object? key) => _[key]; + + @override + void operator []=(String key, String value) => _[key] = value; + + @override + void clear() => _.clear(); + + @override + Iterable get keys => _.keys; + + @override + String? remove(Object? key) => _.remove(key); } diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart index a82273a3..8b25bdb9 100644 --- a/lib/src/query/include.dart +++ b/lib/src/query/include.dart @@ -1,30 +1,28 @@ import 'dart:collection'; -import 'package:json_api/src/query/query_parameters.dart'; - /// Query parameter defining inclusion of related resources. /// @see https://jsonapi.org/format/#fetching-includes -class Include extends QueryParameters with IterableMixin { +class Include with IterableMixin { /// Example: /// ```dart - /// Include(['comments', 'comments.author']).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?include=comments,comments.author + /// Include(['comments', 'comments.author']); /// ``` - Include(Iterable resources) - : _resources = [...resources], - super({'include': resources.join(',')}); + Include([Iterable resources = const []]) { + _.addAll(resources); + } - static Include fromUri(Uri uri) => - fromQueryParameters(uri.queryParametersAll); + static Include fromUri(Uri uri) => Include( + uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? []); - static Include fromQueryParameters(Map> parameters) => - Include((parameters['include']?.expand((_) => _.split(',')) ?? [])); + final _ = []; + + /// Converts to a map of query parameters + Map get asQueryParameters => + {if (isNotEmpty) 'include': join(',')}; @override - Iterator get iterator => _resources.iterator; + Iterator get iterator => _.iterator; - final List _resources; + @override + int get length => _.length; } diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart index b9d9b224..301b0741 100644 --- a/lib/src/query/page.dart +++ b/lib/src/query/page.dart @@ -1,8 +1,8 @@ -import 'package:json_api/src/query/query_parameters.dart'; +import 'dart:collection'; /// Query parameters defining the pagination data. /// @see https://jsonapi.org/format/#fetching-pagination -class Page extends QueryParameters { +class Page with MapMixin { /// Example: /// ```dart /// Page({'limit': '10', 'offset': '20'}).addTo(url); @@ -12,20 +12,33 @@ class Page extends QueryParameters { /// ?page[limit]=10&page[offset]=20 /// ``` /// - Page(Map parameters) - : _parameters = {...parameters}, - super(parameters.map((k, v) => MapEntry('page[${k}]', v))); + Page([Map parameters = const {}]) { + addAll(parameters); + } - static Page fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); + static Page fromUri(Uri uri) => Page(uri.queryParametersAll + .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last)) + ..removeWhere((k, v) => k.isEmpty)); + static final _regex = RegExp(r'^page\[(.+)\]$'); - static Page fromQueryParameters(Map> queryParameters) => - Page(queryParameters - .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.last)) - ..removeWhere((k, v) => k == null)); + final _ = {}; - String operator [](String key) => _parameters[key]; + /// Converts to a map of query parameters + Map get asQueryParameters => + _.map((k, v) => MapEntry('page[$k]', v)); - static final _regex = RegExp(r'^page\[(.+)\]$'); + @override + String? operator [](Object? key) => _[key]; + + @override + void operator []=(String key, String value) => _[key] = value; + + @override + void clear() => _.clear(); + + @override + Iterable get keys => _.keys; - final Map _parameters; + @override + String? remove(Object? key) => _.remove(key); } diff --git a/lib/src/query/query_parameters.dart b/lib/src/query/query_parameters.dart deleted file mode 100644 index b55beced..00000000 --- a/lib/src/query/query_parameters.dart +++ /dev/null @@ -1,24 +0,0 @@ -/// This class and its descendants describe the query parameters recognized -/// by JSON:API. -class QueryParameters { - QueryParameters(Map parameters) - : _parameters = {...parameters}; - final Map _parameters; - - bool get isEmpty => _parameters.isEmpty; - - bool get isNotEmpty => _parameters.isNotEmpty; - - /// Adds (or replaces) this parameters to the [uri]. - Uri addToUri(Uri uri) => isEmpty - ? uri - : uri.replace(queryParameters: {...uri.queryParameters, ..._parameters}); - - /// Merges this parameters with [other] parameters. Returns a new instance. - QueryParameters merge(QueryParameters other) => - QueryParameters({..._parameters, ...other._parameters}); - - /// A shortcut for [merge] - QueryParameters operator &(QueryParameters moreParameters) => - merge(moreParameters); -} diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart index 0636b1d1..85200cc5 100644 --- a/lib/src/query/sort.dart +++ b/lib/src/query/sort.dart @@ -1,65 +1,67 @@ import 'dart:collection'; -import 'package:json_api/src/query/query_parameters.dart'; - /// Query parameters defining the sorting. /// @see https://jsonapi.org/format/#fetching-sorting -class Sort extends QueryParameters with IterableMixin { +class Sort with IterableMixin { /// The [fields] arguments is the list of sorting criteria. - /// Use [Asc] and [Desc] to define sort direction. /// /// Example: /// ```dart - /// Sort([Asc('created'), Desc('title')]).addTo(url); - /// ``` - /// encodes into - /// ``` - /// ?sort=-created,title + /// Sort(['-created', 'title']); /// ``` - Sort(Iterable fields) - : _fields = [...fields], - super({'sort': fields.join(',')}); - final List _fields; + Sort([Iterable fields = const []]) { + _.addAll(fields.map((SortField.parse))); + } - static Sort fromUri(Uri uri) => fromQueryParameters(uri.queryParametersAll); + static Sort fromUri(Uri uri) => + Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? [])); - static Sort fromQueryParameters(Map> queryParameters) => - Sort((queryParameters['sort']?.expand((_) => _.split(',')) ?? []) - .map(SortField.parse)); + final _ = []; - @override - Iterator get iterator => _fields.iterator; -} + /// Converts to a map of query parameters + Map get asQueryParameters => + {if (isNotEmpty) 'sort': join(',')}; -class SortField { - SortField.Asc(this.name) - : isAsc = true, - isDesc = false; + @override + int get length => _.length; - SortField.Desc(this.name) - : isAsc = false, - isDesc = true; + @override + Iterator get iterator => _.iterator; +} +abstract class SortField { static SortField parse(String queryParam) => queryParam.startsWith('-') ? Desc(queryParam.substring(1)) : Asc(queryParam); - final bool isAsc; - final bool isDesc; + String get name; + + /// Returns 1 for Ascending fields, -1 for Descending + int get factor; +} + +class Asc implements SortField { + const Asc(this.name); + @override final String name; - /// Returns 1 for Ascending fields, -1 for Descending - int get comparisonFactor => isAsc ? 1 : -1; + @override + final int factor = 1; @override - String toString() => isAsc ? name : '-$name'; + String toString() => name; } -class Asc extends SortField { - Asc(String name) : super.Asc(name); -} +class Desc implements SortField { + const Desc(this.name); -class Desc extends SortField { - Desc(String name) : super.Desc(name); + @override + final String name; + + @override + final int factor = -1; + + @override + String toString() => '-$name'; } diff --git a/lib/src/routing/composite_routing.dart b/lib/src/routing/composite_routing.dart deleted file mode 100644 index 7d73d5e4..00000000 --- a/lib/src/routing/composite_routing.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_api/src/routing/route_factory.dart'; -import 'package:json_api/src/routing/route_matcher.dart'; -import 'package:json_api/src/routing/routes.dart'; - -/// URI design composed of independent routes. -class CompositeRouting implements RouteFactory, RouteMatcher { - CompositeRouting(this.collectionRoute, this.resourceRoute, this.relatedRoute, - this.relationshipRoute); - - final CollectionRoute collectionRoute; - final ResourceRoute resourceRoute; - final RelatedRoute relatedRoute; - final RelationshipRoute relationshipRoute; - - @override - Uri collection(String type) => collectionRoute.uri(type); - - @override - Uri related(String type, String id, String relationship) => - relatedRoute.uri(type, id, relationship); - - @override - Uri relationship(String type, String id, String relationship) => - relationshipRoute.uri(type, id, relationship); - - @override - Uri resource(String type, String id) => resourceRoute.uri(type, id); - - @override - bool matchCollection(Uri uri, void Function(String type) onMatch) => - collectionRoute.match(uri, onMatch); - - @override - bool matchRelated(Uri uri, - void Function(String type, String id, String relationship) onMatch) => - relatedRoute.match(uri, onMatch); - - @override - bool matchRelationship(Uri uri, - void Function(String type, String id, String relationship) onMatch) => - relationshipRoute.match(uri, onMatch); - - @override - bool matchResource(Uri uri, void Function(String type, String id) onMatch) => - resourceRoute.match(uri, onMatch); -} diff --git a/lib/src/routing/route_factory.dart b/lib/src/routing/route_factory.dart deleted file mode 100644 index 78e79f81..00000000 --- a/lib/src/routing/route_factory.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// Makes URIs for specific targets -abstract class RouteFactory { - /// Returns a URL for the primary resource collection of type [type] - Uri collection(String type); - - /// Returns a URL for the related resource/collection. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - Uri related(String type, String id, String relationship); - - /// Returns a URL for the relationship itself. - /// The [type] and [id] identify the primary resource and the [relationship] - /// is the relationship name. - Uri relationship(String type, String id, String relationship); - - /// Returns a URL for the primary resource of type [type] with id [id] - Uri resource(String type, String id); -} diff --git a/lib/src/routing/route_matcher.dart b/lib/src/routing/route_matcher.dart deleted file mode 100644 index eb8cbb2a..00000000 --- a/lib/src/routing/route_matcher.dart +++ /dev/null @@ -1,26 +0,0 @@ -/// Matches the URI with URI Design patterns. -/// -/// See https://jsonapi.org/recommendations/#urls -abstract class RouteMatcher { - /// Matches the [uri] with a collection route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchCollection(Uri uri, void Function(String type) onMatch); - - /// Matches the [uri] with a resource route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchResource(Uri uri, void Function(String type, String id) onMatch); - - /// Matches the [uri] with a related route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchRelated(Uri uri, - void Function(String type, String id, String relationship) onMatch); - - /// Matches the [uri] with a relationship route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool matchRelationship(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} diff --git a/lib/src/routing/routes.dart b/lib/src/routing/routes.dart deleted file mode 100644 index 750f1dde..00000000 --- a/lib/src/routing/routes.dart +++ /dev/null @@ -1,39 +0,0 @@ -/// Primary resource collection route -abstract class CollectionRoute { - /// Returns the URI for a collection of type [type]. - Uri uri(String type); - - /// Matches the [uri] with a collection route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, void Function(String type) onMatch); -} - -abstract class RelationshipRoute { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a relationship route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} - -abstract class RelatedRoute { - Uri uri(String type, String id, String relationship); - - /// Matches the [uri] with a related route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch); -} - -abstract class ResourceRoute { - Uri uri(String type, String id); - - /// Matches the [uri] with a resource route pattern. - /// If the match is successful, calls the [onMatch] and returns true. - /// Otherwise returns false. - bool match(Uri uri, void Function(String type, String id) onMatch); -} diff --git a/lib/src/routing/standard_routes.dart b/lib/src/routing/standard_routes.dart deleted file mode 100644 index c8c5d38d..00000000 --- a/lib/src/routing/standard_routes.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:json_api/src/routing/routes.dart'; - -/// The recommended URI design for a primary resource collections. -/// Example: `/photos` -/// -/// See: https://jsonapi.org/recommendations/#urls-resource-collections -class StandardCollectionRoute extends _BaseRoute implements CollectionRoute { - StandardCollectionRoute([Uri base]) : super(base); - - @override - bool match(Uri uri, void Function(String type) onMatch) { - final seg = _segments(uri); - if (seg.length == 1) { - onMatch(seg.first); - return true; - } - return false; - } - - @override - Uri uri(String type) => _resolve([type]); -} - -/// The recommended URI design for a primary resource. -/// Example: `/photos/1` -/// -/// See: https://jsonapi.org/recommendations/#urls-individual-resources -class StandardResourceRoute extends _BaseRoute implements ResourceRoute { - StandardResourceRoute([Uri base]) : super(base); - - @override - bool match(Uri uri, void Function(String type, String id) onMatch) { - final seg = _segments(uri); - if (seg.length == 2) { - onMatch(seg.first, seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id) => _resolve([type, id]); -} - -/// The recommended URI design for a related resource or collections. -/// Example: `/photos/1/comments` -/// -/// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelatedRoute extends _BaseRoute implements RelatedRoute { - StandardRelatedRoute([Uri base]) : super(base); - - @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { - final seg = _segments(uri); - if (seg.length == 3) { - onMatch(seg.first, seg[1], seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id, String relationship) => - _resolve([type, id, relationship]); -} - -/// The recommended URI design for a relationship. -/// Example: `/photos/1/relationships/comments` -/// -/// See: https://jsonapi.org/recommendations/#urls-relationships -class StandardRelationshipRoute extends _BaseRoute - implements RelationshipRoute { - StandardRelationshipRoute([Uri base]) : super(base); - - @override - bool match(Uri uri, - void Function(String type, String id, String relationship) onMatch) { - final seg = _segments(uri); - if (seg.length == 4 && seg[2] == _rel) { - onMatch(seg.first, seg[1], seg.last); - return true; - } - return false; - } - - @override - Uri uri(String type, String id, String relationship) => - _resolve([type, id, _rel, relationship]); - - static const _rel = 'relationships'; -} - -class _BaseRoute { - _BaseRoute([Uri base]) : _base = base ?? Uri(path: '/'); - - final Uri _base; - - Uri _resolve(List pathSegments) => - _base.resolveUri(Uri(pathSegments: pathSegments)); - - List _segments(Uri uri) => - uri.pathSegments.skip(_base.pathSegments.length).toList(); -} diff --git a/lib/src/routing/standard_routing.dart b/lib/src/routing/standard_routing.dart deleted file mode 100644 index eeab080e..00000000 --- a/lib/src/routing/standard_routing.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/src/routing/composite_routing.dart'; -import 'package:json_api/src/routing/standard_routes.dart'; - -/// The standard (recommended) URI design -class StandardRouting extends CompositeRouting { - StandardRouting([Uri base]) - : super(StandardCollectionRoute(base), StandardResourceRoute(base), - StandardRelatedRoute(base), StandardRelationshipRoute(base)); -} diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart new file mode 100644 index 00000000..27d13829 --- /dev/null +++ b/lib/src/routing/standard_uri_design.dart @@ -0,0 +1,61 @@ +import 'package:json_api/routing.dart'; + +/// URL Design recommended by the standard. +/// See https://jsonapi.org/recommendations/#urls +class StandardUriDesign implements UriDesign { + /// Creates an instance of [UriDesign] recommended by JSON:API standard. + /// The [base] URI will be used as a prefix for the generated URIs. + const StandardUriDesign(this.base); + + /// A "path only" version of the recommended URL design, e.g. + /// `/books`, `/books/42`, `/books/42/authors` + static final pathOnly = StandardUriDesign(Uri(path: '/')); + + static Target? matchTarget(Uri uri) { + final s = uri.pathSegments; + if (s.length == 1) { + return Target(s.first); + } + if (s.length == 2) { + return ResourceTarget(s.first, s.last); + } + if (s.length == 3) { + return RelatedTarget(s.first, s[1], s.last); + } + if (s.length == 4 && s[2] == 'relationships') { + return RelationshipTarget(s.first, s[1], s.last); + } + return null; + } + + final Uri base; + + /// Returns a URL for the primary resource collection of type [type]. + /// E.g. `/books`. + @override + Uri collection(String type) => _resolve([type]); + + /// Returns a URL for the primary resource of type [type] with id [id]. + /// E.g. `/books/123`. + @override + Uri resource(String type, String id) => _resolve([type, id]); + + /// Returns a URL for the relationship itself. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + /// E.g. `/books/123/relationships/authors`. + @override + Uri relationship(String type, String id, String relationship) => + _resolve([type, id, 'relationships', relationship]); + + /// Returns a URL for the related resource or collection. + /// The [type] and [id] identify the primary resource and the [relationship] + /// is the relationship name. + /// E.g. `/books/123/authors`. + @override + Uri related(String type, String id, String relationship) => + _resolve([type, id, relationship]); + + Uri _resolve(List pathSegments) => + base.resolveUri(Uri(pathSegments: pathSegments)); +} diff --git a/lib/src/routing/target.dart b/lib/src/routing/target.dart new file mode 100644 index 00000000..78a59c25 --- /dev/null +++ b/lib/src/routing/target.dart @@ -0,0 +1,35 @@ +class Target { + const Target(this.type); + + final String type; +} + +class ResourceTarget implements Target { + const ResourceTarget(this.type, this.id); + + @override + final String type; + final String id; +} + +class RelatedTarget implements ResourceTarget { + const RelatedTarget(this.type, this.id, this.relationship); + + @override + final String type; + @override + final String id; + + final String relationship; +} + +class RelationshipTarget implements ResourceTarget { + const RelationshipTarget(this.type, this.id, this.relationship); + + @override + final String type; + @override + final String id; + + final String relationship; +} diff --git a/lib/src/routing/uri_design.dart b/lib/src/routing/uri_design.dart new file mode 100644 index 00000000..4bb1519b --- /dev/null +++ b/lib/src/routing/uri_design.dart @@ -0,0 +1,9 @@ +abstract class UriDesign { + Uri collection(String type); + + Uri resource(String type, String id); + + Uri related(String type, String id, String relationship); + + Uri relationship(String type, String id, String relationship); +} diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart index 6035b2d4..9759d0b6 100644 --- a/lib/src/server/controller.dart +++ b/lib/src/server/controller.dart @@ -1,60 +1,41 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// This is a controller consolidating all possible requests a JSON:API server -/// may handle. -abstract class Controller { - /// Finds an returns a primary resource collection. - /// See https://jsonapi.org/format/#fetching-resources - T fetchCollection(String type, Map> queryParameters); - - /// Finds an returns a primary resource. - /// See https://jsonapi.org/format/#fetching-resources - T fetchResource( - ResourceTarget target, Map> queryParameters); - - /// Finds an returns a related resource or a collection of related resources. - /// See https://jsonapi.org/format/#fetching-resources - T fetchRelated( - RelationshipTarget target, Map> queryParameters); - - /// Finds an returns a relationship of a primary resource. - /// See https://jsonapi.org/format/#fetching-relationships - T fetchRelationship( - RelationshipTarget target, Map> queryParameters); - - /// Deletes the resource. - /// See https://jsonapi.org/format/#crud-deleting - T deleteResource(ResourceTarget target); - - /// Creates a new resource in the collection. - /// See https://jsonapi.org/format/#crud-creating - T createResource(String type, Resource resource); - - /// Updates the resource. - /// See https://jsonapi.org/format/#crud-updating - T updateResource(ResourceTarget target, Resource resource); - - /// Replaces the to-one relationship. - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T replaceToOne(RelationshipTarget target, Identifier identifier); - - /// Deletes the to-one relationship. - /// See https://jsonapi.org/format/#crud-updating-to-one-relationships - T deleteToOne(RelationshipTarget target); - - /// Replaces the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T replaceToMany(RelationshipTarget target, Iterable identifiers); - - /// Removes the given identifiers from the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T deleteFromRelationship( - RelationshipTarget target, Iterable identifiers); - - /// Adds the given identifiers to the to-many relationship. - /// See https://jsonapi.org/format/#crud-updating-to-many-relationships - T addToRelationship( - RelationshipTarget target, Iterable identifiers); +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; + +/// JSON:API controller +abstract class Controller { + /// Fetch a primary resource collection + Future fetchCollection(HttpRequest request, Target target); + + /// Create resource + Future createResource(HttpRequest request, Target target); + + /// Fetch a single primary resource + Future fetchResource( + HttpRequest request, ResourceTarget target); + + /// Updates a primary resource + Future updateResource( + HttpRequest request, ResourceTarget target); + + /// Deletes the primary resource + Future deleteResource( + HttpRequest request, ResourceTarget target); + + /// Fetches a relationship + Future fetchRelationship( + HttpRequest rq, RelationshipTarget target); + + /// Add new entries to a to-many relationship + Future addMany(HttpRequest request, RelationshipTarget target); + + /// Updates the relationship + Future replaceRelationship( + HttpRequest request, RelationshipTarget target); + + /// Deletes the members from the to-many relationship + Future deleteMany( + HttpRequest request, RelationshipTarget target); + + /// Fetches related resource or collection + Future fetchRelated(HttpRequest request, RelatedTarget target); } diff --git a/lib/src/server/dart_server.dart b/lib/src/server/dart_server.dart deleted file mode 100644 index 04850ba1..00000000 --- a/lib/src/server/dart_server.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; -import 'dart:io' as dart; - -import 'package:json_api/http.dart'; - -class DartServer { - DartServer(this._handler); - - final HttpHandler _handler; - - Future call(dart.HttpRequest request) async { - final response = await _handler(await _convertRequest(request)); - response.headers.forEach(request.response.headers.add); - request.response.statusCode = response.statusCode; - request.response.write(response.body); - await request.response.close(); - } - - Future _convertRequest(dart.HttpRequest r) async { - final body = await r.cast>().transform(utf8.decoder).join(); - final headers = {}; - r.headers.forEach((k, v) => headers[k] = v.join(', ')); - return HttpRequest(r.method, r.requestedUri, body: body, headers: headers); - } -} diff --git a/lib/src/server/document_factory.dart b/lib/src/server/document_factory.dart deleted file mode 100644 index 9ad37b81..00000000 --- a/lib/src/server/document_factory.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/nullable.dart'; -import 'package:json_api/src/server/links/links_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// The factory producing JSON:API Documents -class DocumentFactory { - DocumentFactory({LinksFactory links = const NoLinks()}) : _links = links; - - final Api _api = Api(version: '1.0'); - - final LinksFactory _links; - - /// An error document - Document error(Iterable errors) => - Document.error(errors, api: _api); - - /// A resource collection document - Document collection(Iterable collection, - {int total, - Iterable included, - Pagination pagination = const NoPagination()}) => - Document( - ResourceCollectionData(collection.map(_resourceObject).toList(), - links: _links.collection(total, pagination), - included: included?.map(_resourceObject)), - api: _api); - - /// An empty (meta) document - Document empty(Map meta) => Document.empty(meta, api: _api); - - Document resource(Resource resource, - {Iterable included}) => - Document( - ResourceData(_resourceObject(resource), - links: _links.resource(), - included: included?.map(_resourceObject)), - api: _api); - - Document resourceCreated(Resource resource) => Document( - ResourceData(_resourceObject(resource), - links: _links - .createdResource(ResourceTarget(resource.type, resource.id))), - api: _api); - - Document toMany( - RelationshipTarget target, Iterable identifiers, - {Iterable included}) => - Document( - ToMany( - identifiers.map(IdentifierObject.fromIdentifier), - links: _links.relationship(target), - ), - api: _api); - - Document toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}) => - Document( - ToOne( - nullable(IdentifierObject.fromIdentifier)(identifier), - links: _links.relationship(target), - ), - api: _api); - - ResourceObject _resourceObject(Resource r) => ResourceObject(r.type, r.id, - attributes: r.attributes, - relationships: { - ...r.toOne.map((k, v) => MapEntry( - k, - ToOne(nullable(IdentifierObject.fromIdentifier)(v), - links: _links.resourceRelationship( - RelationshipTarget(r.type, r.id, k))))), - ...r.toMany.map((k, v) => MapEntry( - k, - ToMany(v.map(IdentifierObject.fromIdentifier), - links: _links.resourceRelationship( - RelationshipTarget(r.type, r.id, k))))) - }, - links: _links.createdResource(ResourceTarget(r.type, r.id))); -} diff --git a/lib/src/server/errors/method_not_allowed.dart b/lib/src/server/errors/method_not_allowed.dart new file mode 100644 index 00000000..f5f27c98 --- /dev/null +++ b/lib/src/server/errors/method_not_allowed.dart @@ -0,0 +1,5 @@ +class MethodNotAllowed implements Exception { + MethodNotAllowed(this.method); + + final String method; +} diff --git a/lib/src/server/errors/unmatched_target.dart b/lib/src/server/errors/unmatched_target.dart new file mode 100644 index 00000000..c3ce1fa0 --- /dev/null +++ b/lib/src/server/errors/unmatched_target.dart @@ -0,0 +1,5 @@ +class UnmatchedTarget implements Exception { + UnmatchedTarget(this.uri); + + final Uri uri; +} diff --git a/lib/src/server/http_response_converter.dart b/lib/src/server/http_response_converter.dart deleted file mode 100644 index a31c67cb..00000000 --- a/lib/src/server/http_response_converter.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/document_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; -import 'package:json_api/src/server/response_converter.dart'; - -/// An implementation of [ResponseConverter] converting to [HttpResponse]. -class HttpResponseConverter implements ResponseConverter { - HttpResponseConverter(this._doc, this._routing); - - final RouteFactory _routing; - final DocumentFactory _doc; - - @override - HttpResponse error(Iterable errors, int statusCode, - Map headers) => - _ok(_doc.error(errors), status: statusCode, headers: headers); - - @override - HttpResponse collection(Iterable resources, - {int total, - Iterable included, - Pagination pagination = const NoPagination()}) { - return _ok(_doc.collection(resources, - total: total, included: included, pagination: pagination)); - } - - @override - HttpResponse accepted(Resource resource) => - _ok(_doc.resource(resource), status: 202, headers: { - 'Content-Location': - _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse meta(Map meta) => _ok(_doc.empty(meta)); - - @override - HttpResponse resource(Resource resource, {Iterable included}) => - _ok(_doc.resource(resource, included: included)); - - @override - HttpResponse resourceCreated(Resource resource) => - _ok(_doc.resourceCreated(resource), status: 201, headers: { - 'Location': _routing.resource(resource.type, resource.id).toString() - }); - - @override - HttpResponse seeOther(ResourceTarget target) => HttpResponse(303, headers: { - 'Location': _routing.resource(target.type, target.id).toString() - }); - - @override - HttpResponse toMany( - RelationshipTarget target, Iterable identifiers, - {Iterable included}) => - _ok(_doc.toMany(target, identifiers, included: included)); - - @override - HttpResponse toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}) => - _ok(_doc.toOne(target, identifier, included: included)); - - @override - HttpResponse noContent() => HttpResponse(204); - - HttpResponse _ok(Document d, - {int status = 200, Map headers = const {}}) => - HttpResponse(status, - body: jsonEncode(d), - headers: {...headers, 'Content-Type': Document.contentType}); -} diff --git a/lib/src/server/in_memory_repository.dart b/lib/src/server/in_memory_repository.dart deleted file mode 100644 index 0052993c..00000000 --- a/lib/src/server/in_memory_repository.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -typedef IdGenerator = String Function(); -typedef TypeAttributionCriteria = bool Function(String collection, String type); - -/// An in-memory implementation of [Repository] -class InMemoryRepository implements Repository { - InMemoryRepository(this._collections, {IdGenerator nextId}) - : _nextId = nextId; - final Map> _collections; - final IdGenerator _nextId; - - @override - FutureOr create(String collection, Resource resource) async { - if (!_collections.containsKey(collection)) { - throw CollectionNotFound("Collection '$collection' does not exist"); - } - if (collection != resource.type) { - throw _invalidType(resource, collection); - } - for (final relationship in resource.toOne.values - .followedBy(resource.toMany.values.expand((_) => _))) { - // Make sure the relationships exist - await get(ResourceTarget.fromIdentifier(relationship)); - } - if (resource.id == null) { - if (_nextId == null) { - throw UnsupportedOperation('Id generation is not supported'); - } - final id = _nextId(); - final created = Resource(resource.type, id ?? resource.id, - attributes: resource.attributes, - toOne: resource.toOne, - toMany: resource.toMany); - _collections[collection][created.id] = created; - return created; - } - if (_collections[collection].containsKey(resource.id)) { - throw ResourceExists('Resource with this type and id already exists'); - } - _collections[collection][resource.id] = resource; - return null; - } - - @override - FutureOr get(ResourceTarget target) async { - if (_collections.containsKey(target.type)) { - final resource = _collections[target.type][target.id]; - if (resource == null) { - throw ResourceNotFound( - "Resource '${target.id}' does not exist in '${target.type}'"); - } - return resource; - } - throw CollectionNotFound("Collection '${target.type}' does not exist"); - } - - @override - FutureOr update(ResourceTarget target, Resource resource) async { - if (target.type != resource.type) { - throw _invalidType(resource, target.type); - } - final original = await get(target); - if (resource.attributes.isEmpty && - resource.toOne.isEmpty && - resource.toMany.isEmpty && - resource.id == target.id) { - return null; - } - final updated = Resource( - original.type, - original.id, - attributes: {...original.attributes}..addAll(resource.attributes), - toOne: {...original.toOne}..addAll(resource.toOne), - toMany: {...original.toMany}..addAll(resource.toMany), - ); - _collections[target.type][target.id] = updated; - return updated; - } - - @override - FutureOr delete(ResourceTarget target) async { - await get(target); - _collections[target.type].remove(target.id); - return null; - } - - @override - FutureOr> getCollection(String collection, - {int limit, int offset, List sort}) async { - if (_collections.containsKey(collection)) { - return Collection( - _collections[collection].values, _collections[collection].length); - } - throw CollectionNotFound("Collection '$collection' does not exist"); - } - - InvalidType _invalidType(Resource resource, String collection) { - return InvalidType( - "Type '${resource.type}' does not belong in '$collection'"); - } -} diff --git a/lib/src/server/json_api_request.dart b/lib/src/server/json_api_request.dart deleted file mode 100644 index fa3de875..00000000 --- a/lib/src/server/json_api_request.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// The base interface for JSON:API requests. -abstract class JsonApiRequest { - /// Calls the appropriate method of [controller] and returns the response - T handleWith(Controller controller); -} - -/// A request to fetch a collection of type [type]. -/// -/// See: https://jsonapi.org/format/#fetching-resources -class FetchCollection implements JsonApiRequest { - FetchCollection(this.queryParameters, this.type); - - /// Resource type - final String type; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchCollection(type, queryParameters); -} - -/// A request to create a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-creating -class CreateResource implements JsonApiRequest { - CreateResource(this.type, this.resource); - - /// Resource type - final String type; - - /// Resource to create - final Resource resource; - - @override - T handleWith(Controller controller) => - controller.createResource(type, resource); -} - -/// A request to update a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-updating -class UpdateResource implements JsonApiRequest { - UpdateResource(this.target, this.resource); - - final ResourceTarget target; - - /// Resource containing fields to be updated - final Resource resource; - - @override - T handleWith(Controller controller) => - controller.updateResource(target, resource); -} - -/// A request to delete a resource on the server -/// -/// See: https://jsonapi.org/format/#crud-deleting -class DeleteResource implements JsonApiRequest { - DeleteResource(this.target); - - final ResourceTarget target; - - @override - T handleWith(Controller controller) => - controller.deleteResource(target); -} - -/// A request to fetch a resource -/// -/// See: https://jsonapi.org/format/#fetching-resources -class FetchResource implements JsonApiRequest { - FetchResource(this.target, this.queryParameters); - - final ResourceTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchResource(target, queryParameters); -} - -/// A request to fetch a related resource or collection -/// -/// See: https://jsonapi.org/format/#fetching -class FetchRelated implements JsonApiRequest { - FetchRelated(this.target, this.queryParameters); - - final RelationshipTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchRelated(target, queryParameters); -} - -/// A request to fetch a relationship -/// -/// See: https://jsonapi.org/format/#fetching-relationships -class FetchRelationship implements JsonApiRequest { - FetchRelationship(this.target, this.queryParameters); - - final RelationshipTarget target; - - /// URI query parameters - final Map> queryParameters; - - @override - T handleWith(Controller controller) => - controller.fetchRelationship(target, queryParameters); -} - -/// A request to delete identifiers from a relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class DeleteFromRelationship implements JsonApiRequest { - DeleteFromRelationship(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The identifiers to delete - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.deleteFromRelationship(target, identifiers); -} - -/// A request to replace a to-one relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class ReplaceToOne implements JsonApiRequest { - ReplaceToOne(this.target, this.identifier); - - final RelationshipTarget target; - - /// The identifier to be put instead of the existing - final Identifier identifier; - - @override - T handleWith(Controller controller) => - controller.replaceToOne(target, identifier); -} - -/// A request to delete a to-one relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-one-relationships -class DeleteToOne implements JsonApiRequest { - DeleteToOne(this.target); - - final RelationshipTarget target; - - @override - T handleWith(Controller controller) => - controller.replaceToOne(target, null); -} - -/// A request to completely replace a to-many relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class ReplaceToMany implements JsonApiRequest { - ReplaceToMany(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The set of identifiers to replace the current ones - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.replaceToMany(target, identifiers); -} - -/// A request to add identifiers to a to-many relationship -/// -/// See: https://jsonapi.org/format/#crud-updating-to-many-relationships -class AddToRelationship implements JsonApiRequest { - AddToRelationship(this.target, Iterable identifiers) - : identifiers = List.unmodifiable(identifiers); - - final RelationshipTarget target; - - /// The identifiers to be added to the existing ones - final List identifiers; - - @override - T handleWith(Controller controller) => - controller.addToRelationship(target, identifiers); -} diff --git a/lib/src/server/json_api_response.dart b/lib/src/server/json_api_response.dart deleted file mode 100644 index 53f8d347..00000000 --- a/lib/src/server/json_api_response.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; -import 'package:json_api/src/server/response_converter.dart'; - -/// The base interface for JSON:API responses. -abstract class JsonApiResponse { - /// Converts the JSON:API response to another object, e.g. HTTP response. - T convert(ResponseConverter converter); -} - -/// HTTP 204 No Content response. -/// -/// See: -/// - https://jsonapi.org/format/#crud-creating-responses-204 -/// - https://jsonapi.org/format/#crud-updating-responses-204 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 -/// - https://jsonapi.org/format/#crud-deleting-responses-204 -class NoContentResponse implements JsonApiResponse { - @override - T convert(ResponseConverter converter) => converter.noContent(); -} - -/// HTTP 200 OK response with a resource collection. -/// -/// See: https://jsonapi.org/format/#fetching-resources-responses-200 -class CollectionResponse implements JsonApiResponse { - CollectionResponse(Iterable resources, - {Iterable included, this.total}) - : resources = List.unmodifiable(resources), - included = included == null ? null : List.unmodifiable(included); - - final List resources; - final List included; - - final int total; - - @override - T convert(ResponseConverter converter) => - converter.collection(resources, included: included, total: total); -} - -/// HTTP 202 Accepted response. -/// -/// See: https://jsonapi.org/recommendations/#asynchronous-processing -class AcceptedResponse implements JsonApiResponse { - AcceptedResponse(this.resource); - - final Resource resource; - - @override - T convert(ResponseConverter converter) => converter.accepted(resource); -} - -/// A common error response. -/// -/// See: https://jsonapi.org/format/#errors -class ErrorResponse implements JsonApiResponse { - ErrorResponse(this.statusCode, Iterable errors, - {Map headers = const {}}) - : _headers = Map.unmodifiable(headers), - errors = List.unmodifiable(errors); - - /// HTTP 400 Bad Request response. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-includes - /// - https://jsonapi.org/format/#fetching-sorting - /// - https://jsonapi.org/format/#query-parameters - ErrorResponse.badRequest(Iterable errors) : this(400, errors); - - /// HTTP 403 Forbidden response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-client-ids - /// - https://jsonapi.org/format/#crud-creating-responses-403 - /// - https://jsonapi.org/format/#crud-updating-resource-relationships - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-403 - ErrorResponse.forbidden(Iterable errors) : this(403, errors); - - /// HTTP 404 Not Found response. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-resources-responses-404 - /// - https://jsonapi.org/format/#fetching-relationships-responses-404 - /// - https://jsonapi.org/format/#crud-creating-responses-404 - /// - https://jsonapi.org/format/#crud-updating-responses-404 - /// - https://jsonapi.org/format/#crud-deleting-responses-404 - ErrorResponse.notFound(Iterable errors) : this(404, errors); - - /// HTTP 405 Method Not Allowed response. - /// The allowed methods can be specified in [allow] - ErrorResponse.methodNotAllowed( - Iterable errors, Iterable allow) - : this(405, errors, headers: {'Allow': allow.join(', ')}); - - /// HTTP 409 Conflict response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-responses-409 - /// - https://jsonapi.org/format/#crud-updating-responses-409 - ErrorResponse.conflict(Iterable errors) : this(409, errors); - - /// HTTP 500 Internal Server Error response. - ErrorResponse.internalServerError(Iterable errors) - : this(500, errors); - - /// HTTP 501 Not Implemented response. - ErrorResponse.notImplemented(Iterable errors) - : this(501, errors); - - /// Error objects to send with the response - final List errors; - - /// HTTP status code - final int statusCode; - final Map _headers; - - @override - T convert(ResponseConverter converter) => - converter.error(errors, statusCode, _headers); -} - -/// HTTP 200 OK response containing an empty document. -/// -/// See: -/// - https://jsonapi.org/format/#crud-updating-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -/// - https://jsonapi.org/format/#crud-deleting-responses-200 -class MetaResponse implements JsonApiResponse { - MetaResponse(Map meta) : meta = Map.unmodifiable(meta); - - final Map meta; - - @override - T convert(ResponseConverter converter) => converter.meta(meta); -} - -/// A successful response containing a resource object. -/// -/// See: -/// - https://jsonapi.org/format/#fetching-resources-responses-200 -/// - https://jsonapi.org/format/#crud-updating-responses-200 -class ResourceResponse implements JsonApiResponse { - ResourceResponse(this.resource, {Iterable included}) - : included = included == null ? null : List.unmodifiable(included); - - final Resource resource; - - final List included; - - @override - T convert(ResponseConverter converter) => - converter.resource(resource, included: included); -} - -/// HTTP 201 Created response containing a newly created resource -/// -/// See: https://jsonapi.org/format/#crud-creating-responses-201 -class ResourceCreatedResponse implements JsonApiResponse { - ResourceCreatedResponse(this.resource); - - final Resource resource; - - @override - T convert(ResponseConverter converter) => - converter.resourceCreated(resource); -} - -/// HTTP 303 See Other response. -/// -/// See: https://jsonapi.org/recommendations/#asynchronous-processing -class SeeOtherResponse implements JsonApiResponse { - SeeOtherResponse(this.target); - - final ResourceTarget target; - - @override - T convert(ResponseConverter converter) => converter.seeOther(target); -} - -/// HTTP 200 OK response containing a to-may relationship. -/// -/// See: -/// - https://jsonapi.org/format/#fetching-relationships-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToManyResponse implements JsonApiResponse { - ToManyResponse(this.target, Iterable identifiers) - : identifiers = - identifiers == null ? null : List.unmodifiable(identifiers); - - final RelationshipTarget target; - final List identifiers; - - @override - T convert(ResponseConverter converter) => - converter.toMany(target, identifiers); -} - -/// HTTP 200 OK response containing a to-one relationship -/// -/// See: -/// - https://jsonapi.org/format/#fetching-relationships-responses-200 -/// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 -class ToOneResponse implements JsonApiResponse { - ToOneResponse(this.target, this.identifier); - - final RelationshipTarget target; - - final Identifier identifier; - - @override - T convert(ResponseConverter converter) => - converter.toOne(target, identifier); -} diff --git a/lib/src/server/json_api_server.dart b/lib/src/server/json_api_server.dart deleted file mode 100644 index d5ca8477..00000000 --- a/lib/src/server/json_api_server.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/request_converter.dart'; - -/// A simple implementation of JSON:API server -class JsonApiServer implements HttpHandler { - JsonApiServer(this._controller, {RouteFactory routing}) - : _routing = routing ?? StandardRouting(); - - final RouteFactory _routing; - final Controller> _controller; - - @override - Future call(HttpRequest httpRequest) async { - JsonApiRequest jsonApiRequest; - JsonApiResponse jsonApiResponse; - try { - jsonApiRequest = RequestConverter().convert(httpRequest); - } on FormatException catch (e) { - jsonApiResponse = ErrorResponse.badRequest([ - ErrorObject( - status: '400', - title: 'Bad request', - detail: 'Invalid JSON. ${e.message}') - ]); - } on DocumentException catch (e) { - jsonApiResponse = ErrorResponse.badRequest([ - ErrorObject(status: '400', title: 'Bad request', detail: e.message) - ]); - } on MethodNotAllowedException catch (e) { - jsonApiResponse = ErrorResponse.methodNotAllowed([ - ErrorObject( - status: '405', - title: 'Method Not Allowed', - detail: 'Allowed methods: ${e.allow.join(', ')}') - ], e.allow); - } on UnmatchedUriException { - jsonApiResponse = ErrorResponse.notFound([ - ErrorObject( - status: '404', - title: 'Not Found', - detail: 'The requested URL does exist on the server') - ]); - } on IncompleteRelationshipException { - jsonApiResponse = ErrorResponse.badRequest([ - ErrorObject( - status: '400', - title: 'Bad request', - detail: 'Incomplete relationship object') - ]); - } - jsonApiResponse ??= await jsonApiRequest.handleWith(_controller) ?? - ErrorResponse.internalServerError([ - ErrorObject( - status: '500', - title: 'Internal Server Error', - detail: 'Controller responded with null') - ]); - - final links = StandardLinks(httpRequest.uri, _routing); - final documentFactory = DocumentFactory(links: links); - final responseFactory = HttpResponseConverter(documentFactory, _routing); - return jsonApiResponse.convert(responseFactory); - } -} diff --git a/lib/src/server/links/links_factory.dart b/lib/src/server/links/links_factory.dart deleted file mode 100644 index 885d2b00..00000000 --- a/lib/src/server/links/links_factory.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Creates `links` objects for JSON:API documents -abstract class LinksFactory { - /// Links for a resource object (primary or related) - Map resource(); - - /// Links for a collection (primary or related) - Map collection(int total, Pagination pagination); - - /// Links for a newly created resource - Map createdResource(ResourceTarget target); - - /// Links for a standalone relationship - Map relationship(RelationshipTarget target); - - /// Links for a relationship inside a resource - Map resourceRelationship(RelationshipTarget target); -} diff --git a/lib/src/server/links/no_links.dart b/lib/src/server/links/no_links.dart deleted file mode 100644 index 688327bb..00000000 --- a/lib/src/server/links/no_links.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/link.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -class NoLinks implements LinksFactory { - const NoLinks(); - - @override - Map collection(int total, Pagination pagination) => const {}; - - @override - Map createdResource(ResourceTarget target) => const {}; - - @override - Map relationship(RelationshipTarget target) => const {}; - - @override - Map resource() => const {}; - - @override - Map resourceRelationship(RelationshipTarget target) => const {}; -} diff --git a/lib/src/server/links/standard_links.dart b/lib/src/server/links/standard_links.dart deleted file mode 100644 index de48b7f2..00000000 --- a/lib/src/server/links/standard_links.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/links/links_factory.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -class StandardLinks implements LinksFactory { - StandardLinks(this._requested, this._route); - - final Uri _requested; - final RouteFactory _route; - - @override - Map resource() => {'self': Link(_requested)}; - - @override - Map collection(int total, Pagination pagination) => - {'self': Link(_requested), ..._navigation(total, pagination)}; - - @override - Map createdResource(ResourceTarget target) => - {'self': Link(_route.resource(target.type, target.id))}; - - @override - Map relationship(RelationshipTarget target) => { - 'self': Link(_requested), - 'related': - Link(_route.related(target.type, target.id, target.relationship)) - }; - - @override - Map resourceRelationship(RelationshipTarget target) => { - 'self': Link( - _route.relationship(target.type, target.id, target.relationship)), - 'related': - Link(_route.related(target.type, target.id, target.relationship)) - }; - - Map _navigation(int total, Pagination pagination) { - final page = Page.fromQueryParameters(_requested.queryParametersAll); - - return ({ - 'first': pagination.first(), - 'last': pagination.last(total), - 'prev': pagination.prev(page), - 'next': pagination.next(page, total) - }..removeWhere((k, v) => v == null)) - .map((k, v) => MapEntry(k, Link(v.addToUri(_requested)))); - } -} diff --git a/lib/src/server/pagination.dart b/lib/src/server/pagination.dart deleted file mode 100644 index 9877a16d..00000000 --- a/lib/src/server/pagination.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:json_api/src/query/page.dart'; - -/// Pagination strategy determines how pagination information is encoded in the -/// URL query parameters -abstract class Pagination { - /// Number of elements per page. Null for unlimited. - int limit(Page page); - - /// The page offset. - int offset(Page page); - - /// Link to the first page. Null if not supported. - Page first(); - - /// Reference to the last page. Null if not supported. - Page last(int total); - - /// Reference to the next page. Null if not supported or if current page is the last. - Page next(Page page, [int total]); - - /// Reference to the first page. Null if not supported or if current page is the first. - Page prev(Page page); -} - -/// No pagination. The server will not be able to produce pagination links. -class NoPagination implements Pagination { - const NoPagination(); - - @override - Page first() => null; - - @override - Page last(int total) => null; - - @override - int limit(Page page) => -1; - - @override - Page next(Page page, [int total]) => null; - - @override - int offset(Page page) => 0; - - @override - Page prev(Page page) => null; -} - -/// Pages of fixed [size]. -class FixedSizePage implements Pagination { - FixedSizePage(this.size) { - if (size < 1) throw ArgumentError(); - } - - final int size; - - @override - Page first() => _page(1); - - @override - Page last(int total) => _page((total - 1) ~/ size + 1); - - @override - Page next(Page page, [int total]) { - final number = _number(page); - if (total == null || number * size < total) { - return _page(number + 1); - } - return null; - } - - @override - Page prev(Page page) { - final number = _number(page); - if (number > 1) return _page(number - 1); - return null; - } - - @override - int limit(Page page) => size; - - @override - int offset(Page page) => size * (_number(page) - 1); - - int _number(Page page) => int.parse(page['number'] ?? '1'); - - Page _page(int number) => Page({'number': number.toString()}); -} diff --git a/lib/src/server/relationship_target.dart b/lib/src/server/relationship_target.dart deleted file mode 100644 index c5dd0e2d..00000000 --- a/lib/src/server/relationship_target.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:json_api/src/server/resource_target.dart'; - -class RelationshipTarget { - const RelationshipTarget(this.type, this.id, this.relationship); - - final String type; - final String id; - final String relationship; - - ResourceTarget get resource => ResourceTarget(type, id); -} diff --git a/lib/src/server/repository.dart b/lib/src/server/repository.dart deleted file mode 100644 index d96a77fe..00000000 --- a/lib/src/server/repository.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// The Repository translates CRUD operations on resources to actual data -/// manipulation. -abstract class Repository { - /// Creates the [resource] in the [collection]. - /// If the resource was modified during creation, - /// this method must return the modified resource (e.g. with the generated id). - /// Otherwise must return null. - /// - /// Throws [CollectionNotFound] if there is no such [collection]. - - /// Throws [ResourceNotFound] if one or more related resources are not found. - /// - /// Throws [UnsupportedOperation] if the operation - /// is not supported (e.g. the client sent a resource without the id, but - /// the id generation is not supported by this repository). This exception - /// will be converted to HTTP 403 error. - /// - /// Throws [InvalidType] if the [resource] - /// does not belong to the collection. - FutureOr create(String collection, Resource resource); - - /// Returns the resource by [target]. - FutureOr get(ResourceTarget target); - - /// Updates the resource identified by [target]. - /// If the resource was modified during update, returns the modified resource. - /// Otherwise returns null. - FutureOr update(ResourceTarget target, Resource resource); - - /// Deletes the resource identified by [target] - FutureOr delete(ResourceTarget target); - - /// Returns a collection of resources - FutureOr> getCollection(String collection, - {int limit, int offset, List sort}); -} - -/// A collection of elements (e.g. resources) returned by the server. -class Collection { - Collection(Iterable elements, [this.total]) - : elements = List.unmodifiable(elements); - - final List elements; - - /// Total count of the elements on the server. May be null. - final int total; -} - -/// Thrown when the requested collection does not exist -/// This exception should result in HTTP 404. -class CollectionNotFound implements Exception { - CollectionNotFound(this.message); - - final String message; -} - -/// Thrown when the requested resource does not exist. -/// This exception should result in HTTP 404. -class ResourceNotFound implements Exception { - ResourceNotFound(this.message); - - final String message; -} - -/// Thrown if the operation -/// is not supported (e.g. the client sent a resource without the id, but -/// the id generation is not supported by this repository). -/// This exception should result in HTTP 403. -class UnsupportedOperation implements Exception { - UnsupportedOperation(this.message); - - final String message; -} - -/// Thrown if the resource type does not belong to the collection. -/// This exception should result in HTTP 409. -class InvalidType implements Exception { - InvalidType(this.message); - - final String message; -} - -/// Thrown if the client asks to create a resource which already exists. -/// This exception should result in HTTP 409. -class ResourceExists implements Exception { - ResourceExists(this.message); - - final String message; -} diff --git a/lib/src/server/repository_controller.dart b/lib/src/server/repository_controller.dart deleted file mode 100644 index f8f3e45c..00000000 --- a/lib/src/server/repository_controller.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'dart:async'; - -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/controller.dart'; -import 'package:json_api/src/server/json_api_response.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/repository.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// An opinionated implementation of [Controller]. Translates JSON:API -/// requests to [Repository] methods calls. -class RepositoryController implements Controller> { - RepositoryController(this._repo, {Pagination pagination}) - : _pagination = pagination ?? NoPagination(); - - final Repository _repo; - final Pagination _pagination; - - @override - FutureOr addToRelationship( - RelationshipTarget target, Iterable identifiers) => - _do(() async { - final original = await _repo.get(target.resource); - if (!original.toMany.containsKey(target.relationship)) { - return ErrorResponse.notFound([ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "There is no to-many relationship '${target.relationship}' in this resource") - ]); - } - final updated = await _repo.update( - target.resource, - Resource(target.type, target.id, toMany: { - target.relationship: { - ...original.toMany[target.relationship], - ...identifiers - }.toList() - })); - return ToManyResponse(target, updated.toMany[target.relationship]); - }); - - @override - FutureOr createResource(String type, Resource resource) => - _do(() async { - final modified = await _repo.create(type, resource); - if (modified == null) return NoContentResponse(); - return ResourceCreatedResponse(modified); - }); - - @override - FutureOr deleteFromRelationship( - RelationshipTarget target, Iterable identifiers) => - _do(() async { - final original = await _repo.get(target.resource); - final updated = await _repo.update( - target.resource, - Resource(target.type, target.id, toMany: { - target.relationship: ({...original.toMany[target.relationship]} - ..removeAll(identifiers)) - .toList() - })); - return ToManyResponse(target, updated.toMany[target.relationship]); - }); - - @override - FutureOr deleteResource(ResourceTarget target) => - _do(() async { - await _repo.delete(target); - return NoContentResponse(); - }); - - @override - FutureOr fetchCollection( - String type, Map> queryParameters) => - _do(() async { - final sort = Sort.fromQueryParameters(queryParameters); - final include = Include.fromQueryParameters(queryParameters); - final page = Page.fromQueryParameters(queryParameters); - final limit = _pagination.limit(page); - final offset = _pagination.offset(page); - - final c = await _repo.getCollection(type, - sort: sort.toList(), limit: limit, offset: offset); - - final resources = []; - for (final resource in c.elements) { - for (final path in include) { - resources.addAll(await _getRelated(resource, path.split('.'))); - } - } - - return CollectionResponse(c.elements, - total: c.total, included: include.isEmpty ? null : resources); - }); - - @override - FutureOr fetchRelated(RelationshipTarget target, - Map> queryParameters) => - _do(() async { - final resource = await _repo.get(target.resource); - if (resource.toOne.containsKey(target.relationship)) { - return ResourceResponse(await _repo.get(ResourceTarget.fromIdentifier( - resource.toOne[target.relationship]))); - } - if (resource.toMany.containsKey(target.relationship)) { - final related = []; - for (final identifier in resource.toMany[target.relationship]) { - related.add( - await _repo.get(ResourceTarget.fromIdentifier(identifier))); - } - return CollectionResponse(related); - } - return _relationshipNotFound(target.relationship); - }); - - @override - FutureOr fetchRelationship(RelationshipTarget target, - Map> queryParameters) => - _do(() async { - final resource = await _repo.get(target.resource); - if (resource.toOne.containsKey(target.relationship)) { - return ToOneResponse(target, resource.toOne[target.relationship]); - } - if (resource.toMany.containsKey(target.relationship)) { - return ToManyResponse(target, resource.toMany[target.relationship]); - } - return _relationshipNotFound(target.relationship); - }); - - @override - FutureOr fetchResource( - ResourceTarget target, Map> queryParameters) => - _do(() async { - final include = Include.fromQueryParameters(queryParameters); - final resource = await _repo.get(target); - final resources = []; - for (final path in include) { - resources.addAll(await _getRelated(resource, path.split('.'))); - } - return ResourceResponse(resource, - included: include.isEmpty ? null : resources); - }); - - @override - FutureOr replaceToMany( - RelationshipTarget target, Iterable identifiers) => - _do(() async { - await _repo.update( - target.resource, - Resource(target.type, target.id, - toMany: {target.relationship: identifiers})); - return NoContentResponse(); - }); - - @override - FutureOr updateResource( - ResourceTarget target, Resource resource) => - _do(() async { - final modified = await _repo.update(target, resource); - if (modified == null) return NoContentResponse(); - return ResourceResponse(modified); - }); - - @override - FutureOr replaceToOne( - RelationshipTarget target, Identifier identifier) => - _do(() async { - await _repo.update( - target.resource, - Resource(target.type, target.id, - toOne: {target.relationship: identifier})); - return NoContentResponse(); - }); - - @override - FutureOr deleteToOne(RelationshipTarget target) => - replaceToOne(target, null); - - Future> _getRelated( - Resource resource, - Iterable path, - ) async { - if (path.isEmpty) return []; - final resources = []; - final ids = []; - - if (resource.toOne.containsKey(path.first)) { - ids.add(resource.toOne[path.first]); - } else if (resource.toMany.containsKey(path.first)) { - ids.addAll(resource.toMany[path.first]); - } - for (final id in ids) { - final r = await _repo.get(ResourceTarget.fromIdentifier(id)); - if (path.length > 1) { - resources.addAll(await _getRelated(r, path.skip(1))); - } else { - resources.add(r); - } - } - return _unique(resources); - } - - Iterable _unique(Iterable included) => - Map.fromIterable(included, - key: (_) => '${_.type}:${_.id}').values; - - FutureOr _do( - FutureOr Function() action) async { - try { - return await action(); - } on UnsupportedOperation catch (e) { - return ErrorResponse.forbidden([ - ErrorObject( - status: '403', title: 'Unsupported operation', detail: e.message) - ]); - } on CollectionNotFound catch (e) { - return ErrorResponse.notFound([ - ErrorObject( - status: '404', title: 'Collection not found', detail: e.message) - ]); - } on ResourceNotFound catch (e) { - return ErrorResponse.notFound([ - ErrorObject( - status: '404', title: 'Resource not found', detail: e.message) - ]); - } on InvalidType catch (e) { - return ErrorResponse.conflict([ - ErrorObject( - status: '409', title: 'Invalid resource type', detail: e.message) - ]); - } on ResourceExists catch (e) { - return ErrorResponse.conflict([ - ErrorObject(status: '409', title: 'Resource exists', detail: e.message) - ]); - } - } - - JsonApiResponse _relationshipNotFound(String relationship) { - return ErrorResponse.notFound([ - ErrorObject( - status: '404', - title: 'Relationship not found', - detail: - "Relationship '$relationship' does not exist in this resource") - ]); - } -} diff --git a/lib/src/server/request_converter.dart b/lib/src/server/request_converter.dart deleted file mode 100644 index 32d853ed..00000000 --- a/lib/src/server/request_converter.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/src/server/json_api_request.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Converts HTTP requests to JSON:API requests -class RequestConverter { - RequestConverter({RouteMatcher routeMatcher}) - : _matcher = routeMatcher ?? StandardRouting(); - final RouteMatcher _matcher; - - /// Creates a [JsonApiRequest] from [httpRequest] - JsonApiRequest convert(HttpRequest httpRequest) { - String type; - String id; - String rel; - - void setType(String t) { - type = t; - } - - void setTypeId(String t, String i) { - type = t; - id = i; - } - - void setTypeIdRel(String t, String i, String r) { - type = t; - id = i; - rel = r; - } - - final uri = httpRequest.uri; - if (_matcher.matchCollection(uri, setType)) { - switch (httpRequest.method) { - case 'GET': - return FetchCollection(uri.queryParametersAll, type); - case 'POST': - return CreateResource(type, - ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['GET', 'POST']); - } - } else if (_matcher.matchResource(uri, setTypeId)) { - final target = ResourceTarget(type, id); - switch (httpRequest.method) { - case 'DELETE': - return DeleteResource(target); - case 'GET': - return FetchResource(target, uri.queryParametersAll); - case 'PATCH': - return UpdateResource(target, - ResourceData.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH']); - } - } else if (_matcher.matchRelated(uri, setTypeIdRel)) { - switch (httpRequest.method) { - case 'GET': - return FetchRelated( - RelationshipTarget(type, id, rel), uri.queryParametersAll); - default: - throw MethodNotAllowedException(['GET']); - } - } else if (_matcher.matchRelationship(uri, setTypeIdRel)) { - final target = RelationshipTarget(type, id, rel); - switch (httpRequest.method) { - case 'DELETE': - return DeleteFromRelationship( - target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); - case 'GET': - return FetchRelationship(target, uri.queryParametersAll); - case 'PATCH': - final r = Relationship.fromJson(jsonDecode(httpRequest.body)); - if (r is ToOne) { - final identifier = r.unwrap(); - if (identifier != null) { - return ReplaceToOne(target, identifier); - } - return DeleteToOne(target); - } - if (r is ToMany) { - return ReplaceToMany(target, r.unwrap()); - } - throw IncompleteRelationshipException(); - case 'POST': - return AddToRelationship( - target, ToMany.fromJson(jsonDecode(httpRequest.body)).unwrap()); - default: - throw MethodNotAllowedException(['DELETE', 'GET', 'PATCH', 'POST']); - } - } - throw UnmatchedUriException(); - } -} - -class RequestFactoryException implements Exception {} - -/// Thrown if HTTP method is not allowed for the given route -class MethodNotAllowedException implements RequestFactoryException { - MethodNotAllowedException(Iterable allow) - : allow = List.unmodifiable(allow ?? const []); - - /// List of allowed methods - final List allow; -} - -/// Thrown if the request URI can not be matched to a target -class UnmatchedUriException implements RequestFactoryException {} - -/// Thrown if the relationship object has no data -class IncompleteRelationshipException implements RequestFactoryException {} diff --git a/lib/src/server/resource_target.dart b/lib/src/server/resource_target.dart deleted file mode 100644 index e4aa956d..00000000 --- a/lib/src/server/resource_target.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/document.dart'; - -class ResourceTarget { - const ResourceTarget(this.type, this.id); - - static ResourceTarget fromIdentifier(Identifier identifier) => - ResourceTarget(identifier.type, identifier.id); - - final String type; - - final String id; -} diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart new file mode 100644 index 00000000..ba17cebb --- /dev/null +++ b/lib/src/server/response.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/http.dart'; +import 'package:json_api/src/nullable.dart'; + +/// JSON:API response +class Response extends HttpResponse { + Response(int statusCode, {this.document}) : super(statusCode) { + if (document != null) { + headers['Content-Type'] = MediaType.jsonApi; + } + } + + final D? document; + + @override + String get body => nullable(jsonEncode)(document) ?? ''; + + static Response ok(OutboundDocument document) => + Response(StatusCode.ok, document: document); + + static Response noContent() => Response(StatusCode.noContent); + + static Response created(OutboundDocument document, String location) => + Response(StatusCode.created, document: document) + ..headers['location'] = location; + + static Response notFound([OutboundErrorDocument? document]) => + Response(StatusCode.notFound, document: document); + + static Response methodNotAllowed([OutboundErrorDocument? document]) => + Response(StatusCode.methodNotAllowed, document: document); + + static Response badRequest([OutboundErrorDocument? document]) => + Response(StatusCode.badRequest, document: document); +} diff --git a/lib/src/server/response_converter.dart b/lib/src/server/response_converter.dart deleted file mode 100644 index e0781fce..00000000 --- a/lib/src/server/response_converter.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:json_api/src/server/relationship_target.dart'; -import 'package:json_api/src/server/resource_target.dart'; - -/// Converts JsonApi Controller responses to other responses, e.g. HTTP -abstract class ResponseConverter { - /// A common error response. - /// - /// See: https://jsonapi.org/format/#errors - T error(Iterable errors, int statusCode, - Map headers); - - /// HTTP 200 OK response with a resource collection. - /// - /// See: https://jsonapi.org/format/#fetching-resources-responses-200 - T collection(Iterable resources, - {int total, Iterable included, Pagination pagination}); - - /// HTTP 202 Accepted response. - /// - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - T accepted(Resource resource); - - /// HTTP 200 OK response containing an empty document. - /// - /// See: - /// - https://jsonapi.org/format/#crud-updating-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - /// - https://jsonapi.org/format/#crud-deleting-responses-200 - T meta(Map meta); - - /// A successful response containing a resource object. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-resources-responses-200 - /// - https://jsonapi.org/format/#crud-updating-responses-200 - T resource(Resource resource, {Iterable included}); - - /// HTTP 200 with a document containing a single (primary) resource which has been created - /// on the server. The difference with [resource] is that this - /// method generates the `self` link to match the `location` header. - /// - /// This is the quote from the documentation: - /// > If the resource object returned by the response contains a self key - /// > in its links member and a Location header is provided, the value of - /// > the self member MUST match the value of the Location header. - /// - /// See https://jsonapi.org/format/#crud-creating-responses-201 - T resourceCreated(Resource resource); - - /// HTTP 303 See Other response. - /// - /// See: https://jsonapi.org/recommendations/#asynchronous-processing - T seeOther(ResourceTarget target); - - /// HTTP 200 OK response containing a to-may relationship. - /// - /// See: - /// - https://jsonapi.org/format/#fetching-relationships-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toMany(RelationshipTarget target, Iterable identifiers, - {Iterable included}); - - /// HTTP 200 OK response containing a to-one relationship - /// - /// See: - /// - https://jsonapi.org/format/#fetching-relationships-responses-200 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-200 - T toOne(RelationshipTarget target, Identifier identifier, - {Iterable included}); - - /// HTTP 204 No Content response. - /// - /// See: - /// - https://jsonapi.org/format/#crud-creating-responses-204 - /// - https://jsonapi.org/format/#crud-updating-responses-204 - /// - https://jsonapi.org/format/#crud-updating-relationship-responses-204 - /// - https://jsonapi.org/format/#crud-deleting-responses-204 - T noContent(); -} diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart new file mode 100644 index 00000000..aeb29b7a --- /dev/null +++ b/lib/src/server/router.dart @@ -0,0 +1,60 @@ +import 'package:json_api/http.dart'; +import 'package:json_api/routing.dart'; +import 'package:json_api/src/server/controller.dart'; +import 'package:json_api/src/server/errors/method_not_allowed.dart'; +import 'package:json_api/src/server/errors/unmatched_target.dart'; + +class Router implements HttpHandler { + Router(this._controller, this._matchTarget); + + final Controller _controller; + final Target? Function(Uri uri) _matchTarget; + + @override + Future handle(HttpRequest request) async { + final target = _matchTarget(request.uri); + if (target is RelationshipTarget) { + if (request.isGet) { + return await _controller.fetchRelationship(request, target); + } + if (request.isPost) { + return await _controller.addMany(request, target); + } + if (request.isPatch) { + return await _controller.replaceRelationship(request, target); + } + if (request.isDelete) { + return await _controller.deleteMany(request, target); + } + throw MethodNotAllowed(request.method); + } + if (target is RelatedTarget) { + if (request.isGet) { + return await _controller.fetchRelated(request, target); + } + throw MethodNotAllowed(request.method); + } + if (target is ResourceTarget) { + if (request.isGet) { + return await _controller.fetchResource(request, target); + } + if (request.isPatch) { + return await _controller.updateResource(request, target); + } + if (request.isDelete) { + return await _controller.deleteResource(request, target); + } + throw MethodNotAllowed(request.method); + } + if (target is Target) { + if (request.isGet) { + return await _controller.fetchCollection(request, target); + } + if (request.isPost) { + return await _controller.createResource(request, target); + } + throw MethodNotAllowed(request.method); + } + throw UnmatchedTarget(request.uri); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 11b32150..d9f675de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,21 +1,17 @@ name: json_api -version: 4.3.0 -description: "Framework-agnostic implementations of JSON:API Client (Flutter, Web and VM) and Server (VM). Supports JSON:API v1.0 (http://jsonapi.org)" -homepage: "https://github.com/f3ath/json-api-dart" +version: 5.0.0-rc.0 +homepage: https://github.com/f3ath/json-api-dart +description: A framework-agnostic implementations of JSON:API Client and Server. Supports JSON:API v1.0 (https://jsonapi.org) +environment: + sdk: '>=2.12.0 <3.0.0' dependencies: - http: ^0.12.0 - http_parser: ^3.1.4 + http: ^0.13.0 dev_dependencies: - args: ^1.5.2 - json_matcher: ^0.2.3 - pedantic: ^1.9.0 - shelf: ^0.7.5 - stream_channel: ^2.0.0 - test: ^1.9.2 - test_coverage: ^0.4.0 - uuid: ^2.0.1 - -environment: - sdk: ">=2.7.0 <3.0.0" + pedantic: ^1.10.0 + test: ^1.16.0 + stream_channel: ^2.1.0 + uuid: ^3.0.0 + coverage: ^1.0.2 + check_coverage: ^0.0.2 diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart new file mode 100644 index 00000000..a2a2177c --- /dev/null +++ b/test/contract/crud_test.dart @@ -0,0 +1,174 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import '../../example/server/demo_handler.dart'; + +void main() { + late RoutingClient client; + + setUp(() async { + client = RoutingClient(StandardUriDesign.pathOnly, + client: Client(handler: DemoHandler())); + }); + + group('CRUD', () { + late Resource alice; + late Resource bob; + late Resource post; + late Resource comment; + late Resource secretComment; + + setUp(() async { + alice = (await client.createNew('users', attributes: {'name': 'Alice'})) + .resource; + bob = (await client.createNew('users', attributes: {'name': 'Bob'})) + .resource; + post = (await client.createNew('posts', + attributes: {'title': 'Hello world'}, + one: {'author': Identifier.of(alice)}, + many: {'comments': []})) + .resource; + comment = (await client.createNew('comments', + attributes: {'text': 'Hi Alice'}, + one: {'author': Identifier.of(bob)})) + .resource; + secretComment = (await client.createNew('comments', + attributes: {'text': 'Secret comment'}, + one: {'author': Identifier.of(bob)})) + .resource; + await client + .addMany(post.type, post.id, 'comments', [Identifier.of(comment)]); + }); + + test('Fetch a complex resource', () async { + final response = await client.fetchCollection('posts', + include: ['author', 'comments', 'comments.author']); + + expect(response.http.statusCode, 200); + expect(response.collection.length, 1); + expect(response.included.length, 3); + + final fetchedPost = response.collection.first; + expect(fetchedPost.attributes['title'], 'Hello world'); + + final fetchedAuthor = + fetchedPost.one('author')!.findIn(response.included); + expect(fetchedAuthor?.attributes['name'], 'Alice'); + + final fetchedComment = + fetchedPost.many('comments')!.findIn(response.included).single; + expect(fetchedComment.attributes['text'], 'Hi Alice'); + }); + + test('Delete a resource', () async { + await client.deleteResource(post.type, post.id); + await client.fetchCollection('posts').then((r) { + expect(r.collection, isEmpty); + }); + }); + + test('Update a resource', () async { + await client.updateResource(post.type, post.id, + attributes: {'title': 'Bob was here'}); + await client.fetchCollection('posts').then((r) { + expect(r.collection.single.attributes['title'], 'Bob was here'); + }); + }); + + test('Fetch a related resource', () async { + await client.fetchRelatedResource(post.type, post.id, 'author').then((r) { + expect(r.resource?.attributes['name'], 'Alice'); + }); + }); + + test('Fetch a related collection', () async { + await client + .fetchRelatedCollection(post.type, post.id, 'comments') + .then((r) { + expect(r.collection.single.attributes['text'], 'Hi Alice'); + }); + }); + + test('Fetch a to-one relationship', () async { + await client.fetchToOne(post.type, post.id, 'author').then((r) { + expect(Identity.same(r.relationship.identifier!, alice), isTrue); + }); + }); + + test('Fetch a to-many relationship', () async { + await client.fetchToMany(post.type, post.id, 'comments').then((r) { + expect(Identity.same(r.relationship.single, comment), isTrue); + }); + }); + + test('Delete a to-one relationship', () async { + await client.deleteToOne(post.type, post.id, 'author'); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { + expect(r.resource.one('author'), isEmpty); + }); + }); + + test('Replace a to-one relationship', () async { + await client.replaceToOne( + post.type, post.id, 'author', Identifier.of(bob)); + await client + .fetchResource(post.type, post.id, include: ['author']).then((r) { + expect(r.resource.one('author')?.findIn(r.included)?.attributes['name'], + 'Bob'); + }); + }); + + test('Delete from a to-many relationship', () async { + await client.deleteFromMany( + post.type, post.id, 'comments', [Identifier.of(comment)]); + await client.fetchResource(post.type, post.id).then((r) { + expect(r.resource.many('comments'), isEmpty); + }); + }); + + test('Replace a to-many relationship', () async { + await client.replaceToMany( + post.type, post.id, 'comments', [Identifier.of(secretComment)]); + await client + .fetchResource(post.type, post.id, include: ['comments']).then((r) { + expect( + r.resource + .many('comments')! + .findIn(r.included) + .single + .attributes['text'], + 'Secret comment'); + expect( + r.resource + .many('comments')! + .findIn(r.included) + .single + .attributes['text'], + 'Secret comment'); + }); + }); + + test('Incomplete relationship', () async {}); + + test('404', () async { + final actions = [ + () => client.fetchCollection('unicorns'), + () => client.fetchResource('posts', 'zzz'), + () => client.fetchRelatedResource(post.type, post.id, 'zzz'), + () => client.fetchToOne(post.type, post.id, 'zzz'), + () => client.fetchToMany(post.type, post.id, 'zzz'), + ]; + for (final action in actions) { + try { + await action(); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 404); + } + } + }); + }); +} diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart new file mode 100644 index 00000000..559e93ae --- /dev/null +++ b/test/contract/errors_test.dart @@ -0,0 +1,38 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +import '../../example/server/demo_handler.dart'; + +void main() { + late Client client; + + setUp(() async { + client = Client(handler: DemoHandler()); + }); + + group('Errors', () { + test('Method not allowed', () async { + final actions = [ + () => client.send(Uri.parse('/posts'), Request('delete')), + () => client.send(Uri.parse('/posts/1'), Request('post')), + () => client.send(Uri.parse('/posts/1/author'), Request('post')), + () => client.send( + Uri.parse('/posts/1/relationships/author'), Request('head')), + ]; + for (final action in actions) { + try { + await action(); + fail('Exception expected'); + } on RequestFailure catch (response) { + expect(response.http.statusCode, 405); + } + } + }); + test('Bad request when target can not be matched', () async { + final r = await DemoHandler() + .handle(HttpRequest('get', Uri.parse('/a/long/prefix/'))); + expect(r.statusCode, 400); + }); + }); +} diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart new file mode 100644 index 00000000..e1b09902 --- /dev/null +++ b/test/contract/resource_creation_test.dart @@ -0,0 +1,36 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import '../../example/server/demo_handler.dart'; + +void main() { + late RoutingClient client; + + setUp(() async { + client = RoutingClient(StandardUriDesign.pathOnly, + client: Client(handler: DemoHandler())); + }); + + group('Resource creation', () { + test('Resource id assigned on the server', () async { + await client + .createNew('posts', attributes: {'title': 'Hello world'}).then((r) { + expect(r.http.statusCode, 201); + expect(r.http.headers['location'], '/posts/${r.resource.id}'); + expect(r.links['self'].toString(), '/posts/${r.resource.id}'); + expect(r.resource.type, 'posts'); + expect(r.resource.attributes['title'], 'Hello world'); + expect(r.resource.links['self'].toString(), '/posts/${r.resource.id}'); + }); + }); + test('Resource id assigned on the client', () async { + await client.create('posts', '12345', + attributes: {'title': 'Hello world'}).then((r) { + expect(r.http.statusCode, 204); + expect(r.resource, isNull); + expect(r.http.headers['location'], isNull); + }); + }); + }); +} diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart new file mode 100644 index 00000000..a33c45e5 --- /dev/null +++ b/test/e2e/browser_test.dart @@ -0,0 +1,20 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import 'e2e_test_set.dart'; + +void main() { + late RoutingClient client; + + setUp(() async { + final channel = spawnHybridUri('hybrid_server.dart'); + final serverUrl = await channel.stream.first; + + client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString()))); + }); + + test('On Browser', () async { + await e2eTests(client); + }, testOn: 'browser'); +} diff --git a/test/e2e/client_server_interaction_test.dart b/test/e2e/client_server_interaction_test.dart deleted file mode 100644 index b4a56a1b..00000000 --- a/test/e2e/client_server_interaction_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/client/dart_http.dart'; -import 'package:pedantic/pedantic.dart'; -import 'package:test/test.dart'; - -void main() { - group('Client-Server interaction over HTTP', () { - final port = 8088; - final host = 'localhost'; - final routing = - StandardRouting(Uri(host: host, port: port, scheme: 'http')); - final repo = InMemoryRepository({'writers': {}, 'books': {}}); - final jsonApiServer = JsonApiServer(RepositoryController(repo)); - final serverHandler = DartServer(jsonApiServer); - Client httpClient; - RoutingClient client; - HttpServer server; - - setUp(() async { - server = await HttpServer.bind(host, port); - httpClient = Client(); - client = RoutingClient(JsonApiClient(DartHttp(httpClient)), routing); - unawaited(server.forEach(serverHandler)); - }); - - tearDown(() async { - httpClient.close(); - await server.close(); - }); - - test('Happy Path', () async { - final writer = - Resource('writers', '1', attributes: {'name': 'Martin Fowler'}); - final book = Resource('books', '2', attributes: {'title': 'Refactoring'}); - - await client.createResource(writer); - await client.createResource(book); - await client - .updateResource(Resource('books', '2', toMany: {'authors': []})); - await client.addToRelationship( - 'books', '2', 'authors', [Identifier('writers', '1')]); - - final response = await client.fetchResource('books', '2', - parameters: Include(['authors'])); - - expect(response.data.unwrap().attributes['title'], 'Refactoring'); - expect(response.data.included.first.unwrap().attributes['name'], - 'Martin Fowler'); - }); - }, testOn: 'vm'); -} diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart new file mode 100644 index 00000000..04f63bef --- /dev/null +++ b/test/e2e/e2e_test_set.dart @@ -0,0 +1,34 @@ +import 'package:json_api/client.dart'; +import 'package:test/test.dart'; + +Future e2eTests(RoutingClient client) async { + await _testAllHttpMethods(client); + await _testLocationIsSet(client); +} + +Future _testAllHttpMethods(RoutingClient client) async { + final id = '12345'; + // POST + await client.create('posts', id, attributes: {'title': 'Hello world'}); + // GET + await client.fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Hello world'); + }); + // PATCH + await client.updateResource('posts', id, attributes: {'title': 'Bye world'}); + await client.fetchResource('posts', id).then((r) { + expect(r.resource.attributes['title'], 'Bye world'); + }); + // DELETE + await client.deleteResource('posts', id); + await client.fetchCollection('posts').then((r) { + expect(r.collection, isEmpty); + }); +} + +Future _testLocationIsSet(RoutingClient client) async { + await client + .createNew('posts', attributes: {'title': 'Location test'}).then((r) { + expect(r.http.headers['Location'], isNotEmpty); + }); +} diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart new file mode 100644 index 00000000..578430f8 --- /dev/null +++ b/test/e2e/hybrid_server.dart @@ -0,0 +1,12 @@ +import 'package:stream_channel/stream_channel.dart'; + +import '../../example/server/demo_handler.dart'; +import '../../example/server/json_api_server.dart'; + +void hybridMain(StreamChannel channel, Object message) async { + final host = 'localhost'; + final port = 8000; + final server = JsonApiServer(DemoHandler(), host: host, port: port); + await server.start(); + channel.sink.add('http://$host:$port'); +} diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart new file mode 100644 index 00000000..1c3d98f7 --- /dev/null +++ b/test/e2e/vm_test.dart @@ -0,0 +1,27 @@ +import 'package:json_api/client.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import '../../example/server/demo_handler.dart'; +import '../../example/server/json_api_server.dart'; +import 'e2e_test_set.dart'; + +void main() { + late RoutingClient client; + late JsonApiServer server; + + setUp(() async { + server = JsonApiServer(DemoHandler(), port: 8001); + await server.start(); + client = RoutingClient(StandardUriDesign( + Uri(scheme: 'http', host: server.host, port: server.port))); + }); + + tearDown(() async { + await server.stop(); + }); + + test('On VM', () async { + await e2eTests(client); + }, testOn: 'vm'); +} diff --git a/test/functional/compound_document_test.dart b/test/functional/compound_document_test.dart deleted file mode 100644 index 70a2ed22..00000000 --- a/test/functional/compound_document_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/query.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import '../helper/expect_resources_equal.dart'; - -void main() async { - RoutingClient client; - JsonApiServer server; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - final wonderland = - Resource('countries', '1', attributes: {'name': 'Wonderland'}); - final alice = Resource('people', '1', - attributes: {'name': 'Alice'}, - toOne: {'birthplace': Identifier.of(wonderland)}); - final bob = Resource('people', '2', - attributes: {'name': 'Bob'}, - toOne: {'birthplace': Identifier.of(wonderland)}); - final comment1 = Resource('comments', '1', - attributes: {'text': 'First comment!'}, - toOne: {'author': Identifier.of(bob)}); - final comment2 = Resource('comments', '2', - attributes: {'text': 'Oh hi Bob'}, - toOne: {'author': Identifier.of(alice)}); - final post = Resource('posts', '1', attributes: { - 'title': 'Hello World' - }, toOne: { - 'author': Identifier.of(alice) - }, toMany: { - 'comments': [Identifier.of(comment1), Identifier.of(comment2)], - 'tags': [] - }); - - setUp(() async { - final repository = InMemoryRepository({ - 'posts': {'1': post}, - 'comments': {'1': comment1, '2': comment2}, - 'people': {'1': alice, '2': bob}, - 'countries': {'1': wonderland}, - 'tags': {} - }); - server = JsonApiServer(RepositoryController(repository)); - client = RoutingClient(JsonApiClient(server), routing); - }); - - group('Single Resouces', () { - test('not compound by default', () async { - final r = await client.fetchResource('posts', '1'); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.isCompound, isFalse); - }); - - test('included == [] when requested but nothing to include', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included, []); - expect(r.data.isCompound, isTrue); - }); - - test('can include first-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expect(r.data.isCompound, isTrue); - }); - - test('can include second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - expect(r.data.isCompound, isTrue); - }); - - test('can include third-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - expect(r.data.isCompound, isTrue); - }); - - test('can include first- and second-level relatives', () async { - final r = await client.fetchResource('posts', '1', - parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap(), post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - expect(r.data.isCompound, isTrue); - }); - }); - - group('Resource Collection', () { - test('not compound by default', () async { - final r = await client.fetchCollection('posts'); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.isCompound, isFalse); - }); - - test('document is compound when requested but nothing to include', - () async { - final r = - await client.fetchCollection('posts', parameters: Include(['tags'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included, []); - expect(r.data.isCompound, isTrue); - }); - - test('can include first-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expect(r.data.isCompound, isTrue); - }); - - test('can include second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 2); - expectResourcesEqual(r.data.included.first.unwrap(), bob); - expectResourcesEqual(r.data.included.last.unwrap(), alice); - expect(r.data.isCompound, isTrue); - }); - - test('can include third-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments.author.birthplace'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 1); - expectResourcesEqual(r.data.included.first.unwrap(), wonderland); - expect(r.data.isCompound, isTrue); - }); - - test('can include first- and second-level relatives', () async { - final r = await client.fetchCollection('posts', - parameters: Include(['comments', 'comments.author'])); - expectResourcesEqual(r.data.unwrap().first, post); - expect(r.data.included.length, 4); - expectResourcesEqual(r.data.included[0].unwrap(), comment1); - expectResourcesEqual(r.data.included[1].unwrap(), comment2); - expectResourcesEqual(r.data.included[2].unwrap(), bob); - expectResourcesEqual(r.data.included[3].unwrap(), alice); - expect(r.data.isCompound, isTrue); - }); - }); -} diff --git a/test/functional/crud/creating_resources_test.dart b/test/functional/crud/creating_resources_test.dart deleted file mode 100644 index ca55b94d..00000000 --- a/test/functional/crud/creating_resources_test.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; -import 'package:uuid/uuid.dart'; - -import '../../helper/expect_resources_equal.dart'; - -void main() async { - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - group('Server-generated ID', () { - test('201 Created', () async { - final repository = InMemoryRepository({ - 'people': {}, - }, nextId: Uuid().v4); - final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server); - final routingClient = RoutingClient(client, routing); - - final person = - NewResource('people', attributes: {'name': 'Martin Fowler'}); - final r = await routingClient.createResource(person); - expect(r.statusCode, 201); - expect(r.location, isNotNull); - expect(r.location, r.data.links['self'].uri); - final created = r.data.unwrap(); - expect(created.type, person.type); - expect(created.id, isNotNull); - expect(created.attributes, equals(person.attributes)); - final r1 = await client.fetchResourceAt(r.location); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), created); - }); - - test('403 when the id can not be generated', () async { - final repository = InMemoryRepository({'people': {}}); - final server = JsonApiServer(RepositoryController(repository)); - final client = JsonApiClient(server); - final routingClient = RoutingClient(client, routing); - - final r = await routingClient.createResource(Resource('people', null)); - expect(r.statusCode, 403); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '403'); - expect(error.title, 'Unsupported operation'); - expect(error.detail, 'Id generation is not supported'); - }); - }); - - group('Client-generated ID', () { - JsonApiClient client; - RoutingClient routingClient; - setUp(() async { - final repository = InMemoryRepository({ - 'books': {}, - 'people': {}, - 'companies': {}, - 'noServerId': {}, - 'fruits': {}, - 'apples': {} - }); - final server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - }); - - test('204 No Content', () async { - final person = - Resource('people', '123', attributes: {'name': 'Martin Fowler'}); - final r = await routingClient.createResource(person); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.location, isNull); - expect(r.data, isNull); - final r1 = await routingClient.fetchResource(person.type, person.id); - expect(r1.isSuccessful, isTrue); - expect(r1.statusCode, 200); - expectResourcesEqual(r1.data.unwrap(), person); - }); - - test('404 when the collection does not exist', () async { - final r = await routingClient.createResource(Resource('unicorns', null)); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when the related resource does not exist (to-one)', () async { - final book = Resource('books', null, - toOne: {'publisher': Identifier('companies', '123')}); - final r = await routingClient.createResource(book); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'companies'"); - }); - - test('404 when the related resource does not exist (to-many)', () async { - final book = Resource('books', null, toMany: { - 'authors': [Identifier('people', '123')] - }); - final r = await routingClient.createResource(book); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '123' does not exist in 'people'"); - }); - - test('409 when the resource type does not match collection', () async { - final r = await client.createResourceAt( - routing.collection('fruits'), Resource('cucumbers', null)); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'cucumbers' does not belong in 'fruits'"); - }); - - test('409 when the resource with this id already exists', () async { - final apple = Resource('apples', '123'); - await routingClient.createResource(apple); - final r = await routingClient.createResource(apple); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Resource exists'); - expect(error.detail, 'Resource with this type and id already exists'); - }); - }); -} diff --git a/test/functional/crud/deleting_resources_test.dart b/test/functional/crud/deleting_resources_test.dart deleted file mode 100644 index 9b99fa21..00000000 --- a/test/functional/crud/deleting_resources_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - RoutingClient routingClient; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - - await seedResources(routingClient); - }); - - test('successful', () async { - final r = await routingClient.deleteResource('books', '1'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.isSuccessful, isFalse); - expect(r1.statusCode, 404); - }); - - test('404 on collecton', () async { - final r = await routingClient.deleteResource('unicorns', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.deleteResource('books', '42'); - expect(r.isSuccessful, isFalse); - expect(r.isFailed, isTrue); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); -} diff --git a/test/functional/crud/fetching_relationships_test.dart b/test/functional/crud/fetching_relationships_test.dart deleted file mode 100644 index 31d142fe..00000000 --- a/test/functional/crud/fetching_relationships_test.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - RoutingClient routingClient; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - - await seedResources(routingClient); - }); - group('To-one', () { - test('200 OK', () async { - final r = await routingClient.fetchToOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = await routingClient.fetchToOne('unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.fetchToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await routingClient.fetchToOne('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'owner' does not exist in this resource"); - }); - }); - - group('To-many', () { - test('200 OK', () async { - final r = await routingClient.fetchToMany('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.type, 'people'); - }); - - test('404 on collection', () async { - final r = await routingClient.fetchToMany('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.fetchToMany('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await routingClient.fetchToMany('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in this resource"); - }); - }); - - group('Generic', () { - test('200 OK to-one', () async { - final r = - await routingClient.fetchRelationship('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - final rel = r.data; - if (rel is ToOne) { - expect(rel.unwrap().type, 'companies'); - expect(rel.unwrap().id, '1'); - } else { - fail('Not a ToOne relationship'); - } - }); - - test('200 OK to-many', () async { - final r = await routingClient.fetchRelationship('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - final rel = r.data; - if (rel is ToMany) { - expect(rel.unwrap().length, 2); - expect(rel.unwrap().first.id, '1'); - expect(rel.unwrap().first.type, 'people'); - expect(rel.unwrap().last.id, '2'); - expect(rel.unwrap().last.type, 'people'); - } else { - fail('Not a ToMany relationship'); - } - }); - - test('404 on collection', () async { - final r = - await routingClient.fetchRelationship('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.fetchRelationship('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await routingClient.fetchRelationship('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in this resource"); - }); - }); -} diff --git a/test/functional/crud/fetching_resources_test.dart b/test/functional/crud/fetching_resources_test.dart deleted file mode 100644 index c547d249..00000000 --- a/test/functional/crud/fetching_resources_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - RoutingClient routingClient; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - - await seedResources(routingClient); - }); - - group('Primary Resource', () { - test('200 OK', () async { - final r = await routingClient.fetchResource('people', '1'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().id, '1'); - expect(r.data.unwrap().attributes['name'], 'Martin Fowler'); - }); - - test('404 on collection', () async { - final r = await routingClient.fetchResource('unicorns', '1'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.fetchResource('people', '42'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'people'"); - }); - }); - - group('Primary collections', () { - test('200 OK', () async { - final r = await routingClient.fetchCollection('people'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 3); - expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); - }); - - test('404', () async { - final r = await routingClient.fetchCollection('unicorns'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - }); - - group('Related Resource', () { - test('200 OK', () async { - final r = - await routingClient.fetchRelatedResource('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().type, 'companies'); - expect(r.data.unwrap().id, '1'); - }); - - test('404 on collection', () async { - final r = await routingClient.fetchRelatedResource( - 'unicorns', '1', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = - await routingClient.fetchRelatedResource('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = await routingClient.fetchRelatedResource('books', '1', 'owner'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'owner' does not exist in this resource"); - }); - }); - - group('Related Collection', () { - test('successful', () async { - final r = - await routingClient.fetchRelatedCollection('books', '1', 'authors'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.attributes['name'], 'Martin Fowler'); - }); - - test('404 on collection', () async { - final r = - await routingClient.fetchRelatedCollection('unicorns', '1', 'athors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Collection not found'); - expect(r.errors.first.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = - await routingClient.fetchRelatedCollection('books', '42', 'authors'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Resource not found'); - expect(r.errors.first.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 on relationship', () async { - final r = - await routingClient.fetchRelatedCollection('books', '1', 'readers'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.errors.first.status, '404'); - expect(r.errors.first.title, 'Relationship not found'); - expect(r.errors.first.detail, - "Relationship 'readers' does not exist in this resource"); - }); - }); -} diff --git a/test/functional/crud/seed_resources.dart b/test/functional/crud/seed_resources.dart deleted file mode 100644 index bf0cdc2a..00000000 --- a/test/functional/crud/seed_resources.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; - -Future seedResources(RoutingClient client) async { - await client.createResource( - Resource('people', '1', attributes: {'name': 'Martin Fowler'})); - await client.createResource( - Resource('people', '2', attributes: {'name': 'Kent Beck'})); - await client.createResource( - Resource('people', '3', attributes: {'name': 'Robert Martin'})); - await client.createResource(Resource('companies', '1', - attributes: {'name': 'Addison-Wesley Professional'})); - await client.createResource( - Resource('companies', '2', attributes: {'name': 'Prentice Hall'})); - await client.createResource(Resource('books', '1', attributes: { - 'title': 'Refactoring', - 'ISBN-10': '0134757599' - }, toOne: { - 'publisher': Identifier('companies', '1') - }, toMany: { - 'authors': [Identifier('people', '1'), Identifier('people', '2')] - })); -} diff --git a/test/functional/crud/updating_relationships_test.dart b/test/functional/crud/updating_relationships_test.dart deleted file mode 100644 index fd051102..00000000 --- a/test/functional/crud/updating_relationships_test.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - RoutingClient routingClient; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - - await seedResources(routingClient); - }); - - group('Updatng a to-one relationship', () { - test('204 No Content', () async { - final r = await routingClient.replaceToOne( - 'books', '1', 'publisher', Identifier('companies', '2')); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'].id, '2'); - }); - - test('404 on collection', () async { - final r = await routingClient.replaceToOne( - 'unicorns', '1', 'breed', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.replaceToOne( - 'books', '42', 'publisher', Identifier('companies', '2')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Deleting a to-one relationship', () { - test('204 No Content', () async { - final r = await routingClient.deleteToOne('books', '1', 'publisher'); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toOne['publisher'], isNull); - }); - - test('404 on collection', () async { - final r = await routingClient.deleteToOne('unicorns', '1', 'breed'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 on resource', () async { - final r = await routingClient.deleteToOne('books', '42', 'publisher'); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Replacing a to-many relationship', () { - test('204 No Content', () async { - final r = await routingClient - .replaceToMany('books', '1', 'authors', [Identifier('people', '1')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); - expect(r1.data.unwrap().toMany['authors'].first.id, '1'); - }); - - test('404 when collection not found', () async { - final r = await routingClient.replaceToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await routingClient.replaceToMany( - 'books', '42', 'publisher', [Identifier('companies', '2')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); - - group('Adding to a to-many relationship', () { - test('successfully adding a new identifier', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '3')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 3); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '3'); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 3); - }); - - test('successfully adding an existing identifier', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'authors', [Identifier('people', '2')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); - }); - - test('404 when collection not found', () async { - final r = await routingClient.addToRelationship( - 'unicorns', '1', 'breed', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await routingClient.addToRelationship( - 'books', '42', 'publisher', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - - test('404 when relationship not found', () async { - final r = await routingClient.addToRelationship( - 'books', '1', 'sellers', [Identifier('companies', '3')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Relationship not found'); - expect(error.detail, - "There is no to-many relationship 'sellers' in this resource"); - }); - }); - - group('Deleting from a to-many relationship', () { - test('successfully deleting an identifier', () async { - final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '1')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 1); - expect(r.data.unwrap().first.id, '2'); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 1); - }); - - test('successfully deleting a non-present identifier', () async { - final r = await routingClient.deleteFromToMany( - 'books', '1', 'authors', [Identifier('people', '3')]); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().length, 2); - expect(r.data.unwrap().first.id, '1'); - expect(r.data.unwrap().last.id, '2'); - - final r1 = await routingClient.fetchResource('books', '1'); - expect(r1.data.unwrap().toMany['authors'].length, 2); - }); - - test('404 when collection not found', () async { - final r = await routingClient.deleteFromToMany( - 'unicorns', '1', 'breed', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Collection not found'); - expect(error.detail, "Collection 'unicorns' does not exist"); - }); - - test('404 when resource not found', () async { - final r = await routingClient.deleteFromToMany( - 'books', '42', 'publisher', [Identifier('companies', '1')]); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - }); -} diff --git a/test/functional/crud/updating_resources_test.dart b/test/functional/crud/updating_resources_test.dart deleted file mode 100644 index efaf684e..00000000 --- a/test/functional/crud/updating_resources_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/server/in_memory_repository.dart'; -import 'package:json_api/src/server/json_api_server.dart'; -import 'package:json_api/src/server/repository_controller.dart'; -import 'package:test/test.dart'; - -import '../../helper/expect_resources_equal.dart'; -import 'seed_resources.dart'; - -void main() async { - JsonApiServer server; - JsonApiClient client; - RoutingClient routingClient; - final host = 'localhost'; - final port = 80; - final base = Uri(scheme: 'http', host: host, port: port); - final routing = StandardRouting(base); - - setUp(() async { - final repository = - InMemoryRepository({'books': {}, 'people': {}, 'companies': {}}); - server = JsonApiServer(RepositoryController(repository)); - client = JsonApiClient(server); - routingClient = RoutingClient(client, routing); - - await seedResources(routingClient); - }); - - test('200 OK', () async { - final r = - await routingClient.updateResource(Resource('books', '1', attributes: { - 'title': 'Refactoring. Improving the Design of Existing Code', - 'pages': 448 - }, toOne: { - 'publisher': null - }, toMany: { - 'authors': [Identifier('people', '1')], - 'reviewers': [Identifier('people', '2')] - })); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 200); - expect(r.data.unwrap().attributes['title'], - 'Refactoring. Improving the Design of Existing Code'); - expect(r.data.unwrap().attributes['pages'], 448); - expect(r.data.unwrap().attributes['ISBN-10'], '0134757599'); - expect(r.data.unwrap().toOne['publisher'], isNull); - expect( - r.data.unwrap().toMany['authors'], equals([Identifier('people', '1')])); - expect(r.data.unwrap().toMany['reviewers'], - equals([Identifier('people', '2')])); - - final r1 = await routingClient.fetchResource('books', '1'); - expectResourcesEqual(r1.data.unwrap(), r.data.unwrap()); - }); - - test('204 No Content', () async { - final r = await routingClient.updateResource(Resource('books', '1')); - expect(r.isSuccessful, isTrue); - expect(r.statusCode, 204); - expect(r.data, isNull); - }); - - test('404 on the target resource', () async { - final r = await routingClient.updateResource(Resource('books', '42')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 404); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '404'); - expect(error.title, 'Resource not found'); - expect(error.detail, "Resource '42' does not exist in 'books'"); - }); - - test('409 when the resource type does not match the collection', () async { - final r = await client.updateResourceAt( - routing.resource('people', '1'), Resource('books', '1')); - expect(r.isSuccessful, isFalse); - expect(r.statusCode, 409); - expect(r.data, isNull); - final error = r.errors.first; - expect(error.status, '409'); - expect(error.title, 'Invalid resource type'); - expect(error.detail, "Type 'books' does not belong in 'people'"); - }); -} diff --git a/test/helper/expect_resources_equal.dart b/test/helper/expect_resources_equal.dart deleted file mode 100644 index 4a9898c2..00000000 --- a/test/helper/expect_resources_equal.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void expectResourcesEqual(Resource a, Resource b) { - expect(a.type, equals(b.type)); - expect(a.id, equals(b.id)); - expect(a.attributes, equals(b.attributes)); - expect(a.toOne, equals(b.toOne)); - expect(a.toMany, equals(b.toMany)); -} diff --git a/test/helper/test_http_handler.dart b/test/helper/test_http_handler.dart deleted file mode 100644 index 26a9275e..00000000 --- a/test/helper/test_http_handler.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:json_api/http.dart'; - -class TestHttpHandler implements HttpHandler { - final requestLog = []; - HttpResponse nextResponse; - - @override - Future call(HttpRequest request) async { - requestLog.add(request); - return nextResponse; - } -} diff --git a/test/performance/encode_decode.dart b/test/performance/encode_decode.dart deleted file mode 100644 index cfd6fb9d..00000000 --- a/test/performance/encode_decode.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:json_api/document.dart'; - -void main() { - final meta = { - 'bool': true, - 'array': [1, 2, 3], - 'string': 'foo' - }; - final json = { - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page=2', - 'last': 'http://example.com/articles?page=10' - }, - 'meta': meta, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'meta': meta, - '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', - 'meta': meta, - }, - {'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'} - } - ], - 'jsonapi': {'version': '1.0', 'meta': meta} - }; - - final count = 100000; - final start = DateTime.now().millisecondsSinceEpoch; - for (var i = 0; i < count; i++) { - Document.fromJson(json, ResourceCollectionData.fromJson).toJson(); - } - final stop = DateTime.now().millisecondsSinceEpoch; - print('$count iterations took ${stop - start} ms'); -} diff --git a/test/unit/client/async_processing_test.dart b/test/unit/client/async_processing_test.dart deleted file mode 100644 index e0b376d0..00000000 --- a/test/unit/client/async_processing_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:json_api/src/document/resource.dart'; -import 'package:test/test.dart'; - -import '../../helper/test_http_handler.dart'; - -void main() { - final handler = TestHttpHandler(); - final client = RoutingClient(JsonApiClient(handler), StandardRouting()); - final routing = StandardRouting(); - - test('Client understands async responses', () async { - final links = StandardLinks(Uri.parse('/books'), routing); - final responseFactory = - HttpResponseConverter(DocumentFactory(links: links), routing); - handler.nextResponse = responseFactory.accepted(Resource('jobs', '42')); - - final r = await client.createResource(Resource('books', '1')); - expect(r.isAsync, true); - expect(r.isSuccessful, false); - expect(r.isFailed, false); - expect(r.asyncData.unwrap().type, 'jobs'); - expect(r.asyncData.unwrap().id, '42'); - expect(r.contentLocation.toString(), '/jobs/42'); - }); -} diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart new file mode 100644 index 00000000..eb27eb31 --- /dev/null +++ b/test/unit/client/client_test.dart @@ -0,0 +1,740 @@ +import 'dart:convert'; + +import 'package:json_api/client.dart'; +import 'package:json_api/document.dart'; +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +import 'mock_handler.dart'; +import 'response.dart' as mock; + +void main() { + final http = MockHandler(); + final client = + RoutingClient(StandardUriDesign.pathOnly, client: Client(handler: http)); + + group('Failure', () { + test('RequestFailure', () async { + http.response = mock.error422; + try { + await client.fetchCollection('articles'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + expect(e.errors.first.title, 'Invalid Attribute'); + } + }); + test('ServerError', () async { + http.response = mock.error500; + try { + await client.fetchCollection('articles'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 500); + } + }); + }); + + group('Fetch Collection', () { + test('Min', () async { + http.response = mock.collectionMin; + final response = await client.fetchCollection('articles'); + expect(response.collection.single.type, 'articles'); + expect(response.collection.single.id, '1'); + expect(response.included, isEmpty); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + }); + + test('Full', () async { + http.response = mock.collectionFull; + final response = await client.fetchCollection('articles', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, page: { + 'limit': '10' + }, sort: [ + 'title', + '-date' + ]); + + expect(response.collection.length, 1); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles'); + expect(http.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'sort': 'title,-date', + 'page[limit]': '10', + 'foo': 'bar' + }); + expect(http.request.headers, + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + group('Fetch Related Collection', () { + test('Min', () async { + http.response = mock.collectionFull; + final response = + await client.fetchRelatedCollection('people', '1', 'articles'); + expect(response.collection.length, 1); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/people/1/articles'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.collectionFull; + final response = await client + .fetchRelatedCollection('people', '1', 'articles', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }, page: { + 'limit': '10' + }, sort: [ + 'title', + '-date' + ]); + + expect(response.collection.length, 1); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/people/1/articles'); + expect(http.request.uri.queryParameters, { + 'include': 'author', + 'fields[author]': 'name', + 'sort': 'title,-date', + 'page[limit]': '10', + 'foo': 'bar' + }); + expect(http.request.headers, + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Fetch Primary Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client.fetchResource('articles', '1'); + expect(response.resource.type, 'articles'); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = await client.fetchResource('articles', '1', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }); + expect(response.resource.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1'); + expect(http.request.uri.queryParameters, + {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(http.request.headers, + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Fetch Related Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = + await client.fetchRelatedResource('articles', '1', 'author'); + expect(response.resource?.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/author'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = await client + .fetchRelatedResource('articles', '1', 'author', headers: { + 'foo': 'bar' + }, query: { + 'foo': 'bar' + }, include: [ + 'author' + ], fields: { + 'author': ['name'] + }); + expect(response.resource?.type, 'articles'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1/author'); + expect(http.request.uri.queryParameters, + {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'}); + expect(http.request.headers, + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + + test('Missing resource', () async { + http.response = mock.relatedResourceNull; + final response = + await client.fetchRelatedResource('articles', '1', 'author'); + expect(response.resource, isNull); + expect(response.included, isEmpty); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/author'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + }); + + group('Fetch Relationship', () { + test('Min', () async { + http.response = mock.one; + final response = await client.fetchToOne('articles', '1', 'author'); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, {'Accept': 'application/vnd.api+json'}); + }); + + test('Full', () async { + http.response = mock.one; + final response = await client.fetchToOne('articles', '1', 'author', + headers: {'foo': 'bar'}, query: {'foo': 'bar'}); + expect(response.included.length, 3); + expect(http.request.method, 'get'); + expect(http.request.uri.path, '/articles/1/relationships/author'); + expect(http.request.uri.queryParameters, {'foo': 'bar'}); + expect(http.request.headers, + {'Accept': 'application/vnd.api+json', 'foo': 'bar'}); + }); + }); + + group('Create New Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client.createNew('articles'); + expect(response.resource.type, 'articles'); + expect( + response.links['self'].toString(), 'http://example.com/articles/1'); + expect(response.included.length, 3); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = await client.createNew('articles', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }, headers: { + 'foo': 'bar' + }); + expect(response.resource.type, 'articles'); + expect( + response.links['self'].toString(), 'http://example.com/articles/1'); + expect(response.included.length, 3); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Create Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client.create('articles', '1'); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Min with 204 No Content', () async { + http.response = mock.noContent; + final response = await client.create('articles', '1'); + expect(response.resource, isNull); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = await client.create('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }, headers: { + 'foo': 'bar' + }); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Update Resource', () { + test('Min', () async { + http.response = mock.primaryResource; + final response = await client.updateResource('articles', '1'); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Min with 204 No Content', () async { + http.response = mock.noContent; + final response = await client.updateResource('articles', '1'); + expect(response.resource, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'articles', 'id': '1'} + }); + }); + + test('Full', () async { + http.response = mock.primaryResource; + final response = + await client.updateResource('articles', '1', attributes: { + 'cool': true + }, one: { + 'author': Identifier('people', '42')..meta.addAll({'hey': 'yos'}) + }, many: { + 'tags': [Identifier('tags', '1'), Identifier('tags', '2')] + }, meta: { + 'answer': 42 + }, headers: { + 'foo': 'bar' + }); + expect(response.resource?.type, 'articles'); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'cool': true}, + 'relationships': { + 'author': { + 'data': { + 'type': 'people', + 'id': '42', + 'meta': {'hey': 'yos'} + } + }, + 'tags': { + 'data': [ + {'type': 'tags', 'id': '1'}, + {'type': 'tags', 'id': '2'} + ] + } + }, + 'meta': {'answer': 42} + } + }); + }); + }); + + group('Replace One', () { + test('Min', () async { + http.response = mock.one; + final response = await client.replaceToOne( + 'articles', '1', 'author', Identifier('people', '42')); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Full', () async { + http.response = mock.one; + final response = await client.replaceToOne( + 'articles', '1', 'author', Identifier('people', '42'), + headers: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': {'type': 'people', 'id': '42'} + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.replaceToOne( + 'articles', '1', 'author', Identifier('people', '42')); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.many; + expect( + () => client.replaceToOne( + 'articles', '1', 'author', Identifier('people', '42')), + throwsFormatException); + }); + }); + + group('Delete One', () { + test('Min', () async { + http.response = mock.oneEmpty; + final response = await client.deleteToOne('articles', '1', 'author'); + expect(response.relationship, isA()); + expect(response.relationship!.identifier, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Full', () async { + http.response = mock.oneEmpty; + final response = await client + .deleteToOne('articles', '1', 'author', headers: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(response.relationship!.identifier, isNull); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/author'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), {'data': null}); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client.deleteToOne('articles', '1', 'author'); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.many; + expect(() => client.deleteToOne('articles', '1', 'author'), + throwsFormatException); + }); + }); + + group('Delete Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client + .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); + expect(response.relationship, isA()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client.deleteFromMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(http.request.method, 'delete'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client + .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () => client.deleteFromMany( + 'articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException); + }); + }); + + group('Replace Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client + .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client.replaceToMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(http.request.method, 'patch'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client + .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () => client.replaceToMany( + 'articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException); + }); + }); + + group('Add Many', () { + test('Min', () async { + http.response = mock.many; + final response = await client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); + expect(response.relationship, isA()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Full', () async { + http.response = mock.many; + final response = await client.addMany( + 'articles', '1', 'tags', [Identifier('tags', '1')], + headers: {'foo': 'bar'}); + expect(response.relationship, isA()); + expect(http.request.method, 'post'); + expect(http.request.uri.toString(), '/articles/1/relationships/tags'); + expect(http.request.headers, { + 'Accept': 'application/vnd.api+json', + 'Content-Type': 'application/vnd.api+json', + 'foo': 'bar' + }); + expect(jsonDecode(http.request.body), { + 'data': [ + {'type': 'tags', 'id': '1'} + ] + }); + }); + + test('Throws RequestFailure', () async { + http.response = mock.error422; + try { + await client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]); + fail('Exception expected'); + } on RequestFailure catch (e) { + expect(e.http.statusCode, 422); + expect(e.errors.first.status, '422'); + expect(e.toString(), contains('422')); + } + }); + + test('Throws FormatException', () async { + http.response = mock.one; + expect( + () => client + .addMany('articles', '1', 'tags', [Identifier('tags', '1')]), + throwsFormatException); + }); + }); +} diff --git a/test/unit/client/conrner_cases_test.dart b/test/unit/client/conrner_cases_test.dart deleted file mode 100644 index 5ca311f7..00000000 --- a/test/unit/client/conrner_cases_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:test/test.dart'; - -import '../../helper/test_http_handler.dart'; - -void main() { - final handler = TestHttpHandler(); - final client = RoutingClient(JsonApiClient(handler), StandardRouting()); - test('Error status code with incorrect content-type, body is not decoded', - () async { - handler.nextResponse = HttpResponse(500, body: 'Something went wrong'); - - final r = await client.fetchCollection('books'); - expect(r.isAsync, false); - expect(r.isSuccessful, false); - expect(r.isFailed, true); - expect(r.data, isNull); - expect(r.asyncData, isNull); - expect(r.statusCode, 500); - }); - - test('Do not attempt to decode primary data if decoder is null', () async { - handler.nextResponse = HttpResponse(200, - body: jsonEncode({ - 'meta': {'foo': 'bar'}, - 'data': {'id': '123', 'type': 'books'} - })); - - final r = await client.deleteResource('books', '123'); - expect(r.isAsync, false); - expect(r.isSuccessful, true); - expect(r.isFailed, false); - expect(r.data, isNull); - expect(r.document.meta['foo'], 'bar'); - expect(r.asyncData, isNull); - expect(r.statusCode, 200); - }); -} diff --git a/test/unit/client/creating_resource_with_meta_test.dart b/test/unit/client/creating_resource_with_meta_test.dart deleted file mode 100644 index b4a11e9f..00000000 --- a/test/unit/client/creating_resource_with_meta_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:json_api/client.dart'; -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -import '../../helper/test_http_handler.dart'; - -void main() async { - test('request actually has the meta property', () async { - final handler = TestHttpHandler(); - final client = JsonApiClient(handler); - - final uri = Uri.parse('https://github.com/f3ath/json-api-dart'); - final person = - Resource('people', '123', attributes: {'name': 'Te Cheng Hung'}); - final meta = {'friend': 'Martin Fowler'}; - - handler.nextResponse = HttpResponse(201); - await client.createResourceAt(uri, person, meta: meta); - - final request = handler.requestLog.first; - expect(request.body, - '{"data":{"type":"people","id":"123","attributes":{"name":"Te Cheng Hung"}},"meta":{"friend":"Martin Fowler"}}'); - }); -} diff --git a/test/unit/client/dart_http_test.dart b/test/unit/client/dart_http_test.dart deleted file mode 100644 index 0c82eb6b..00000000 --- a/test/unit/client/dart_http_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:json_api/client.dart'; -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - group('Decode body with', () { - final stringBodyRu = 'йцукен'; - final bytesBodyRu = utf8.encode(stringBodyRu); - final stringBodyEn = 'qwerty'; - final bytesBodyEn = utf8.encode(stringBodyEn); - - final buildResponse = ( - List bytesBody, - Encoding encoding, - ) async { - final dartHttp = DartHttp( - MockClient( - (request) async { - return http.Response.bytes(bytesBody, 200); - }, - ), - encoding, - ); - - return dartHttp.call(HttpRequest('', Uri.parse('http://test.com'))); - }; - - test('UTF-8 ru', () async { - final response = await buildResponse(bytesBodyRu, utf8); - expect(response.body, equals(stringBodyRu)); - }); - - test('latin1 ru', () async { - final response = await buildResponse(bytesBodyRu, latin1); - expect(response.body, isNot(equals(stringBodyRu))); - }); - - test('UTF-8 en', () async { - final response = await buildResponse(bytesBodyEn, utf8); - expect(response.body, equals(stringBodyEn)); - }); - - test('latin1 en', () async { - final response = await buildResponse(bytesBodyEn, latin1); - expect(response.body, equals(stringBodyEn)); - }); - }); -} diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart new file mode 100644 index 00000000..9dfe2ff9 --- /dev/null +++ b/test/unit/client/mock_handler.dart @@ -0,0 +1,12 @@ +import 'package:json_api/http.dart'; + +class MockHandler implements HttpHandler { + late HttpRequest request; + late HttpResponse response; + + @override + Future handle(HttpRequest request) async { + this.request = request; + return response; + } +} diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart new file mode 100644 index 00000000..10213a0f --- /dev/null +++ b/test/unit/client/response.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; + +import 'package:json_api/http.dart'; +import 'package:json_api/src/http/media_type.dart'; + +final collectionMin = HttpResponse(200, + body: jsonEncode({ + 'data': [ + {'type': 'articles', 'id': '1'} + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final collectionFull = HttpResponse(200, + body: jsonEncode({ + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10' + }, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final primaryResource = HttpResponse(200, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1'}, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'} + } + } + }, + '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'} + } + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); +final relatedResourceNull = HttpResponse(200, + body: jsonEncode({ + 'links': {'self': 'http://example.com/articles/1/author'}, + 'data': null + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); +final one = HttpResponse(200, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': {'type': 'people', 'id': '12'}, + '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'} + } + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final oneEmpty = HttpResponse(200, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': null, + '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'} + } + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final many = HttpResponse(200, + body: jsonEncode({ + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [ + {'type': 'tags', 'id': '12'} + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final noContent = HttpResponse(204); + +final error422 = HttpResponse(422, + body: jsonEncode({ + 'errors': [ + { + 'status': '422', + 'source': {'pointer': '/data/attributes/firstName'}, + 'title': 'Invalid Attribute', + 'detail': 'First name must contain at least three characters.' + } + ] + })) + ..headers.addAll({'Content-Type': MediaType.jsonApi}); + +final error500 = HttpResponse(500); diff --git a/test/unit/document/api_test.dart b/test/unit/document/api_test.dart deleted file mode 100644 index 317cbf35..00000000 --- a/test/unit/document/api_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - test('Api can be json-encoded', () { - final api = Api.fromJson( - json.decode(json.encode(Api(version: '1.0', meta: {'foo': 'bar'})))); - expect('1.0', api.version); - expect('bar', api.meta['foo']); - }); - - test('Throws exception when can not be decoded', () { - expect(() => Api.fromJson([]), throwsA(TypeMatcher())); - }); - - test('Empty/null properties are not encoded', () { - expect(Api(), encodesToJson({})); - }); -} diff --git a/test/unit/document/document_test.dart b/test/unit/document/document_test.dart deleted file mode 100644 index e094c72c..00000000 --- a/test/unit/document/document_test.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('Unrecognized structure', () { - expect(() => Document.fromJson({}, ResourceCollectionData.fromJson), - throwsA(TypeMatcher())); - }); -} diff --git a/test/unit/document/error_object_test.dart b/test/unit/document/error_object_test.dart new file mode 100644 index 00000000..f42b2d2b --- /dev/null +++ b/test/unit/document/error_object_test.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:json_api/src/document/error_source.dart'; +import 'package:test/test.dart'; + +void main() { + group('ErrorObject', () { + test('Minimal', () { + expect(jsonEncode(ErrorObject()), '{}'); + }); + test('Full', () { + expect( + jsonEncode(ErrorObject( + id: 'test_id', + status: 'test_status', + code: 'test_code', + title: 'test_title', + detail: 'test_detail', + source: ErrorSource( + parameter: 'test_parameter', pointer: 'test_pointer')) + ..links['foo'] = Link(Uri.parse('/bar')) + ..meta['foo'] = 42), + jsonEncode({ + 'id': 'test_id', + 'status': 'test_status', + 'code': 'test_code', + 'title': 'test_title', + 'detail': 'test_detail', + 'source': { + 'parameter': 'test_parameter', + 'pointer': 'test_pointer' + }, + 'links': {'foo': '/bar'}, + 'meta': {'foo': 42}, + })); + }); + }); +} diff --git a/test/unit/document/identifier_object_test.dart b/test/unit/document/identifier_object_test.dart deleted file mode 100644 index f7aff941..00000000 --- a/test/unit/document/identifier_object_test.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:test/test.dart'; - -void main() { - test('throws DocumentException when can not be decoded', () { - expect(() => IdentifierObject.fromJson([]), - throwsA(TypeMatcher())); - }); -} diff --git a/test/unit/document/identifier_test.dart b/test/unit/document/identifier_test.dart deleted file mode 100644 index 05d485c4..00000000 --- a/test/unit/document/identifier_test.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('equal identifiers are detected by Set', () { - expect({Identifier('foo', '1'), Identifier('foo', '1')}.length, 1); - }); -} diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart new file mode 100644 index 00000000..75669439 --- /dev/null +++ b/test/unit/document/inbound_document_test.dart @@ -0,0 +1,185 @@ +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +import 'payload.dart' as payload; + +void main() { + group('InboundDocument', () { + group('Errors', () { + test('Minimal', () { + final e = InboundDocument({ + 'errors': [{}] + }).errors().first; + expect(e.id, ''); + expect(e.status, ''); + expect(e.code, ''); + expect(e.title, ''); + expect(e.detail, ''); + expect(e.source.parameter, ''); + expect(e.source.pointer, ''); + expect(e.source.isEmpty, true); + expect(e.source.isNotEmpty, false); + expect(e.links, isEmpty); + expect(e.meta, isEmpty); + }); + test('Full', () { + final error = { + 'id': 'test_id', + 'status': 'test_status', + 'code': 'test_code', + 'title': 'test_title', + 'detail': 'test_detail', + 'source': {'parameter': 'test_parameter', 'pointer': 'test_pointer'}, + 'links': {'foo': '/bar'}, + 'meta': {'foo': 42}, + }; + final e = InboundDocument({ + 'errors': [error] + }).errors().first; + + expect(e.id, 'test_id'); + expect(e.status, 'test_status'); + expect(e.code, 'test_code'); + expect(e.title, 'test_title'); + expect(e.detail, 'test_detail'); + expect(e.source.parameter, 'test_parameter'); + expect(e.source.pointer, 'test_pointer'); + expect(e.source.isEmpty, false); + expect(e.source.isNotEmpty, true); + expect(e.links['foo'].toString(), '/bar'); + expect(e.meta['foo'], 42); + }); + + test('Invalid', () { + expect( + () => InboundDocument({ + 'errors': [ + {'id': []} + ] + }).errors().first, + throwsFormatException); + }); + }); + + group('Parsing', () { + test('can parse the standard example', () { + final doc = InboundDocument(payload.example); + expect( + doc + .dataAsCollection() + .first + .relationships['author']! + .links['self']! + .uri + .toString(), + 'http://example.com/articles/1/relationships/author'); + expect(doc.included().first.attributes['firstName'], 'Dan'); + expect(doc.links()['self'].toString(), 'http://example.com/articles'); + expect(doc.meta(), isEmpty); + }); + + test('can parse primary resource', () { + final doc = InboundDocument(payload.resource); + final article = doc.dataAsResource(); + expect(article.id, '1'); + expect(article.attributes['title'], 'JSON:API paints my bikeshed!'); + expect(article.relationships['author'], isA()); + expect(doc.included(), isEmpty); + expect(doc.links()['self'].toString(), 'http://example.com/articles/1'); + expect(doc.meta(), isEmpty); + }); + + test('can parse a new resource', () { + final doc = InboundDocument(payload.newResource); + final article = doc.dataAsNewResource(); + expect(article.attributes['title'], 'A new article'); + expect(doc.included(), isEmpty); + expect(doc.links(), isEmpty); + expect(doc.meta(), isEmpty); + }); + + test('newResource() has id if data is sufficient', () { + final doc = InboundDocument(payload.resource); + final article = doc.dataAsNewResource(); + expect(article.id, isNotEmpty); + }); + + test('can parse related resource', () { + final doc = InboundDocument(payload.relatedEmpty); + expect(doc.dataAsResourceOrNull(), isNull); + expect(doc.included(), isEmpty); + expect(doc.links()['self'].toString(), + 'http://example.com/articles/1/author'); + expect(doc.meta(), isEmpty); + }); + + test('can parse to-one', () { + final doc = InboundDocument(payload.one); + expect(doc.asToOne(), isA()); + expect(doc.asToOne(), isNotEmpty); + expect(doc.asToOne().first.type, 'people'); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta(), isEmpty); + }); + + test('can parse empty to-one', () { + final doc = InboundDocument(payload.oneEmpty); + expect(doc.asToOne(), isA()); + expect(doc.asToOne(), isEmpty); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/author'); + expect(doc.meta(), isEmpty); + }); + + test('can parse to-many', () { + final doc = InboundDocument(payload.many); + expect(doc.asToMany(), isA()); + expect(doc.asToMany(), isNotEmpty); + expect(doc.asToMany().first.type, 'tags'); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta(), isEmpty); + }); + + test('can parse empty to-many', () { + final doc = InboundDocument(payload.manyEmpty); + expect(doc.asToMany(), isA()); + expect(doc.asToMany(), isEmpty); + expect(doc.included(), isEmpty); + expect( + doc.links()['self'].toString(), '/articles/1/relationships/tags'); + expect(doc.meta(), isEmpty); + }); + + test('throws on invalid doc', () { + expect(() => InboundDocument(payload.manyEmpty).dataAsResourceOrNull(), + throwsFormatException); + expect(() => InboundDocument(payload.newResource).dataAsResource(), + throwsFormatException); + expect( + () => InboundDocument(payload.newResource).dataAsResourceOrNull(), + throwsFormatException); + expect(() => InboundDocument({}).dataAsResourceOrNull(), + throwsFormatException); + expect(() => InboundDocument({'data': 42}).asToMany(), + throwsFormatException); + expect( + () => InboundDocument({ + 'links': {'self': 42} + }).asToOne(), + throwsFormatException); + }); + + test('throws on invalid relationship kind', () { + expect(() => InboundDocument(payload.one).asToMany(), + throwsFormatException); + expect(() => InboundDocument(payload.many).asToOne(), + throwsFormatException); + }); + }); + }); +} diff --git a/test/unit/document/json_api_error_test.dart b/test/unit/document/json_api_error_test.dart deleted file mode 100644 index 3d08db45..00000000 --- a/test/unit/document/json_api_error_test.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('links', () { - test('recognizes custom links', () { - final e = ErrorObject( - links: {'my-link': Link(Uri.parse('http://example.com'))}); - expect(e.links['my-link'].toString(), 'http://example.com'); - }); - - test('"links" may contain the "about" key', () { - final e = ErrorObject(links: { - 'my-link': Link(Uri.parse('http://example.com')), - 'about': Link(Uri.parse('/about')) - }); - expect(e.links['my-link'].toString(), 'http://example.com'); - expect(e.links['about'].toString(), '/about'); - expect(e.about.toString(), '/about'); - }); - - test('custom "links" survives json serialization', () { - final e = ErrorObject( - links: {'my-link': Link(Uri.parse('http://example.com'))}); - expect( - ErrorObject.fromJson(json.decode(json.encode(e))) - .links['my-link'] - .toString(), - 'http://example.com'); - }); - }); - - group('parsing', () { - // see https://github.com/f3ath/json-api-dart/issues/91 - test('non-standard keys/values in the source object casted to string', () { - final e = ErrorObject.fromJson({ - 'detail': 'Oops', - 'source': {'file': '/some/file.php', 'line': 42, 'parameter': 'foo'} - }); - expect(e.detail, 'Oops'); - expect(e.source['parameter'], 'foo'); - expect(e.source['file'], '/some/file.php'); - expect(e.source['line'], '42'); - }); - }); -} diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart index b17245c9..2d7489c3 100644 --- a/test/unit/document/link_test.dart +++ b/test/unit/document/link_test.dart @@ -1,41 +1,22 @@ import 'dart:convert'; +import 'dart:io'; import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; import 'package:test/test.dart'; void main() { - test('link can encoded and decoded', () { - final link = Link(Uri.parse('http://example.com')); - expect(Link.fromJson(json.decode(json.encode(link))).uri.toString(), - 'http://example.com'); - }); - - test('link object can be parsed from JSON', () { - final link = - LinkObject(Uri.parse('http://example.com'), meta: {'foo': 'bar'}); - - final parsed = Link.fromJson(json.decode(json.encode(link))); - expect(parsed.uri.toString(), 'http://example.com'); - if (parsed is LinkObject) { - expect(parsed.meta['foo'], 'bar'); - } else { - fail('LinkObject expected'); - } - }); - - test('a map of link object can be parsed from JSON', () { - final links = Link.mapFromJson({ - 'first': 'http://example.com/first', - 'last': 'http://example.com/last' + group('Link', () { + final href = 'http://example.com'; + test('String', () { + expect(jsonEncode(Link(Uri.parse(href))), jsonEncode(href)); + }); + test('Object', () { + expect( + jsonEncode(Link(Uri.parse(href))..meta['foo'] = []), + jsonEncode({ + 'href': href, + 'meta': {'foo': []} + })); }); - expect(links['first'].uri.toString(), 'http://example.com/first'); - expect(links['last'].uri.toString(), 'http://example.com/last'); - }); - - test('link throws DocumentException on invalid JSON', () { - expect(() => Link.fromJson([]), throwsA(TypeMatcher())); - expect( - () => Link.mapFromJson([]), throwsA(TypeMatcher())); }); } diff --git a/test/unit/document/meta_members_test.dart b/test/unit/document/meta_members_test.dart deleted file mode 100644 index 4cf1c2d2..00000000 --- a/test/unit/document/meta_members_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('Meta members', () { - test('should be parsed correctly', () { - final meta = { - 'bool': true, - 'array': [1, 2, 3], - 'string': 'foo' - }; - final json = { - 'links': { - 'self': 'http://example.com/articles', - 'next': 'http://example.com/articles?page=2', - 'last': 'http://example.com/articles?page=10' - }, - 'meta': meta, - 'data': [ - { - 'type': 'articles', - 'id': '1', - 'attributes': {'title': 'JSON:API paints my bikeshed!'}, - 'meta': meta, - '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', - 'meta': meta, - }, - {'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'} - } - ] - }; - - final doc = Document.fromJson(json, ResourceCollectionData.fromJson); - expect(doc.meta['bool'], true); - expect(doc.data.collection.first.meta, meta); - expect( - (doc.data.collection.first.relationships['comments'] as ToMany) - .linkage - .first - .meta, - meta); - }); - }); -} diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart new file mode 100644 index 00000000..f3d826aa --- /dev/null +++ b/test/unit/document/new_resource_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('NewResource', () { + test('json encoding', () { + expect(jsonEncode(NewResource('test_type')), + jsonEncode({'type': 'test_type'})); + + expect( + jsonEncode(NewResource('test_type') + ..meta['foo'] = [42] + ..attributes['color'] = 'green' + ..relationships['one'] = + (ToOne(Identifier('rel', '1')..meta['rel'] = 1) + ..meta['one'] = 1) + ..relationships['many'] = + (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) + ..meta['many'] = 1)), + jsonEncode({ + 'type': 'test_type', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + 'meta': {'one': 1} + }, + 'many': { + 'data': [ + { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + ], + 'meta': {'many': 1} + } + }, + 'meta': { + 'foo': [42] + } + })); + }); + }); +} diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart new file mode 100644 index 00000000..5631f3a2 --- /dev/null +++ b/test/unit/document/outbound_document_test.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; + +import 'package:json_api/document.dart'; +import 'package:test/test.dart'; + +void main() { + group('OutboundDocument', () { + group('Meta', () { + test('The "meta" member must be always present', () { + expect(toObject(OutboundDocument()), {'meta': {}}); + }); + test('full', () { + expect(toObject(OutboundDocument()..meta['foo'] = true), { + 'meta': {'foo': true} + }); + }); + }); + + group('Error', () { + test('minimal', () { + expect(toObject(OutboundErrorDocument([])), {'errors': []}); + }); + test('full', () { + expect( + toObject(OutboundErrorDocument([ErrorObject(detail: 'Some issue')]) + ..meta['foo'] = 42), + { + 'errors': [ + {'detail': 'Some issue'} + ], + 'meta': {'foo': 42} + }); + }); + }); + }); + + group('Data', () { + final book = Resource('books', '1'); + final author = Resource('people', '2'); + group('Resource', () { + test('minimal', () { + expect(toObject(OutboundDataDocument.resource(book)), { + 'data': {'type': 'books', 'id': '1'} + }); + }); + test('full', () { + expect( + toObject(OutboundDataDocument.resource(book) + ..meta['foo'] = 42 + ..included.add(author) + ..links['self'] = Link(Uri.parse('/books/1'))), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('Collection', () { + test('minimal', () { + expect(toObject(OutboundDataDocument.collection([])), {'data': []}); + }); + test('full', () { + expect( + toObject(OutboundDataDocument.collection([book]) + ..meta['foo'] = 42 + ..included.add(author) + ..links['self'] = Link(Uri.parse('/books/1'))), + { + 'data': [ + {'type': 'books', 'id': '1'} + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('One', () { + test('minimal', () { + expect( + toObject(OutboundDataDocument.one(ToOne.empty())), {'data': null}); + }); + test('full', () { + expect( + toObject(OutboundDataDocument.one(ToOne(Identifier.of(book)) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1'))) + ..included.add(author)), + { + 'data': {'type': 'books', 'id': '1'}, + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + + group('Many', () { + test('minimal', () { + expect(toObject(OutboundDataDocument.many(ToMany([]))), {'data': []}); + }); + test('full', () { + expect( + toObject(OutboundDataDocument.many(ToMany([Identifier.of(book)]) + ..meta['foo'] = 42 + ..links['self'] = Link(Uri.parse('/books/1'))) + ..included.add(author)), + { + 'data': [ + {'type': 'books', 'id': '1'} + ], + 'links': {'self': '/books/1'}, + 'included': [ + {'type': 'people', 'id': '2'} + ], + 'meta': {'foo': 42} + }); + }); + }); + }); +} + +Map toObject(v) => jsonDecode(jsonEncode(v)); diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart new file mode 100644 index 00000000..fc1758c2 --- /dev/null +++ b/test/unit/document/payload.dart @@ -0,0 +1,140 @@ +final example = { + 'links': { + 'self': 'http://example.com/articles', + 'next': 'http://example.com/articles?page[offset]=2', + 'last': 'http://example.com/articles?page[offset]=10' + }, + 'data': [ + { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/author', + 'related': 'http://example.com/articles/1/author' + }, + 'data': {'type': 'people', 'id': '9'} + }, + 'comments': { + 'links': { + 'self': 'http://example.com/articles/1/relationships/comments', + 'related': 'http://example.com/articles/1/comments' + }, + 'data': [ + {'type': 'comments', 'id': '5'}, + {'type': 'comments', 'id': '12'} + ] + } + }, + 'links': {'self': 'http://example.com/articles/1'} + } + ], + 'included': [ + { + 'type': 'people', + 'id': '9', + 'attributes': { + 'firstName': 'Dan', + 'lastName': 'Gebhardt', + 'twitter': 'dgeb' + }, + 'links': {'self': 'http://example.com/people/9'} + }, + { + 'type': 'comments', + 'id': '5', + 'attributes': {'body': 'First!'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '2'} + } + }, + 'links': {'self': 'http://example.com/comments/5'} + }, + { + 'type': 'comments', + 'id': '12', + 'attributes': {'body': 'I like XML better'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '9'} + } + }, + 'links': {'self': 'http://example.com/comments/12'} + } + ] +}; + +final newResource = { + 'data': { + 'type': 'articles', + 'attributes': {'title': 'A new article'}, + 'relationships': { + 'author': { + 'data': {'type': 'people', 'id': '42'} + } + } + } +}; + +final many = { + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [ + {'type': 'tags', 'id': '2'}, + {'type': 'tags', 'id': '3'} + ] +}; + +final manyEmpty = { + 'links': { + 'self': '/articles/1/relationships/tags', + 'related': '/articles/1/tags' + }, + 'data': [] +}; + +final one = { + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': {'type': 'people', 'id': '12'} +}; + +final oneEmpty = { + 'links': { + 'self': '/articles/1/relationships/author', + 'related': '/articles/1/author' + }, + 'data': null +}; + +final relatedEmpty = { + 'links': {'self': 'http://example.com/articles/1/author'}, + 'data': null +}; + +final resource = { + 'links': { + 'self': { + 'href': 'http://example.com/articles/1', + 'meta': {'answer': 42} + } + }, + 'data': { + 'type': 'articles', + 'id': '1', + 'attributes': {'title': 'JSON:API paints my bikeshed!'}, + 'relationships': { + 'author': { + 'links': {'related': 'http://example.com/articles/1/author'} + }, + 'reviewer': {'data': null} + } + } +}; diff --git a/test/unit/document/relationship_test.dart b/test/unit/document/relationship_test.dart index 360c3de5..934af982 100644 --- a/test/unit/document/relationship_test.dart +++ b/test/unit/document/relationship_test.dart @@ -1,37 +1,45 @@ -import 'dart:convert'; - import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - group('custom links', () { - test('recognizes custom links', () { - final r = Relationship(links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); + final a = Identifier('apples', 'a'); + final b = Identifier('apples', 'b'); + group('Relationship', () { + test('one', () { + expect(ToOne(a).identifier, a); + expect([...ToOne(a)].first, a); - test('"links" may contain the "related" and "self" keys', () { - final r = Relationship(links: { - 'my-link': Link(Uri.parse('/my-link')), - 'related': Link(Uri.parse('/related')), - 'self': Link(Uri.parse('/self')) - }); - expect(r.links['my-link'].toString(), '/my-link'); - expect(r.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - expect(r.self.toString(), '/self'); - expect(r.related.toString(), '/related'); + expect(ToOne.empty().identifier, isNull); + expect([...ToOne.empty()], isEmpty); }); - test('custom "links" survives json serialization', () { - final r = Relationship(links: { - 'my-link': Link(Uri.parse('/my-link')), - }); + test('many', () { + expect(ToMany([]), isEmpty); + expect([...ToMany([])], isEmpty); + + expect(ToMany([a]), isNotEmpty); + expect( + [ + ...ToMany([a]) + ].first, + a); + + expect(ToMany([a, b]), isNotEmpty); + expect( + [ + ...ToMany([a, b]) + ].first, + a); expect( - Relationship.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); + [ + ...ToMany([a, b]) + ].last, + b); }); }); + + test('incomplete', () { + expect(Relationship(), isEmpty); + expect([...Relationship()], isEmpty); + }); } diff --git a/test/unit/document/resource_collection_data_test.dart b/test/unit/document/resource_collection_data_test.dart deleted file mode 100644 index 76d0dfa5..00000000 --- a/test/unit/document/resource_collection_data_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - test('unwrapToMap() returns a map by id', () { - final fruits = ResourceCollectionData( - [ResourceObject('apples', '1'), ResourceObject('pears', '2')]) - .unwrapToMap(); - expect(fruits['1'].type, 'apples'); - expect(fruits['2'].type, 'pears'); - expect(fruits.length, 2); - }); - group('custom links', () { - test('recognizes custom links', () { - final r = ResourceCollectionData([], - links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "self" key', () { - final r = ResourceCollectionData([], links: { - 'my-link': Link(Uri.parse('/my-link')), - 'self': Link(Uri.parse('/self')), - }); - expect(r.links['my-link'].toString(), '/my-link'); - expect(r.links['self'].toString(), '/self'); - expect(r.self.toString(), '/self'); - }); - - test('survives json serialization', () { - final r = ResourceCollectionData([], links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ResourceCollectionData.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/document/resource_data_test.dart b/test/unit/document/resource_data_test.dart deleted file mode 100644 index 6d92b3ec..00000000 --- a/test/unit/document/resource_data_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/src/document/document_exception.dart'; -import 'package:test/test.dart'; - -void main() { - test('Can decode a primary resource with missing id', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples'} - }))); - expect(data.unwrap().type, 'apples'); - expect(data.unwrap().id, isNull); - }); - - test('Can decode a primary resource with null id', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': null} - }))); - expect(data.unwrap().type, 'apples'); - expect(data.unwrap().id, isNull); - }); - - test('Can decode a related resource which is null', () { - final data = - ResourceData.fromJson(json.decode(json.encode({'data': null}))); - expect(data.unwrap(), null); - }); - - test('Inherits links from ResourceObject', () { - final res = ResourceObject('apples', '1', links: { - 'foo': Link(Uri.parse('/foo')), - 'bar': Link(Uri.parse('/bar')), - 'self': Link(Uri.parse('/self')), - }); - final data = ResourceData(res, links: { - 'bar': Link(Uri.parse('/bar-new')), - }); - expect(data.links['foo'].toString(), '/foo'); - expect(data.links['bar'].toString(), '/bar-new'); - expect(data.self.toString(), '/self'); - }); - - group('included resources decoding', () { - test('null decodes to null', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'} - }))); - expect(data.isCompound, isFalse); - }); - test('[] decodes to []', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': [] - }))); - expect(data.included, equals([])); - expect(data.isCompound, isTrue); - }); - test('non empty [] decodes to non-empty []', () { - final data = ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': [ - {'type': 'oranges', 'id': '1'} - ] - }))); - expect(data.included, isNotEmpty); - expect(data.isCompound, isTrue); - }); - test('invalid value throws DocumentException', () { - expect( - () => ResourceData.fromJson(json.decode(json.encode({ - 'data': {'type': 'apples', 'id': '1'}, - 'included': {} - }))), - throwsA(TypeMatcher())); - }); - }); - - group('custom links', () { - final res = ResourceObject('apples', '1'); - test('recognizes custom links', () { - final data = - ResourceData(res, links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(data.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "self" key', () { - final data = ResourceData(res, links: { - 'my-link': Link(Uri.parse('/my-link')), - 'self': Link(Uri.parse('/self')) - }); - expect(data.links['my-link'].toString(), '/my-link'); - expect(data.links['self'].toString(), '/self'); - expect(data.self.toString(), '/self'); - }); - - test('survives json serialization', () { - final data = ResourceData(res, links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ResourceData.fromJson(json.decode(json.encode(data))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/document/resource_object_test.dart b/test/unit/document/resource_object_test.dart deleted file mode 100644 index d1f20738..00000000 --- a/test/unit/document/resource_object_test.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_matcher/json_matcher.dart'; -import 'package:test/test.dart'; - -void main() { - group('ResourceObject', () { - /// id:null should not be included in JSON - /// https://jsonapi.org/format/#crud-creating - test('id:null should not be included in JSON', () { - final res = ResourceObject('photos', null, attributes: { - 'title': 'Ember Hamster', - 'src': 'http://example.com/images/productivity.png' - }, relationships: { - 'photographer': ToOne(IdentifierObject('people', '9')) - }); - - expect( - res, - encodesToJson({ - 'type': 'photos', - 'attributes': { - 'title': 'Ember Hamster', - 'src': 'http://example.com/images/productivity.png' - }, - 'relationships': { - 'photographer': { - 'data': {'type': 'people', 'id': '9'} - } - } - })); - }); - }); - - group('custom links', () { - test('recognizes custom links', () { - final r = ResourceObject('apples', '1', - links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "self" key', () { - final r = ResourceObject('apples', '1', links: { - 'my-link': Link(Uri.parse('/my-link')), - 'self': Link(Uri.parse('/self')) - }); - expect(r.links['my-link'].toString(), '/my-link'); - expect(r.links['self'].toString(), '/self'); - expect(r.self.toString(), '/self'); - }); - - test('survives json serialization', () { - final r = ResourceObject('apples', '1', links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ResourceObject.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/document/resource_test.dart b/test/unit/document/resource_test.dart index 0a4418eb..94aafc56 100644 --- a/test/unit/document/resource_test.dart +++ b/test/unit/document/resource_test.dart @@ -1,16 +1,65 @@ +import 'dart:convert'; + import 'package:json_api/document.dart'; import 'package:test/test.dart'; void main() { - test('Removes duplicate identifiers in toMany relationships', () { - final r = Resource('type', 'id', toMany: { - 'rel': [Identifier('foo', '1'), Identifier('foo', '1')] - }); - expect(r.toMany['rel'].length, 1); - }); + group('Resource', () { + test('json encoding', () { + expect(jsonEncode(Resource('test_type', 'test_id')), + jsonEncode({'type': 'test_type', 'id': 'test_id'})); - test('toString', () { - expect(Resource('appless', '42', attributes: {'color': 'red'}).toString(), - 'Resource(appless:42 {color: red})'); + expect( + jsonEncode(Resource('test_type', 'test_id') + ..meta['foo'] = [42] + ..attributes['color'] = 'green' + ..relationships['one'] = + (ToOne(Identifier('rel', '1')..meta['rel'] = 1) + ..meta['one'] = 1) + ..relationships['many'] = + (ToMany([Identifier('rel', '1')..meta['rel'] = 1]) + ..meta['many'] = 1) + ..links['self'] = (Link(Uri.parse('/apples/42'))..meta['a'] = 1)), + jsonEncode({ + 'type': 'test_type', + 'id': 'test_id', + 'attributes': {'color': 'green'}, + 'relationships': { + 'one': { + 'data': { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + 'meta': {'one': 1} + }, + 'many': { + 'data': [ + { + 'type': 'rel', + 'id': '1', + 'meta': {'rel': 1} + }, + ], + 'meta': {'many': 1} + } + }, + 'links': { + 'self': { + 'href': '/apples/42', + 'meta': {'a': 1} + } + }, + 'meta': { + 'foo': [42] + } + })); + }); + test('one() return null when relationship does not exist', () { + expect(Resource('books', '1').one('author'), isNull); + }); + test('many() returns null when relationship does not exist', () { + expect(Resource('books', '1').many('tags'), isNull); + }); }); } diff --git a/test/unit/document/to_many_test.dart b/test/unit/document/to_many_test.dart deleted file mode 100644 index de2b3f8a..00000000 --- a/test/unit/document/to_many_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('custom links', () { - test('recognizes custom links', () { - final r = ToMany([], links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "related" and "self" keys', () { - final r = ToMany([], links: { - 'my-link': Link(Uri.parse('/my-link')), - 'related': Link(Uri.parse('/related')), - 'self': Link(Uri.parse('/self')) - }); - expect(r.links['my-link'].toString(), '/my-link'); - expect(r.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - expect(r.self.toString(), '/self'); - expect(r.related.toString(), '/related'); - }); - - test('custom "links" survives json serialization', () { - final r = ToMany([], links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ToMany.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/document/to_one_test.dart b/test/unit/document/to_one_test.dart deleted file mode 100644 index e9ac2bc6..00000000 --- a/test/unit/document/to_one_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:test/test.dart'; - -void main() { - group('custom links', () { - test('recognizes custom links', () { - final r = ToOne(null, links: {'my-link': Link(Uri.parse('/my-link'))}); - expect(r.links['my-link'].toString(), '/my-link'); - }); - - test('"links" may contain the "related" and "self" keys', () { - final r = ToOne(null, links: { - 'my-link': Link(Uri.parse('/my-link')), - 'related': Link(Uri.parse('/related')), - 'self': Link(Uri.parse('/self')) - }); - expect(r.links['my-link'].toString(), '/my-link'); - expect(r.links['self'].toString(), '/self'); - expect(r.links['related'].toString(), '/related'); - expect(r.self.toString(), '/self'); - expect(r.related.toString(), '/related'); - }); - - test('custom "links" survives json serialization', () { - final r = ToOne(null, links: { - 'my-link': Link(Uri.parse('/my-link')), - }); - expect( - ToOne.fromJson(json.decode(json.encode(r))) - .links['my-link'] - .toString(), - '/my-link'); - }); - }); -} diff --git a/test/unit/http/logging_http_handler_test.dart b/test/unit/http/logging_http_handler_test.dart deleted file mode 100644 index bdaeb2ee..00000000 --- a/test/unit/http/logging_http_handler_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_api/http.dart'; -import 'package:test/test.dart'; - -void main() { - test('Logging handler can log', () async { - final rq = HttpRequest('get', Uri.parse('http://localhost')); - final rs = HttpResponse(200, body: 'Hello'); - HttpRequest loggedRq; - HttpResponse loggedRs; - final logger = LoggingHttpHandler( - HttpHandler.fromFunction(((_) async => rs)), - onResponse: (_) => loggedRs = _, - onRequest: (_) => loggedRq = _); - await logger(rq); - expect(loggedRq, same(rq)); - expect(loggedRs, same(rs)); - }); -} diff --git a/test/unit/http/payload_codec_test.dart b/test/unit/http/payload_codec_test.dart new file mode 100644 index 00000000..ef1057ad --- /dev/null +++ b/test/unit/http/payload_codec_test.dart @@ -0,0 +1,8 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + test('Throws format exception if the payload is not a Map', () { + expect(() => PayloadCodec().decode('"oops"'), throwsFormatException); + }); +} diff --git a/test/unit/http/request_test.dart b/test/unit/http/request_test.dart new file mode 100644 index 00000000..96eaaffb --- /dev/null +++ b/test/unit/http/request_test.dart @@ -0,0 +1,30 @@ +import 'package:json_api/http.dart'; +import 'package:test/test.dart'; + +void main() { + group('HttpRequest', () { + final uri = Uri(); + final get = HttpRequest('get', uri); + final post = HttpRequest('post', uri); + final delete = HttpRequest('delete', uri); + final patch = HttpRequest('patch', uri); + final options = HttpRequest('options', uri); + final fail = HttpRequest('fail', uri); + test('getters', () { + expect(get.isGet, isTrue); + expect(post.isPost, isTrue); + expect(delete.isDelete, isTrue); + expect(patch.isPatch, isTrue); + expect(options.isOptions, isTrue); + + expect(fail.isGet, isFalse); + expect(fail.isPost, isFalse); + expect(fail.isDelete, isFalse); + expect(fail.isPatch, isFalse); + expect(fail.isOptions, isFalse); + }); + test('converts method to lowercase', () { + expect(HttpRequest('pAtCh', Uri()).method, 'patch'); + }); + }); +} diff --git a/test/unit/query/fields_test.dart b/test/unit/query/fields_test.dart index a5dfb041..2708c983 100644 --- a/test/unit/query/fields_test.dart +++ b/test/unit/query/fields_test.dart @@ -2,45 +2,58 @@ import 'package:json_api/src/query/fields.dart'; import 'package:test/test.dart'; void main() { - test('emptiness', () { - expect(Fields({}).isEmpty, isTrue); - expect(Fields({}).isNotEmpty, isFalse); + group('Fields', () { + test('emptiness', () { + expect(Fields().isEmpty, isTrue); + expect(Fields().isNotEmpty, isFalse); - expect( - Fields({ - 'foo': ['bar'] - }).isEmpty, - isFalse); - expect( - Fields({ - 'foo': ['bar'] - }).isNotEmpty, - isTrue); - }); - test('Can decode url without duplicate keys', () { - final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); - final fields = Fields.fromUri(uri); - expect(fields['articles'], ['title', 'body']); - expect(fields['people'], ['name']); - }); + expect( + Fields({ + 'foo': ['bar'] + }).isEmpty, + isFalse); + expect( + Fields({ + 'foo': ['bar'] + }).isNotEmpty, + isTrue); + }); - test('Can decode url with duplicate keys', () { - final uri = Uri.parse( - '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); - final fields = Fields.fromUri(uri); - expect(fields['articles'], ['title', 'body']); - expect(fields['people'], ['name', 'age']); - }); + test('add, remove, clear', () { + final f = Fields(); + f['foo'] = ['bar']; + f['bar'] = ['foo']; + expect(f['foo'], ['bar']); + expect(f['bar'], ['foo']); + f.remove('foo'); + expect(f['foo'], isNull); + f.clear(); + expect(f.isEmpty, isTrue); + }); + + test('can decode url without duplicate keys', () { + final uri = Uri.parse( + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); + final fields = Fields.fromUri(uri); + expect(fields['articles'], ['title', 'body']); + expect(fields['people'], ['name']); + }); - test('Can add to uri', () { - final fields = Fields({ - 'articles': ['title', 'body'], - 'people': ['name'] + test('can decode url with duplicate keys', () { + final uri = Uri.parse( + '/articles?include=author&fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name&fields%5Bpeople%5D=age'); + final fields = Fields.fromUri(uri); + expect(fields['articles'], ['title', 'body']); + expect(fields['people'], ['name', 'age']); }); - final uri = Uri.parse('/articles'); - expect(fields.addToUri(uri).toString(), - '/articles?fields%5Barticles%5D=title%2Cbody&fields%5Bpeople%5D=name'); + test('can convert to query parameters', () { + expect( + Fields({ + 'articles': ['title', 'body'], + 'people': ['name'] + }).asQueryParameters, + {'fields[articles]': 'title,body', 'fields[people]': 'name'}); + }); }); } diff --git a/test/unit/query/filter_test.dart b/test/unit/query/filter_test.dart index 3661dd00..5c8f0286 100644 --- a/test/unit/query/filter_test.dart +++ b/test/unit/query/filter_test.dart @@ -1,23 +1,37 @@ -import 'package:json_api/src/query/filter.dart'; +import 'package:json_api/query.dart'; import 'package:test/test.dart'; void main() { - test('Can decode url', () { - final uri = Uri.parse( - '/articles?include=author&filter%5Barticles%5D=title%2Cbody&filter%5Bpeople%5D=name'); - final filter = Filter.fromUri(uri); - expect(filter['articles'], ['title', 'body']); - expect(filter['people'], ['name']); - }); + group('Filter', () { + test('emptiness', () { + expect(Filter().isEmpty, isTrue); + expect(Filter().isNotEmpty, isFalse); + expect(Filter({'foo': 'bar'}).isEmpty, isFalse); + expect(Filter({'foo': 'bar'}).isNotEmpty, isTrue); + }); - test('Can add to uri', () { - final filter = Filter({ - 'articles': ['title', 'body'], - 'people': ['name'] + test('add, remove, clear', () { + final f = Filter(); + f['foo'] = 'bar'; + f['bar'] = 'foo'; + expect(f['foo'], 'bar'); + expect(f['bar'], 'foo'); + f.remove('foo'); + expect(f['foo'], isNull); + f.clear(); + expect(f.isEmpty, isTrue); }); - final uri = Uri.parse('/articles'); - expect(filter.addToUri(uri).toString(), - '/articles?filter%5Barticles%5D=title%2Cbody&filter%5Bpeople%5D=name'); + test('Can decode url', () { + final uri = Uri.parse('/articles?filter[post]=1,2&filter[author]=12'); + final filter = Filter.fromUri(uri); + expect(filter['post'], '1,2'); + expect(filter['author'], '12'); + }); + + test('Can convert to query parameters', () { + expect(Filter({'post': '1,2', 'author': '12'}).asQueryParameters, + {'filter[post]': '1,2', 'filter[author]': '12'}); + }); }); } diff --git a/test/unit/query/include_test.dart b/test/unit/query/include_test.dart index bac10ffa..ef91bcc9 100644 --- a/test/unit/query/include_test.dart +++ b/test/unit/query/include_test.dart @@ -3,10 +3,12 @@ import 'package:test/test.dart'; void main() { test('emptiness', () { - expect(Include([]).isEmpty, isTrue); - expect(Include([]).isNotEmpty, isFalse); + expect(Include().isEmpty, isTrue); + expect(Include().isNotEmpty, isFalse); + expect(Include().length, 0); expect(Include(['foo']).isEmpty, isFalse); expect(Include(['foo']).isNotEmpty, isTrue); + expect(Include(['foo']).length, 1); }); test('Can decode url without duplicate keys', () { @@ -22,10 +24,8 @@ void main() { expect(include, equals(['author', 'comments.author', 'tags'])); }); - test('Can add to uri', () { - final uri = Uri.parse('/articles/1'); - final include = Include(['author', 'comments.author']); - expect(include.addToUri(uri).toString(), - '/articles/1?include=author%2Ccomments.author'); + test('Can convert to query parameters', () { + expect(Include(['author', 'comments.author']).asQueryParameters, + {'include': 'author,comments.author'}); }); } diff --git a/test/unit/query/merge_test.dart b/test/unit/query/merge_test.dart deleted file mode 100644 index d62110da..00000000 --- a/test/unit/query/merge_test.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:json_api/query.dart'; -import 'package:test/test.dart'; - -void main() { - test('parameters can be merged', () { - final params = Fields({ - 'comments': ['author'] - }) & - Include(['author']) & - Page({'limit': '10'}); - expect(params.addToUri(Uri()).query, - 'fields%5Bcomments%5D=author&include=author&page%5Blimit%5D=10'); - }); -} diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart index d84c80bb..5b878e82 100644 --- a/test/unit/query/page_test.dart +++ b/test/unit/query/page_test.dart @@ -2,25 +2,36 @@ import 'package:json_api/query.dart'; import 'package:test/test.dart'; void main() { - test('emptiness', () { - expect(Page({}).isEmpty, isTrue); - expect(Page({}).isNotEmpty, isFalse); - expect(Page({'foo': 'bar'}).isEmpty, isFalse); - expect(Page({'foo': 'bar'}).isNotEmpty, isTrue); - }); + group('Page', () { + test('emptiness', () { + expect(Page().isEmpty, isTrue); + expect(Page().isNotEmpty, isFalse); + expect(Page({'foo': 'bar'}).isEmpty, isFalse); + expect(Page({'foo': 'bar'}).isNotEmpty, isTrue); + }); - test('Can decode url', () { - final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); - final page = Page.fromUri(uri); - expect(page['limit'], '10'); - expect(page['offset'], '20'); - }); + test('add, remove, clear', () { + final p = Page(); + p['foo'] = 'bar'; + p['bar'] = 'foo'; + expect(p['foo'], 'bar'); + expect(p['bar'], 'foo'); + p.remove('foo'); + expect(p['foo'], isNull); + p.clear(); + expect(p.isEmpty, isTrue); + }); - test('Can add to uri', () { - final fields = Page({'limit': '10', 'offset': '20'}); - final uri = Uri.parse('/articles'); + test('can decode url', () { + final uri = Uri.parse('/articles?page[limit]=10&page[offset]=20'); + final page = Page.fromUri(uri); + expect(page['limit'], '10'); + expect(page['offset'], '20'); + }); - expect(fields.addToUri(uri).toString(), - '/articles?page%5Blimit%5D=10&page%5Boffset%5D=20'); + test('can convert to query parameters', () { + expect(Page({'limit': '10', 'offset': '20'}).asQueryParameters, + {'page[limit]': '10', 'page[offset]': '20'}); + }); }); } diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart index 8075ff91..effa6e72 100644 --- a/test/unit/query/sort_test.dart +++ b/test/unit/query/sort_test.dart @@ -3,19 +3,17 @@ import 'package:test/test.dart'; void main() { test('emptiness', () { - expect(Sort([]).isEmpty, isTrue); - expect(Sort([]).isNotEmpty, isFalse); - expect(Sort([Desc('created')]).isEmpty, isFalse); - expect(Sort([Desc('created')]).isNotEmpty, isTrue); + expect(Sort().isEmpty, isTrue); + expect(Sort().isNotEmpty, isFalse); + expect(Sort(['-created']).isEmpty, isFalse); + expect(Sort(['-created']).isNotEmpty, isTrue); }); - test('Can decode url wthout duplicate keys', () { + test('Can decode url without duplicate keys', () { final uri = Uri.parse('/articles?sort=-created,title'); final sort = Sort.fromUri(uri); expect(sort.length, 2); - expect(sort.first.isDesc, true); expect(sort.first.name, 'created'); - expect(sort.last.isAsc, true); expect(sort.last.name, 'title'); }); @@ -23,15 +21,12 @@ void main() { final uri = Uri.parse('/articles?sort=-created&sort=title'); final sort = Sort.fromUri(uri); expect(sort.length, 2); - expect(sort.first.isDesc, true); expect(sort.first.name, 'created'); - expect(sort.last.isAsc, true); expect(sort.last.name, 'title'); }); - test('Can add to uri', () { - final sort = Sort([Desc('created'), Asc('title')]); - final uri = Uri.parse('/articles'); - expect(sort.addToUri(uri).toString(), '/articles?sort=-created%2Ctitle'); + test('Can convert to query parameters', () { + expect(Sort(['-created', 'title']).asQueryParameters, + {'sort': '-created,title'}); }); } diff --git a/test/unit/routing/standard_routing_test.dart b/test/unit/routing/standard_routing_test.dart deleted file mode 100644 index a7f278f8..00000000 --- a/test/unit/routing/standard_routing_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:json_api/routing.dart'; -import 'package:test/test.dart'; - -void main() { - test('URIs start with slashes when no base provided', () { - final r = StandardRouting(); - expect(r.collection('books').toString(), '/books'); - expect(r.resource('books', '42').toString(), '/books/42'); - expect(r.related('books', '42', 'author').toString(), '/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - '/books/42/relationships/author'); - }); - - test('Authority is retained if exists in base', () { - final r = StandardRouting(Uri.parse('https://example.com')); - expect(r.collection('books').toString(), 'https://example.com/books'); - expect( - r.resource('books', '42').toString(), 'https://example.com/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/books/42/relationships/author'); - }); - - test('Authority is retained if exists in base (non-directory path)', () { - final r = StandardRouting(Uri.parse('https://example.com/foo')); - expect(r.collection('books').toString(), 'https://example.com/books'); - expect( - r.resource('books', '42').toString(), 'https://example.com/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/books/42/relationships/author'); - }); - - test('Authority and path is retained if exists in base (directory path)', () { - final r = StandardRouting(Uri.parse('https://example.com/foo/')); - expect(r.collection('books').toString(), 'https://example.com/foo/books'); - expect(r.resource('books', '42').toString(), - 'https://example.com/foo/books/42'); - expect(r.related('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/author'); - expect(r.relationship('books', '42', 'author').toString(), - 'https://example.com/foo/books/42/relationships/author'); - }); -} diff --git a/test/unit/routing/url_test.dart b/test/unit/routing/url_test.dart new file mode 100644 index 00000000..e3781eb5 --- /dev/null +++ b/test/unit/routing/url_test.dart @@ -0,0 +1,35 @@ +import 'package:json_api/routing.dart'; +import 'package:test/test.dart'; + +void main() { + test('uri generation', () { + final url = StandardUriDesign.pathOnly; + expect(url.collection('books').toString(), '/books'); + expect(url.resource('books', '42').toString(), '/books/42'); + expect(url.related('books', '42', 'author').toString(), '/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), + '/books/42/relationships/author'); + }); + + test('Authority is retained if exists in base', () { + final url = StandardUriDesign(Uri.parse('https://example.com')); + expect(url.collection('books').toString(), 'https://example.com/books'); + expect( + url.resource('books', '42').toString(), 'https://example.com/books/42'); + expect(url.related('books', '42', 'author').toString(), + 'https://example.com/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), + 'https://example.com/books/42/relationships/author'); + }); + + test('Authority and path is retained if exists in base (directory path)', () { + final url = StandardUriDesign(Uri.parse('https://example.com/foo/')); + expect(url.collection('books').toString(), 'https://example.com/foo/books'); + expect(url.resource('books', '42').toString(), + 'https://example.com/foo/books/42'); + expect(url.related('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/author'); + expect(url.relationship('books', '42', 'author').toString(), + 'https://example.com/foo/books/42/relationships/author'); + }); +} diff --git a/test/unit/server/json_api_server_test.dart b/test/unit/server/json_api_server_test.dart deleted file mode 100644 index d1903794..00000000 --- a/test/unit/server/json_api_server_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:convert'; - -import 'package:json_api/document.dart'; -import 'package:json_api/http.dart'; -import 'package:json_api/routing.dart'; -import 'package:json_api/server.dart'; -import 'package:test/test.dart'; - -void main() { - final routing = StandardRouting(Uri.parse('http://example.com')); - final server = JsonApiServer(RepositoryController(InMemoryRepository({}))); - - group('JsonApiServer', () { - test('returns `bad request` on incomplete relationship', () async { - final rq = HttpRequest( - 'PATCH', routing.relationship('books', '1', 'author'), - body: '{}'); - final rs = await server(rq); - expect(rs.statusCode, 400); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad request'); - expect(error.detail, 'Incomplete relationship object'); - }); - - test('returns `bad request` when payload is not a valid JSON', () async { - final rq = - HttpRequest('POST', routing.collection('books'), body: '"ololo"abc'); - final rs = await server(rq); - expect(rs.statusCode, 400); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad request'); - expect(error.detail, startsWith('Invalid JSON. ')); - }); - - test('returns `bad request` when payload is not a valid JSON:API object', - () async { - final rq = - HttpRequest('POST', routing.collection('books'), body: '"oops"'); - final rs = await server(rq); - expect(rs.statusCode, 400); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad request'); - expect(error.detail, - "A JSON:API resource document must be a JSON object and contain the 'data' member"); - }); - - test('returns `bad request` when payload violates JSON:API', () async { - final rq = HttpRequest('POST', routing.collection('books'), - body: '{"data": {}}'); - final rs = await server(rq); - expect(rs.statusCode, 400); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '400'); - expect(error.title, 'Bad request'); - expect(error.detail, 'Invalid JSON:API resource object'); - }); - - test('returns `not found` if URI is not recognized', () async { - final rq = HttpRequest('GET', Uri.parse('http://localhost/a/b/c/d/e')); - final rs = await server(rq); - expect(rs.statusCode, 404); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '404'); - expect(error.title, 'Not Found'); - expect(error.detail, 'The requested URL does exist on the server'); - }); - - test('returns `method not allowed` for resource collection', () async { - final rq = HttpRequest('DELETE', routing.collection('books')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET, POST'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: GET, POST'); - }); - - test('returns `method not allowed` for resource', () async { - final rq = HttpRequest('POST', routing.resource('books', '1')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: DELETE, GET, PATCH'); - }); - - test('returns `method not allowed` for related', () async { - final rq = HttpRequest('POST', routing.related('books', '1', 'author')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'GET'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: GET'); - }); - - test('returns `method not allowed` for relationship', () async { - final rq = - HttpRequest('PUT', routing.relationship('books', '1', 'author')); - final rs = await server(rq); - expect(rs.statusCode, 405); - expect(rs.headers['allow'], 'DELETE, GET, PATCH, POST'); - final error = Document.fromJson(json.decode(rs.body), null).errors.first; - expect(error.status, '405'); - expect(error.title, 'Method Not Allowed'); - expect(error.detail, 'Allowed methods: DELETE, GET, PATCH, POST'); - }); - }); -} diff --git a/test/unit/server/numbered_page_test.dart b/test/unit/server/numbered_page_test.dart deleted file mode 100644 index 1da56662..00000000 --- a/test/unit/server/numbered_page_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:json_api/src/query/page.dart'; -import 'package:json_api/src/server/pagination.dart'; -import 'package:test/test.dart'; - -void main() { - test('page size must be posititve', () { - expect(() => FixedSizePage(0), throwsArgumentError); - }); - - test('no pages after last', () { - final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); - expect(pagination.next(page, 10), isNull); - }); - - test('no pages before first', () { - final page = Page({'number': '1'}); - final pagination = FixedSizePage(3); - expect(pagination.prev(page), isNull); - }); - - test('pagination', () { - final page = Page({'number': '4'}); - final pagination = FixedSizePage(3); - expect(pagination.prev(page)['number'], '3'); - expect(pagination.next(page, 100)['number'], '5'); - expect(pagination.first()['number'], '1'); - expect(pagination.last(100)['number'], '34'); - }); -}