From 903bb2b39a0c3d2e4ed80b4cc89b7ee0e73eef2d Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Sat, 17 Apr 2021 11:21:04 -0700
Subject: [PATCH 01/25] v5 (#109)

---
 .cider.yaml                                   |   2 -
 .github/workflows/dart.yml                    |  20 +-
 .gitignore                                    |   6 +-
 .travis.yml                                   |  11 -
 CHANGELOG.md                                  |  73 +-
 LICENSE                                       |   2 +-
 README.md                                     | 140 +---
 doc/schema.json                               |  60 +-
 example/README.md                             |  30 -
 example/client.dart                           |  56 +-
 example/server.dart                           |  98 ++-
 example/server/cors_handler.dart              |  31 +
 example/server/demo_handler.dart              |  53 ++
 example/server/in_memory_repo.dart            |  74 ++
 example/server/json_api_server.dart           |  58 ++
 example/server/relationship_node.dart         |  26 +
 example/server/repository.dart                | 121 +++
 example/server/repository_controller.dart     | 205 +++++
 example/server/try_catch_handler.dart         |  22 +
 lib/client.dart                               |  15 +-
 lib/document.dart                             |  17 +-
 lib/http.dart                                 |   9 +-
 lib/query.dart                                |   2 +-
 lib/routing.dart                              |  13 +-
 lib/server.dart                               |  18 +-
 lib/src/client/client.dart                    |  53 ++
 lib/src/client/dart_http.dart                 |  57 --
 lib/src/client/disposable_handler.dart        |  19 +
 lib/src/client/json_api_client.dart           | 205 -----
 lib/src/client/persistent_handler.dart        |  22 +
 lib/src/client/request.dart                   |  53 ++
 lib/src/client/response.dart                  |  70 +-
 .../client/response/collection_fetched.dart   |  26 +
 .../response/related_resource_fetched.dart    |  29 +
 .../client/response/relationship_fetched.dart |  19 +
 .../client/response/relationship_updated.dart |  20 +
 lib/src/client/response/request_failure.dart  |  24 +
 lib/src/client/response/resource_created.dart |  23 +
 lib/src/client/response/resource_fetched.dart |  22 +
 lib/src/client/response/resource_updated.dart |  20 +
 lib/src/client/routing_client.dart            | 426 +++++++---
 lib/src/client/status_code.dart               |  17 -
 lib/src/document/api.dart                     |  30 -
 lib/src/document/document.dart                |  87 --
 lib/src/document/document_exception.dart      |   7 -
 lib/src/document/error_object.dart            | 109 +--
 lib/src/document/error_source.dart            |  19 +
 lib/src/document/identifier.dart              |  43 +-
 lib/src/document/identifier_object.dart       |  44 --
 lib/src/document/identity.dart                |   9 +
 lib/src/document/inbound_document.dart        | 152 ++++
 lib/src/document/json_encodable.dart          |   4 -
 lib/src/document/link.dart                    |  54 +-
 lib/src/document/many.dart                    |  26 +
 lib/src/document/new_resource.dart            |  22 +
 lib/src/document/one.dart                     |  23 +
 lib/src/document/outbound_document.dart       |  68 ++
 lib/src/document/primary_data.dart            |  33 -
 lib/src/document/relationship.dart            | 141 +---
 lib/src/document/resource.dart                |  66 +-
 lib/src/document/resource_collection.dart     |  21 +
 .../document/resource_collection_data.dart    |  56 --
 lib/src/document/resource_data.dart           |  44 --
 lib/src/document/resource_object.dart         | 109 ---
 lib/src/document/resource_properties.dart     |  30 +
 lib/src/http/http_handler.dart                |  19 +-
 lib/src/http/http_headers.dart                |   8 +
 lib/src/http/http_message.dart                |   9 +
 lib/src/http/http_request.dart                |  27 +-
 lib/src/http/http_response.dart               |  15 +-
 lib/src/http/logging_handler.dart             |  20 +
 lib/src/http/logging_http_handler.dart        |  25 -
 lib/src/http/media_type.dart                  |   3 +
 lib/src/http/payload_codec.dart               |  14 +
 lib/src/http/status_code.dart                 |  23 +
 lib/src/nullable.dart                         |   5 +-
 lib/src/query/fields.dart                     |  51 +-
 lib/src/query/filter.dart                     |  50 +-
 lib/src/query/include.dart                    |  32 +-
 lib/src/query/page.dart                       |  39 +-
 lib/src/query/query_parameters.dart           |  24 -
 lib/src/query/sort.dart                       |  76 +-
 lib/src/routing/composite_routing.dart        |  46 --
 lib/src/routing/route_factory.dart            |  18 -
 lib/src/routing/route_matcher.dart            |  26 -
 lib/src/routing/routes.dart                   |  39 -
 lib/src/routing/standard_routes.dart          | 104 ---
 lib/src/routing/standard_routing.dart         |   9 -
 lib/src/routing/standard_uri_design.dart      |  61 ++
 lib/src/routing/target.dart                   |  35 +
 lib/src/routing/uri_design.dart               |   9 +
 lib/src/server/controller.dart                |  99 +--
 lib/src/server/dart_server.dart               |  25 -
 lib/src/server/document_factory.dart          |  83 --
 lib/src/server/errors/method_not_allowed.dart |   5 +
 lib/src/server/errors/unmatched_target.dart   |   5 +
 lib/src/server/http_response_converter.dart   |  77 --
 lib/src/server/in_memory_repository.dart      | 107 ---
 lib/src/server/json_api_request.dart          | 201 -----
 lib/src/server/json_api_response.dart         | 215 -----
 lib/src/server/json_api_server.dart           |  71 --
 lib/src/server/links/links_factory.dart       |  22 -
 lib/src/server/links/no_links.dart            |  23 -
 lib/src/server/links/standard_links.dart      |  52 --
 lib/src/server/pagination.dart                |  87 --
 lib/src/server/relationship_target.dart       |  11 -
 lib/src/server/repository.dart                |  96 ---
 lib/src/server/repository_controller.dart     | 251 ------
 lib/src/server/request_converter.dart         | 116 ---
 lib/src/server/resource_target.dart           |  12 -
 lib/src/server/response.dart                  |  37 +
 lib/src/server/response_converter.dart        |  81 --
 lib/src/server/router.dart                    |  60 ++
 pubspec.yaml                                  |  28 +-
 test/contract/crud_test.dart                  | 174 ++++
 test/contract/errors_test.dart                |  38 +
 test/contract/resource_creation_test.dart     |  36 +
 test/e2e/browser_test.dart                    |  20 +
 test/e2e/client_server_interaction_test.dart  |  58 --
 test/e2e/e2e_test_set.dart                    |  34 +
 test/e2e/hybrid_server.dart                   |  12 +
 test/e2e/vm_test.dart                         |  27 +
 test/functional/compound_document_test.dart   | 168 ----
 .../crud/creating_resources_test.dart         | 155 ----
 .../crud/deleting_resources_test.dart         |  64 --
 .../crud/fetching_relationships_test.dart     | 166 ----
 .../crud/fetching_resources_test.dart         | 159 ----
 test/functional/crud/seed_resources.dart      |  23 -
 .../crud/updating_relationships_test.dart     | 255 ------
 .../crud/updating_resources_test.dart         |  88 ---
 test/helper/expect_resources_equal.dart       |  10 -
 test/helper/test_http_handler.dart            |  12 -
 test/performance/encode_decode.dart           |  92 ---
 test/unit/client/async_processing_test.dart   |  28 -
 test/unit/client/client_test.dart             | 740 ++++++++++++++++++
 test/unit/client/conrner_cases_test.dart      |  42 -
 .../creating_resource_with_meta_test.dart     |  25 -
 test/unit/client/dart_http_test.dart          |  52 --
 test/unit/client/mock_handler.dart            |  12 +
 test/unit/client/response.dart                | 255 ++++++
 test/unit/document/api_test.dart              |  23 -
 test/unit/document/document_test.dart         |   9 -
 test/unit/document/error_object_test.dart     |  39 +
 .../unit/document/identifier_object_test.dart |  10 -
 test/unit/document/identifier_test.dart       |   8 -
 test/unit/document/inbound_document_test.dart | 185 +++++
 test/unit/document/json_api_error_test.dart   |  48 --
 test/unit/document/link_test.dart             |  45 +-
 test/unit/document/meta_members_test.dart     |  99 ---
 test/unit/document/new_resource_test.dart     |  51 ++
 .../unit/document/outbound_document_test.dart | 133 ++++
 test/unit/document/payload.dart               | 140 ++++
 test/unit/document/relationship_test.dart     |  60 +-
 .../resource_collection_data_test.dart        |  43 -
 test/unit/document/resource_data_test.dart    | 108 ---
 test/unit/document/resource_object_test.dart  |  64 --
 test/unit/document/resource_test.dart         |  67 +-
 test/unit/document/to_many_test.dart          |  37 -
 test/unit/document/to_one_test.dart           |  37 -
 test/unit/http/logging_http_handler_test.dart |  18 -
 test/unit/http/payload_codec_test.dart        |   8 +
 test/unit/http/request_test.dart              |  30 +
 test/unit/query/fields_test.dart              |  83 +-
 test/unit/query/filter_test.dart              |  44 +-
 test/unit/query/include_test.dart             |  14 +-
 test/unit/query/merge_test.dart               |  14 -
 test/unit/query/page_test.dart                |  45 +-
 test/unit/query/sort_test.dart                |  21 +-
 test/unit/routing/standard_routing_test.dart  |  46 --
 test/unit/routing/url_test.dart               |  35 +
 test/unit/server/json_api_server_test.dart    | 116 ---
 test/unit/server/numbered_page_test.dart      |  30 -
 172 files changed, 4606 insertions(+), 5864 deletions(-)
 delete mode 100644 .cider.yaml
 delete mode 100644 .travis.yml
 delete mode 100644 example/README.md
 create mode 100644 example/server/cors_handler.dart
 create mode 100644 example/server/demo_handler.dart
 create mode 100644 example/server/in_memory_repo.dart
 create mode 100644 example/server/json_api_server.dart
 create mode 100644 example/server/relationship_node.dart
 create mode 100644 example/server/repository.dart
 create mode 100644 example/server/repository_controller.dart
 create mode 100644 example/server/try_catch_handler.dart
 create mode 100644 lib/src/client/client.dart
 delete mode 100644 lib/src/client/dart_http.dart
 create mode 100644 lib/src/client/disposable_handler.dart
 delete mode 100644 lib/src/client/json_api_client.dart
 create mode 100644 lib/src/client/persistent_handler.dart
 create mode 100644 lib/src/client/request.dart
 create mode 100644 lib/src/client/response/collection_fetched.dart
 create mode 100644 lib/src/client/response/related_resource_fetched.dart
 create mode 100644 lib/src/client/response/relationship_fetched.dart
 create mode 100644 lib/src/client/response/relationship_updated.dart
 create mode 100644 lib/src/client/response/request_failure.dart
 create mode 100644 lib/src/client/response/resource_created.dart
 create mode 100644 lib/src/client/response/resource_fetched.dart
 create mode 100644 lib/src/client/response/resource_updated.dart
 delete mode 100644 lib/src/client/status_code.dart
 delete mode 100644 lib/src/document/api.dart
 delete mode 100644 lib/src/document/document.dart
 delete mode 100644 lib/src/document/document_exception.dart
 create mode 100644 lib/src/document/error_source.dart
 delete mode 100644 lib/src/document/identifier_object.dart
 create mode 100644 lib/src/document/identity.dart
 create mode 100644 lib/src/document/inbound_document.dart
 delete mode 100644 lib/src/document/json_encodable.dart
 create mode 100644 lib/src/document/many.dart
 create mode 100644 lib/src/document/new_resource.dart
 create mode 100644 lib/src/document/one.dart
 create mode 100644 lib/src/document/outbound_document.dart
 delete mode 100644 lib/src/document/primary_data.dart
 create mode 100644 lib/src/document/resource_collection.dart
 delete mode 100644 lib/src/document/resource_collection_data.dart
 delete mode 100644 lib/src/document/resource_data.dart
 delete mode 100644 lib/src/document/resource_object.dart
 create mode 100644 lib/src/document/resource_properties.dart
 create mode 100644 lib/src/http/http_headers.dart
 create mode 100644 lib/src/http/http_message.dart
 create mode 100644 lib/src/http/logging_handler.dart
 delete mode 100644 lib/src/http/logging_http_handler.dart
 create mode 100644 lib/src/http/media_type.dart
 create mode 100644 lib/src/http/payload_codec.dart
 create mode 100644 lib/src/http/status_code.dart
 delete mode 100644 lib/src/query/query_parameters.dart
 delete mode 100644 lib/src/routing/composite_routing.dart
 delete mode 100644 lib/src/routing/route_factory.dart
 delete mode 100644 lib/src/routing/route_matcher.dart
 delete mode 100644 lib/src/routing/routes.dart
 delete mode 100644 lib/src/routing/standard_routes.dart
 delete mode 100644 lib/src/routing/standard_routing.dart
 create mode 100644 lib/src/routing/standard_uri_design.dart
 create mode 100644 lib/src/routing/target.dart
 create mode 100644 lib/src/routing/uri_design.dart
 delete mode 100644 lib/src/server/dart_server.dart
 delete mode 100644 lib/src/server/document_factory.dart
 create mode 100644 lib/src/server/errors/method_not_allowed.dart
 create mode 100644 lib/src/server/errors/unmatched_target.dart
 delete mode 100644 lib/src/server/http_response_converter.dart
 delete mode 100644 lib/src/server/in_memory_repository.dart
 delete mode 100644 lib/src/server/json_api_request.dart
 delete mode 100644 lib/src/server/json_api_response.dart
 delete mode 100644 lib/src/server/json_api_server.dart
 delete mode 100644 lib/src/server/links/links_factory.dart
 delete mode 100644 lib/src/server/links/no_links.dart
 delete mode 100644 lib/src/server/links/standard_links.dart
 delete mode 100644 lib/src/server/pagination.dart
 delete mode 100644 lib/src/server/relationship_target.dart
 delete mode 100644 lib/src/server/repository.dart
 delete mode 100644 lib/src/server/repository_controller.dart
 delete mode 100644 lib/src/server/request_converter.dart
 delete mode 100644 lib/src/server/resource_target.dart
 create mode 100644 lib/src/server/response.dart
 delete mode 100644 lib/src/server/response_converter.dart
 create mode 100644 lib/src/server/router.dart
 create mode 100644 test/contract/crud_test.dart
 create mode 100644 test/contract/errors_test.dart
 create mode 100644 test/contract/resource_creation_test.dart
 create mode 100644 test/e2e/browser_test.dart
 delete mode 100644 test/e2e/client_server_interaction_test.dart
 create mode 100644 test/e2e/e2e_test_set.dart
 create mode 100644 test/e2e/hybrid_server.dart
 create mode 100644 test/e2e/vm_test.dart
 delete mode 100644 test/functional/compound_document_test.dart
 delete mode 100644 test/functional/crud/creating_resources_test.dart
 delete mode 100644 test/functional/crud/deleting_resources_test.dart
 delete mode 100644 test/functional/crud/fetching_relationships_test.dart
 delete mode 100644 test/functional/crud/fetching_resources_test.dart
 delete mode 100644 test/functional/crud/seed_resources.dart
 delete mode 100644 test/functional/crud/updating_relationships_test.dart
 delete mode 100644 test/functional/crud/updating_resources_test.dart
 delete mode 100644 test/helper/expect_resources_equal.dart
 delete mode 100644 test/helper/test_http_handler.dart
 delete mode 100644 test/performance/encode_decode.dart
 delete mode 100644 test/unit/client/async_processing_test.dart
 create mode 100644 test/unit/client/client_test.dart
 delete mode 100644 test/unit/client/conrner_cases_test.dart
 delete mode 100644 test/unit/client/creating_resource_with_meta_test.dart
 delete mode 100644 test/unit/client/dart_http_test.dart
 create mode 100644 test/unit/client/mock_handler.dart
 create mode 100644 test/unit/client/response.dart
 delete mode 100644 test/unit/document/api_test.dart
 delete mode 100644 test/unit/document/document_test.dart
 create mode 100644 test/unit/document/error_object_test.dart
 delete mode 100644 test/unit/document/identifier_object_test.dart
 delete mode 100644 test/unit/document/identifier_test.dart
 create mode 100644 test/unit/document/inbound_document_test.dart
 delete mode 100644 test/unit/document/json_api_error_test.dart
 delete mode 100644 test/unit/document/meta_members_test.dart
 create mode 100644 test/unit/document/new_resource_test.dart
 create mode 100644 test/unit/document/outbound_document_test.dart
 create mode 100644 test/unit/document/payload.dart
 delete mode 100644 test/unit/document/resource_collection_data_test.dart
 delete mode 100644 test/unit/document/resource_data_test.dart
 delete mode 100644 test/unit/document/resource_object_test.dart
 delete mode 100644 test/unit/document/to_many_test.dart
 delete mode 100644 test/unit/document/to_one_test.dart
 delete mode 100644 test/unit/http/logging_http_handler_test.dart
 create mode 100644 test/unit/http/payload_codec_test.dart
 create mode 100644 test/unit/http/request_test.dart
 delete mode 100644 test/unit/query/merge_test.dart
 delete mode 100644 test/unit/routing/standard_routing_test.dart
 create mode 100644 test/unit/routing/url_test.dart
 delete mode 100644 test/unit/server/json_api_server_test.dart
 delete mode 100644 test/unit/server/numbered_page_test.dart

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<void> 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<HttpResponse> 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<HttpResponse> 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<String> 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<HttpResponse> _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<String> types) {
+    types.forEach((_) {
+      _storage[_] = {};
+    });
+  }
+
+  final _storage = <String, Map<String, Model>>{};
+
+  @override
+  Stream<Model> fetchCollection(String type) {
+    return Stream.fromIterable(_collection(type).values);
+  }
+
+  @override
+  Future<Model> fetch(String type, String id) async {
+    return _model(type, id);
+  }
+
+  @override
+  Future<void> persist(String type, Model model) async {
+    _collection(type)[model.id] = model;
+  }
+
+  @override
+  Stream<Identity> addMany(
+      String type, String id, String rel, Iterable<Identity> ids) {
+    final many = _many(type, id, rel);
+    many.addAll(ids.map(Ref.of));
+    return Stream.fromIterable(many);
+  }
+
+  @override
+  Future<void> delete(String type, String id) async {
+    _collection(type).remove(id);
+  }
+
+  @override
+  Future<void> update(String type, String id, ModelProps props) async {
+    _model(type, id).setFrom(props);
+  }
+
+  @override
+  Future<void> replaceOne(
+      String type, String id, String rel, Identity? one) async {
+    _model(type, id).one[rel] = nullable(Ref.of)(one);
+  }
+
+  @override
+  Stream<Identity> deleteMany(
+          String type, String id, String rel, Iterable<Identity> many) =>
+      Stream.fromIterable(_many(type, id, rel)..removeAll(many.map(Ref.of)));
+
+  @override
+  Stream<Identity> replaceMany(
+          String type, String id, String rel, Iterable<Identity> many) =>
+      Stream.fromIterable(_many(type, id, rel)
+        ..clear()
+        ..addAll(many.map(Ref.of)));
+
+  Map<String, Model> _collection(String type) =>
+      (_storage[type] ?? (throw CollectionNotFound()));
+
+  Model _model(String type, String id) =>
+      _collection(type)[id] ?? (throw ResourceNotFound());
+
+  Set<Ref> _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<void> start() async {
+    if (_server != null) return;
+    try {
+      _server = await _createServer();
+    } on Exception {
+      await stop();
+      rethrow;
+    }
+  }
+
+  /// Stops the server
+  Future<void> stop({bool force = false}) async {
+    await _server?.close(force: force);
+    _server = null;
+  }
+
+  Future<HttpServer> _createServer() async {
+    final server = await HttpServer.bind(host, port);
+    server.listen((request) async {
+      final headers = <String, String>{};
+      request.headers.forEach((k, v) => headers[k] = v.join(','));
+      final response = await _handler.handle(HttpRequest(
+          request.method, request.requestedUri,
+          body: await request.cast<List<int>>().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<RelationshipNode> forest(Iterable<String> 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<RelationshipNode> get children => _map.values;
+
+  final _map = <String, RelationshipNode>{};
+
+  /// Adds the chain to the tree
+  void add(Iterable<String> 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<Model> fetchCollection(String type);
+
+  /// Throws [ResourceNotFound]
+  Future<Model> fetch(String type, String id);
+
+  /// Throws [CollectionNotFound].
+  Future<void> persist(String type, Model model);
+
+  /// Add refs to a to-many relationship
+  /// Throws [CollectionNotFound].
+  /// Throws [ResourceNotFound].
+  /// Throws [RelationshipNotFound].
+  Stream<Identity> addMany(
+      String type, String id, String rel, Iterable<Identity> refs);
+
+  /// Delete the resource
+  Future<void> delete(String type, String id);
+
+  /// Updates the model
+  Future<void> update(String type, String id, ModelProps props);
+
+  Future<void> replaceOne(String type, String id, String rel, Identity? ref);
+
+  /// Deletes refs from the to-many relationship.
+  /// Returns the new actual refs.
+  Stream<Identity> deleteMany(
+      String type, String id, String rel, Iterable<Identity> refs);
+
+  /// Replaces refs in the to-many relationship.
+  /// Returns the new actual refs.
+  Stream<Identity> replaceMany(
+      String type, String id, String rel, Iterable<Identity> 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 = <String, Object?>{};
+  final one = <String, Ref?>{};
+  final many = <String, Set<Ref>>{};
+
+  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<Response> 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<Response> 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<Response> 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<Response> addMany(
+      HttpRequest request, RelationshipTarget target) async {
+    final many = (await _decode(request)).asRelationship<ToMany>();
+    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<Response> deleteResource(
+      HttpRequest request, ResourceTarget target) async {
+    await repo.delete(target.type, target.id);
+    return Response.noContent();
+  }
+
+  @override
+  Future<Response> updateResource(
+      HttpRequest request, ResourceTarget target) async {
+    await repo.update(target.type, target.id,
+        ModelProps.fromResource((await _decode(request)).dataAsResource()));
+    return Response.noContent();
+  }
+
+  @override
+  Future<Response> 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<Response> 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<Response> 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<Response> 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<Resource> _getAllRelated(
+      Resource resource, Iterable<RelationshipNode> 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<Resource> _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<Resource> _fetchLinkedResource(String type, String id) async {
+    return (await _fetchResource(type, id))
+      ..links['self'] = Link(design.resource(type, id));
+  }
+
+  Stream<Resource> _fetchAll(String type) =>
+      repo.fetchCollection(type).map((_) => _.toResource(type));
+
+  /// Fetches and builds a resource object
+  Future<Resource> _fetchResource(String type, String id) async {
+    return (await repo.fetch(type, id)).toResource(type);
+  }
+
+  Future<Resource?> _fetchRelatedResource(Ref ref) {
+    return _fetchLinkedResource(ref.type, ref.id);
+  }
+
+  Stream<Resource> _fetchRelatedCollection(Iterable<Ref> refs) async* {
+    for (final ref in refs) {
+      final r = await _fetchRelatedResource(ref);
+      if (r != null) yield r;
+    }
+  }
+
+  Future<InboundDocument> _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<HttpResponse> Function(dynamic error) onError;
+
+  static Future<HttpResponse> sendInternalServerError(dynamic e) async =>
+      HttpResponse(500);
+
+  @override
+  Future<HttpResponse> 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<Response> 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<String> _encode(Object? doc) async =>
+      doc == null ? '' : await _codec.encode(doc);
+
+  Future<Map?> _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<HttpResponse> 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<Response> _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<String, String> 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<String, String> 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<HttpResponse> 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<Response<ResourceCollectionData>> fetchCollectionAt(Uri uri,
-          {Map<String, String> 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<Response<ResourceData>> fetchResourceAt(Uri uri,
-          {Map<String, String> 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<Response<ToOne>> fetchToOneAt(Uri uri,
-          {Map<String, String> 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<Response<ToMany>> fetchToManyAt(Uri uri,
-          {Map<String, String> 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<Response<Relationship>> fetchRelationshipAt(Uri uri,
-          {Map<String, String> 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<Response<ResourceData>> createResourceAt(Uri uri, Resource resource,
-          {Map<String, String> headers, Map<String, Object> meta}) =>
-      _call(_post(uri, headers, _resourceDoc(resource, meta: meta)),
-          ResourceData.fromJson);
-
-  /// Deletes the resource.
-  ///
-  /// https://jsonapi.org/format/#crud-deleting
-  Future<Response> deleteResourceAt(Uri uri, {Map<String, String> headers}) =>
-      _call(_delete(uri, headers), null);
-
-  /// Updates the resource via PATCH query.
-  ///
-  /// https://jsonapi.org/format/#crud-updating
-  Future<Response<ResourceData>> updateResourceAt(Uri uri, Resource resource,
-          {Map<String, String> 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<Response<ToOne>> replaceToOneAt(Uri uri, Identifier identifier,
-          {Map<String, String> headers}) =>
-      _call(_patch(uri, headers, _toOneDoc(identifier)), ToOne.fromJson);
-
-  /// Removes a to-one relationship. This is equivalent to calling [replaceToOneAt]
-  /// with id = null.
-  Future<Response<ToOne>> deleteToOneAt(Uri uri,
-          {Map<String, String> headers}) =>
-      replaceToOneAt(uri, null, headers: headers);
-
-  /// Removes the [identifiers] from the to-many relationship.
-  ///
-  /// https://jsonapi.org/format/#crud-updating-to-many-relationships
-  Future<Response<ToMany>> deleteFromToManyAt(
-          Uri uri, Iterable<Identifier> identifiers,
-          {Map<String, String> 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<Response<ToMany>> replaceToManyAt(
-          Uri uri, Iterable<Identifier> identifiers,
-          {Map<String, String> 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<Response<ToMany>> addToRelationshipAt(
-          Uri uri, Iterable<Identifier> identifiers,
-          {Map<String, String> headers}) =>
-      _call(_post(uri, headers, _toManyDoc(identifiers)), ToMany.fromJson);
-
-  final _api = Api(version: '1.0');
-
-  Document<ResourceData> _resourceDoc(Resource resource,
-          {Map<String, Object> meta}) =>
-      Document(ResourceData.fromResource(resource), meta: meta, api: _api);
-
-  Document<ToMany> _toManyDoc(Iterable<Identifier> identifiers) =>
-      Document(ToMany.fromIdentifiers(identifiers), api: _api);
-
-  Document<ToOne> _toOneDoc(Identifier identifier) =>
-      Document(ToOne.fromIdentifier(identifier), api: _api);
-
-  HttpRequest _get(Uri uri, Map<String, String> headers,
-          QueryParameters queryParameters) =>
-      HttpRequest('GET', (queryParameters ?? QueryParameters({})).addToUri(uri),
-          headers: {
-            ...headers ?? {},
-            'Accept': Document.contentType,
-          });
-
-  HttpRequest _post(Uri uri, Map<String, String> headers, Document doc) =>
-      HttpRequest('POST', uri,
-          headers: {
-            ...headers ?? {},
-            'Accept': Document.contentType,
-            'Content-Type': Document.contentType,
-          },
-          body: jsonEncode(doc));
-
-  HttpRequest _delete(Uri uri, Map<String, String> headers) =>
-      HttpRequest('DELETE', uri, headers: {
-        ...headers ?? {},
-        'Accept': Document.contentType,
-      });
-
-  HttpRequest _deleteWithBody(
-          Uri uri, Map<String, String> headers, Document doc) =>
-      HttpRequest('DELETE', uri,
-          headers: {
-            ...headers ?? {},
-            'Accept': Document.contentType,
-            'Content-Type': Document.contentType,
-          },
-          body: jsonEncode(doc));
-
-  HttpRequest _patch(uri, Map<String, String> headers, Document doc) =>
-      HttpRequest('PATCH', uri,
-          headers: {
-            ...headers ?? {},
-            'Accept': Document.contentType,
-            'Content-Type': Document.contentType,
-          },
-          body: jsonEncode(doc));
-
-  Future<Response<D>> _call<D extends PrimaryData>(
-      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<HttpResponse> 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 = <String, String>{};
+
+  /// Requests inclusion of related resources.
+  /// See https://jsonapi.org/format/#fetching-includes
+  void include(Iterable<String> include) {
+    query.addAll(Include(include).asQueryParameters);
+  }
+
+  /// Sets sorting parameters.
+  /// See https://jsonapi.org/format/#fetching-sorting
+  void sort(Iterable<String> sort) {
+    query.addAll(Sort(sort).asQueryParameters);
+  }
+
+  /// Requests sparse fieldsets.
+  /// See https://jsonapi.org/format/#fetching-sparse-fieldsets
+  void fields(Map<String, Iterable<String>> fields) {
+    query.addAll(Fields(fields).asQueryParameters);
+  }
+
+  /// Sets pagination parameters.
+  /// See https://jsonapi.org/format/#fetching-pagination
+  void page(Map<String, String> page) {
+    query.addAll(Page(page).asQueryParameters);
+  }
+
+  /// Response filtering.
+  /// https://jsonapi.org/format/#fetching-filtering
+  void filter(Map<String, String> 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<Data extends PrimaryData> {
-  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<Data> document;
-
-  /// The document received with `202 Accepted` response (if any)
-  /// https://jsonapi.org/recommendations/#asynchronous-processing
-  final Document<ResourceData> asyncDocument;
-
-  /// Headers returned by the server.
-  final Map<String, String> 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<ErrorObject> 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 = <String, Object?>{};
+
+  /// Top-level links object
+  final links = <String, Link>{};
+}
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 = <String, Object?>{};
+
+  /// Top-level links object
+  final links = <String, Link>{};
+}
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<R extends Relationship> {
+  RelationshipFetched(this.http, this.relationship);
+
+  static RelationshipFetched<ToMany> many(HttpResponse http, Map json) =>
+      RelationshipFetched(http, InboundDocument(json).asToMany())
+        ..included.addAll(InboundDocument(json).included());
+
+  static RelationshipFetched<ToOne> 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<R extends Relationship> {
+  RelationshipUpdated(this.http, this.relationship);
+
+  static RelationshipUpdated<ToMany> many(HttpResponse http, Map? json) =>
+      RelationshipUpdated(
+          http, json == null ? null : InboundDocument(json).asToMany());
+
+  static RelationshipUpdated<ToOne> 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 = <ErrorObject>[];
+
+  /// Top-level meta data
+  final meta = <String, Object?>{};
+
+  @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 = <String, Link>{};
+
+  /// 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 = <String, Object?>{};
+
+  /// Top-level links object
+  final links = <String, Link>{};
+}
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<Response<ResourceCollectionData>> fetchCollection(String type,
-          {Map<String, String> 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<Response<ResourceCollectionData>> fetchRelatedCollection(
-          String type, String id, String relationship,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchCollectionAt(_routes.related(type, id, relationship),
-          headers: headers, parameters: parameters);
-
-  /// Fetches a primary resource by [type] and [id].
-  Future<Response<ResourceData>> fetchResource(String type, String id,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchResourceAt(_routes.resource(type, id),
-          headers: headers, parameters: parameters);
-
-  /// Fetches a related resource by [type], [id], [relationship].
-  Future<Response<ResourceData>> fetchRelatedResource(
-          String type, String id, String relationship,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchResourceAt(_routes.related(type, id, relationship),
-          headers: headers, parameters: parameters);
-
-  /// Fetches a to-one relationship by [type], [id], [relationship].
-  Future<Response<ToOne>> fetchToOne(
-          String type, String id, String relationship,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchToOneAt(_routes.relationship(type, id, relationship),
-          headers: headers, parameters: parameters);
-
-  /// Fetches a to-many relationship by [type], [id], [relationship].
-  Future<Response<ToMany>> fetchToMany(
-          String type, String id, String relationship,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchToManyAt(_routes.relationship(type, id, relationship),
-          headers: headers, parameters: parameters);
-
-  /// Fetches a [relationship] of [type] : [id].
-  Future<Response<Relationship>> fetchRelationship(
-          String type, String id, String relationship,
-          {Map<String, String> headers, QueryParameters parameters}) =>
-      _client.fetchRelationshipAt(_routes.relationship(type, id, relationship),
-          headers: headers, parameters: parameters);
-
-  /// Creates the [resource] on the server.
-  Future<Response<ResourceData>> createResource(Resource resource,
-          {Map<String, String> headers}) =>
-      _client.createResourceAt(_routes.collection(resource.type), resource,
-          headers: headers);
-
-  /// Deletes the resource by [type] and [id].
-  Future<Response> deleteResource(String type, String id,
-          {Map<String, String> headers}) =>
-      _client.deleteResourceAt(_routes.resource(type, id), headers: headers);
-
-  /// Updates the [resource].
-  Future<Response<ResourceData>> updateResource(Resource resource,
-          {Map<String, String> headers}) =>
-      _client.updateResourceAt(
-          _routes.resource(resource.type, resource.id), resource,
-          headers: headers);
-
-  /// Replaces the to-one [relationship] of [type] : [id].
-  Future<Response<ToOne>> replaceToOne(
-          String type, String id, String relationship, Identifier identifier,
-          {Map<String, String> headers}) =>
-      _client.replaceToOneAt(
-          _routes.relationship(type, id, relationship), identifier,
-          headers: headers);
-
-  /// Deletes the to-one [relationship] of [type] : [id].
-  Future<Response<ToOne>> deleteToOne(
-          String type, String id, String relationship,
-          {Map<String, String> headers}) =>
-      _client.deleteToOneAt(_routes.relationship(type, id, relationship),
-          headers: headers);
-
-  /// Deletes the [identifiers] from the to-many [relationship] of [type] : [id].
-  Future<Response<ToMany>> deleteFromToMany(String type, String id,
-          String relationship, Iterable<Identifier> identifiers,
-          {Map<String, String> headers}) =>
-      _client.deleteFromToManyAt(
-          _routes.relationship(type, id, relationship), identifiers,
-          headers: headers);
-
-  /// Replaces the to-many [relationship] of [type] : [id] with the [identifiers].
-  Future<Response<ToMany>> replaceToMany(String type, String id,
-          String relationship, Iterable<Identifier> identifiers,
-          {Map<String, String> headers}) =>
-      _client.replaceToManyAt(
-          _routes.relationship(type, id, relationship), identifiers,
-          headers: headers);
-
-  /// Adds the [identifiers] to the to-many [relationship] of [type] : [id].
-  Future<Response<ToMany>> addToRelationship(String type, String id,
-          String relationship, Iterable<Identifier> identifiers,
-          {Map<String, String> 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<RelationshipUpdated<ToMany>> addMany(
+    String type,
+    String id,
+    String relationship,
+    List<Identifier> identifiers, {
+    Map<String, String> 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<ResourceCreated> createNew(
+    String type, {
+    Map<String, Object?> attributes = const {},
+    Map<String, Identifier> one = const {},
+    Map<String, Iterable<Identifier>> many = const {},
+    Map<String, Object?> meta = const {},
+    Map<String, String> 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<RelationshipUpdated> deleteFromMany(
+    String type,
+    String id,
+    String relationship,
+    List<Identifier> identifiers, {
+    Map<String, String> 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<CollectionFetched> fetchCollection(
+    String type, {
+    Map<String, String> headers = const {},
+    Map<String, String> query = const {},
+    Map<String, String> page = const {},
+    Map<String, String> filter = const {},
+    Iterable<String> include = const [],
+    Iterable<String> sort = const [],
+    Map<String, Iterable<String>> 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<CollectionFetched> fetchRelatedCollection(
+    String type,
+    String id,
+    String relationship, {
+    Map<String, String> headers = const {},
+    Map<String, String> page = const {},
+    Map<String, String> filter = const {},
+    Iterable<String> include = const [],
+    Iterable<String> sort = const [],
+    Map<String, Iterable<String>> fields = const {},
+    Map<String, String> 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<RelationshipFetched<ToOne>> fetchToOne(
+    String type,
+    String id,
+    String relationship, {
+    Map<String, String> headers = const {},
+    Map<String, String> 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<RelationshipFetched<ToMany>> fetchToMany(
+    String type,
+    String id,
+    String relationship, {
+    Map<String, String> headers = const {},
+    Map<String, String> 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<RelatedResourceFetched> fetchRelatedResource(
+    String type,
+    String id,
+    String relationship, {
+    Map<String, String> headers = const {},
+    Map<String, String> query = const {},
+    Map<String, String> filter = const {},
+    Iterable<String> include = const [],
+    Map<String, Iterable<String>> 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<ResourceFetched> fetchResource(
+    String type,
+    String id, {
+    Map<String, String> headers = const {},
+    Map<String, String> filter = const {},
+    Iterable<String> include = const [],
+    Map<String, Iterable<String>> fields = const {},
+    Map<String, String> 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<ResourceUpdated> updateResource(String type, String id,
+      {Map<String, Object?> attributes = const {},
+      Map<String, Identifier> one = const {},
+      Map<String, Iterable<Identifier>> many = const {},
+      Map<String, Object?> meta = const {},
+      Map<String, String> 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<ResourceUpdated> create(
+    String type,
+    String id, {
+    Map<String, Object?> attributes = const {},
+    Map<String, Identifier> one = const {},
+    Map<String, Iterable<Identifier>> many = const {},
+    Map<String, Object?> meta = const {},
+    Map<String, String> 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<RelationshipUpdated<ToOne>> replaceToOne(
+    String type,
+    String id,
+    String relationship,
+    Identifier identifier, {
+    Map<String, String> 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<RelationshipUpdated<ToMany>> replaceToMany(
+    String type,
+    String id,
+    String relationship,
+    Iterable<Identifier> identifiers, {
+    Map<String, String> 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<RelationshipUpdated<ToOne>> deleteToOne(
+      String type, String id, String relationship,
+      {Map<String, String> 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<Response> 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<String, Object> 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<String, Object> 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<String, Object> 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<Data extends PrimaryData> implements JsonEncodable {
-  /// Create a document with primary data
-  Document(this.data, {Map<String, Object> 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<ErrorObject> errors,
-      {Map<String, Object> 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<String, Object> 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<ErrorObject> errors;
-
-  /// Meta data. May be empty.
-  final Map<String, Object> 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<Data> fromJson<Data extends PrimaryData>(
-      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<String, Object> 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<String, Object> meta,
-    Map<String, Object> source,
-    Map<String, Link> 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<String, Object> meta;
-
-  /// The `links` object.
-  /// May be empty.
-  /// https://jsonapi.org/format/#document-links
-  final Map<String, Link> 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<String, String> source;
-
-  @override
-  Map<String, Object> 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 = <String, Link>{};
+
+  /// Meta data.
+  final meta = <String, Object?>{};
+
+  Map<String, Object> 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<String, String> 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 = <String, Object?>{};
 
-  @override
-  int get hashCode => 0;
+  Map<String, Object> 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<String, Object> 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<String, Object> meta;
-
-  static IdentifierObject fromIdentifier(Identifier identifier,
-          {Map<String, Object> 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<String, Object> 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<Resource> included() => json
+      .get<List>('included', orGet: () => [])
+      .whereType<Map>()
+      .map(_parse.resource);
+
+  /// Top-level meta data.
+  Map<String, Object?> meta() => _parse.meta(json);
+
+  /// Top-level links object.
+  Map<String, Link> links() => _parse.links(json);
+
+  /// Errors (for an Error Document)
+  Iterable<ErrorObject> errors() => json
+      .get<List>('errors', orGet: () => [])
+      .whereType<Map>()
+      .map(_parse.errorObject);
+
+  Iterable<Resource> dataAsCollection() =>
+      _data<List>().whereType<Map>().map(_parse.resource);
+
+  Resource dataAsResource() => _parse.resource(_data<Map>());
+
+  NewResource dataAsNewResource() => _parse.newResource(_data<Map>());
+
+  Resource? dataAsResourceOrNull() => nullable(_parse.resource)(_data<Map?>());
+
+  ToMany asToMany() => asRelationship<ToMany>();
+
+  ToOne asToOne() => asRelationship<ToOne>();
+
+  R asRelationship<R extends Relationship>() {
+    final rel = _parse.relationship(json);
+    if (rel is R) return rel;
+    throw FormatException('Invalid relationship type');
+  }
+
+  T _data<T>() => json.get<T>('data');
+}
+
+class _Parser {
+  const _Parser();
+
+  Map<String, Object?> meta(Map json) =>
+      json.get<Map<String, Object?>>('meta', orGet: () => {});
+
+  Map<String, Link> links(Map json) => json
+      .get<Map>('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<String>('type'), json.get<String>('id'))
+        ..attributes.addAll(_getAttributes(json))
+        ..relationships.addAll(_getRelationships(json))
+        ..links.addAll(links(json))
+        ..meta.addAll(meta(json));
+
+  NewResource newResource(Map json) => NewResource(json.get<String>('type'),
+      json.containsKey('id') ? json.get<String>('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<String>('type'), json.get<String>('id'))
+        ..meta.addAll(meta(json));
+
+  ErrorObject errorObject(Map json) => ErrorObject(
+      id: json.get<String>('id', orGet: () => ''),
+      status: json.get<String>('status', orGet: () => ''),
+      code: json.get<String>('code', orGet: () => ''),
+      title: json.get<String>('title', orGet: () => ''),
+      detail: json.get<String>('detail', orGet: () => ''),
+      source: errorSource(json.get<Map>('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<String>('pointer', orGet: () => ''),
+      parameter: json.get<String>('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<String, Object?> _getAttributes(Map json) =>
+      json.get<Map<String, Object?>>('attributes', orGet: () => {});
+
+  Map<String, Relationship> _getRelationships(Map json) => json
+      .get<Map>('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>().map(identifier));
+    throw FormatException('Invalid relationship object');
+  }
+}
+
+extension _TypedGeter on Map {
+  T get<T>(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<String, Link> 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 = <String, Object?>{};
 
   @override
   String toString() => uri.toString();
-}
 
-/// A JSON:API link object
-/// https://jsonapi.org/format/#document-links
-class LinkObject extends Link {
-  LinkObject(Uri href, {Map<String, Object> meta})
-      : meta = Map.unmodifiable(meta ?? const {}),
-        super(href);
-
-  final Map<String, Object> 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<Identifier> identifiers) {
+    identifiers.forEach((_) => _map[_.key] = _);
+  }
+
+  final _map = <String, Identifier>{};
+
+  @override
+  Map<String, Object> toJson() =>
+      {'data': _map.values.toList(), ...super.toJson()};
+
+  @override
+  Iterator<Identifier> 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<Resource> 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<String, Object> 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<String, dynamic> toJson() => {'data': identifier, ...super.toJson()};
+
+  final Identifier? identifier;
+
+  @override
+  Iterator<Identifier> get iterator =>
+      identifier == null ? <Identifier>[].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 = <String, Object?>{};
+
+  Map<String, Object?> toJson() => {'meta': meta};
+}
+
+/// An outbound error document.
+class OutboundErrorDocument extends OutboundDocument {
+  OutboundErrorDocument(Iterable<ErrorObject> errors) {
+    this.errors.addAll(errors);
+  }
+
+  /// The list of errors.
+  final errors = <ErrorObject>[];
+
+  @override
+  Map<String, Object> 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<Resource> 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 = <String, Link>{};
+
+  /// A list of included resources.
+  final included = <Resource>[];
+
+  @override
+  Map<String, Object?> 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<ResourceObject> included, Map<String, Link> 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<ResourceObject> included;
-
-  /// True for compound documents.
-  final bool isCompound;
-
-  /// The top-level `links` object. May be empty or null.
-  final Map<String, Link> links;
-
-  /// The `self` link. May be null.
-  Link get self => links['self'];
-
-  @override
-  Map<String, Object> 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<ResourceObject> included, Map<String, Link> 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<String, Relationship> 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<ResourceObject> included, Map<String, Link> links})
-      : super(included: included, links: links);
-
-  ToOne.empty({Link self, Map<String, Link> 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<Identifier /*!*/ > {
+  final links = <String, Link>{};
+  final meta = <String, Object?>{};
 
-  @override
-  Map<String, Object> toJson() => {
-        ...super.toJson(),
-        'data': linkage,
+  Map<String, dynamic> 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<IdentifierObject> linkage,
-      {Iterable<ResourceObject> included, Map<String, Link> links})
-      : linkage = List.unmodifiable(linkage),
-        super(included: included, links: links);
-
-  static ToMany fromIdentifiers(Iterable<Identifier> 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<IdentifierObject> linkage;
-
   @override
-  Map<String, Object> toJson() => {
-        ...super.toJson(),
-        'data': linkage,
-      };
-
-  /// Converts to List<Identifier>.
-  /// For empty relationships returns an empty List.
-  List<Identifier> unwrap() => linkage.map((_) => _.unwrap()).toList();
+  Iterator<Identifier> get iterator => const <Identifier>[].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<String, Object> attributes,
-      Map<String, Identifier> toOne,
-      Map<String, List<Identifier>> 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<String, Object> attributes;
-
-  /// Unmodifiable map of to-one relationships
-  final Map<String, Identifier> toOne;
-
-  /// Unmodifiable map of to-many relationships
-  final Map<String, List<Identifier>> 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<String, Object> attributes,
-      Map<String, Identifier> toOne,
-      Map<String, List<Identifier>> toMany})
-      : super(type, null, attributes: attributes, toOne: toOne, toMany: toMany);
+  /// Resource links
+  final links = <String, Link>{};
+
+  Map<String, Object> 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<Resource> {
+  final _map = <String, Resource>{};
+
+  Resource? operator [](Object? key) => _map[key];
+
+  void add(Resource resource) {
+    _map[resource.key] = resource;
+  }
+
+  void addAll(Iterable<Resource> resources) {
+    resources.forEach(add);
+  }
+
+  @override
+  Iterator<Resource> 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<ResourceObject> collection,
-      {Iterable<ResourceObject> included, Map<String, Link> 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<ResourceObject> 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<Resource> unwrap() => collection.map((_) => _.unwrap()).toList();
-
-  /// Returns a map of resources indexed by ids
-  Map<String, Resource> unwrapToMap() =>
-      Map<String, Resource>.fromIterable(unwrap(), key: (r) => r.id);
-
-  @override
-  Map<String, Object> 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<ResourceObject> included, Map<String, Link> 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<ResourceObject> 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<String, Object> 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<String, Object> attributes,
-      Map<String, Relationship> relationships,
-      Map<String, Object> meta,
-      Map<String, Link> 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<String, Object> attributes;
-  final Map<String, Relationship> relationships;
-  final Map<String, Object> meta;
-
-  /// Read-only `links` object. May be empty.
-  final Map<String, Link> 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<String, Object> 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 = <String, Identifier>{};
-    final toMany = <String, List<Identifier>>{};
-    final incomplete = <String, Relationship>{};
-    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 = <String, Object?>{};
+
+  /// Resource attributes.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-attributes
+  final attributes = <String, Object?>{};
+
+  /// Resource relationships.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-relationships
+  final relationships = <String, Relationship>{};
+
+  /// Returns a to-one relationship by its [name].
+  ToOne? one(String name) => _rel<ToOne>(name);
+
+  /// Returns a to-many relationship by its [name].
+  ToMany? many(String name) => _rel<ToMany>(name);
+
+  /// Returns a typed relationship by its [name].
+  R? _rel<R extends Relationship>(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<HttpResponse> 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<HttpResponse> Function(HttpRequest request);
-
-class _HandlerFromFunction implements HttpHandler {
-  const _HandlerFromFunction(this._f);
-
-  @override
-  Future<HttpResponse> call(HttpRequest request) => _f(request);
-
-  final HttpHandlerFunc _f;
+  Future<HttpResponse> 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<String, String>(
+      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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<HttpResponse> 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<HttpResponse> 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<Map> decode(String body) async {
+    final json = jsonDecode(body);
+    if (json is Map) return json;
+    throw FormatException('Invalid JSON payload: ${json.runtimeType}');
+  }
+
+  Future<String> 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<U, V> nullable<V, U>(U Function(V v) f) => (v) => v == null ? null : f(v);
-
-typedef _Fun<U, V> = U Function(V v);
+U? Function(V? v) nullable<V, U>(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<String, Iterable<String>> {
   /// 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<String, List<String>> fields)
-      : _fields = {...fields},
-        super(fields.map((k, v) => MapEntry('fields[$k]', v.join(','))));
+  Fields([Map<String, Iterable<String>> 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<String, List<String>> 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<String> operator [](String key) => _fields[key];
+        ..removeWhere((k, v) => k.isEmpty));
 
   static final _regex = RegExp(r'^fields\[(.+)\]$');
 
-  final Map<String, List<String>> _fields;
+  final _map = <String, Iterable<String>>{};
+
+  /// Converts to a map of query parameters
+  Map<String, String> get asQueryParameters =>
+      _map.map((k, v) => MapEntry('fields[$k]', v.join(',')));
+
+  @override
+  void operator []=(String key, Iterable<String> value) => _map[key] = value;
+
+  @override
+  void clear() => _map.clear();
+
+  @override
+  Iterable<String> get keys => _map.keys;
+
+  @override
+  Iterable<String>? remove(Object? key) => _map.remove(key);
+
+  @override
+  Iterable<String>? 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<String, String> {
   /// 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<String, List<String>> filter)
-      : _filter = {...filter},
-        super(filter.map((k, v) => MapEntry('filter[$k]', v.join(','))));
+  Filter([Map<String, String> 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<String> 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<String, List<String>> _filter;
+  final _ = <String, String>{};
+
+  /// Converts to a map of query parameters
+  Map<String, String> 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<String> 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<String> {
+class Include with IterableMixin<String> {
   /// Example:
   /// ```dart
-  /// Include(['comments', 'comments.author']).addTo(url);
-  /// ```
-  /// encodes into
-  /// ```
-  /// ?include=comments,comments.author
+  /// Include(['comments', 'comments.author']);
   /// ```
-  Include(Iterable<String> resources)
-      : _resources = [...resources],
-        super({'include': resources.join(',')});
+  Include([Iterable<String> 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<String, List<String>> parameters) =>
-      Include((parameters['include']?.expand((_) => _.split(',')) ?? []));
+  final _ = <String>[];
+
+  /// Converts to a map of query parameters
+  Map<String, String> get asQueryParameters =>
+      {if (isNotEmpty) 'include': join(',')};
 
   @override
-  Iterator<String> get iterator => _resources.iterator;
+  Iterator<String> get iterator => _.iterator;
 
-  final List<String> _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<String, String> {
   /// 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<String, String> parameters)
-      : _parameters = {...parameters},
-        super(parameters.map((k, v) => MapEntry('page[${k}]', v)));
+  Page([Map<String, String> 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<String, List<String>> queryParameters) =>
-      Page(queryParameters
-          .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1), v.last))
-            ..removeWhere((k, v) => k == null));
+  final _ = <String, String>{};
 
-  String operator [](String key) => _parameters[key];
+  /// Converts to a map of query parameters
+  Map<String, String> 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<String> get keys => _.keys;
 
-  final Map<String, String> _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<String, String> parameters)
-      : _parameters = {...parameters};
-  final Map<String, String> _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<SortField> {
+class Sort with IterableMixin<SortField> {
   /// 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<SortField> fields)
-      : _fields = [...fields],
-        super({'sort': fields.join(',')});
-  final List<SortField> _fields;
+  Sort([Iterable<String> 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<String, List<String>> queryParameters) =>
-      Sort((queryParameters['sort']?.expand((_) => _.split(',')) ?? [])
-          .map(SortField.parse));
+  final _ = <SortField>[];
 
-  @override
-  Iterator<SortField> get iterator => _fields.iterator;
-}
+  /// Converts to a map of query parameters
+  Map<String, String> 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<SortField> 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<String> pathSegments) =>
-      _base.resolveUri(Uri(pathSegments: pathSegments));
-
-  List<String> _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<String> 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<T> {
-  /// Finds an returns a primary resource collection.
-  /// See https://jsonapi.org/format/#fetching-resources
-  T fetchCollection(String type, Map<String, List<String>> queryParameters);
-
-  /// Finds an returns a primary resource.
-  /// See https://jsonapi.org/format/#fetching-resources
-  T fetchResource(
-      ResourceTarget target, Map<String, List<String>> 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<String, List<String>> queryParameters);
-
-  /// Finds an returns a relationship of a primary resource.
-  /// See https://jsonapi.org/format/#fetching-relationships
-  T fetchRelationship(
-      RelationshipTarget target, Map<String, List<String>> 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<Identifier> 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<Identifier> 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<Identifier> 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<HttpResponse> fetchCollection(HttpRequest request, Target target);
+
+  /// Create resource
+  Future<HttpResponse> createResource(HttpRequest request, Target target);
+
+  /// Fetch a single primary resource
+  Future<HttpResponse> fetchResource(
+      HttpRequest request, ResourceTarget target);
+
+  /// Updates a primary resource
+  Future<HttpResponse> updateResource(
+      HttpRequest request, ResourceTarget target);
+
+  /// Deletes the primary resource
+  Future<HttpResponse> deleteResource(
+      HttpRequest request, ResourceTarget target);
+
+  /// Fetches a relationship
+  Future<HttpResponse> fetchRelationship(
+      HttpRequest rq, RelationshipTarget target);
+
+  /// Add new entries to a to-many relationship
+  Future<HttpResponse> addMany(HttpRequest request, RelationshipTarget target);
+
+  /// Updates the relationship
+  Future<HttpResponse> replaceRelationship(
+      HttpRequest request, RelationshipTarget target);
+
+  /// Deletes the members from the to-many relationship
+  Future<HttpResponse> deleteMany(
+      HttpRequest request, RelationshipTarget target);
+
+  /// Fetches related resource or collection
+  Future<HttpResponse> 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<void> 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<HttpRequest> _convertRequest(dart.HttpRequest r) async {
-    final body = await r.cast<List<int>>().transform(utf8.decoder).join();
-    final headers = <String, String>{};
-    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<ErrorObject> errors) =>
-      Document.error(errors, api: _api);
-
-  /// A resource collection document
-  Document<ResourceCollectionData> collection(Iterable<Resource> collection,
-          {int total,
-          Iterable<Resource> 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<String, Object> meta) => Document.empty(meta, api: _api);
-
-  Document<ResourceData> resource(Resource resource,
-          {Iterable<Resource> included}) =>
-      Document(
-          ResourceData(_resourceObject(resource),
-              links: _links.resource(),
-              included: included?.map(_resourceObject)),
-          api: _api);
-
-  Document<ResourceData> resourceCreated(Resource resource) => Document(
-      ResourceData(_resourceObject(resource),
-          links: _links
-              .createdResource(ResourceTarget(resource.type, resource.id))),
-      api: _api);
-
-  Document<ToMany> toMany(
-          RelationshipTarget target, Iterable<Identifier> identifiers,
-          {Iterable<Resource> included}) =>
-      Document(
-          ToMany(
-            identifiers.map(IdentifierObject.fromIdentifier),
-            links: _links.relationship(target),
-          ),
-          api: _api);
-
-  Document<ToOne> toOne(RelationshipTarget target, Identifier identifier,
-          {Iterable<Resource> 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<HttpResponse> {
-  HttpResponseConverter(this._doc, this._routing);
-
-  final RouteFactory _routing;
-  final DocumentFactory _doc;
-
-  @override
-  HttpResponse error(Iterable<ErrorObject> errors, int statusCode,
-          Map<String, String> headers) =>
-      _ok(_doc.error(errors), status: statusCode, headers: headers);
-
-  @override
-  HttpResponse collection(Iterable<Resource> resources,
-      {int total,
-      Iterable<Resource> 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<String, Object> meta) => _ok(_doc.empty(meta));
-
-  @override
-  HttpResponse resource(Resource resource, {Iterable<Resource> 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<Identifier> identifiers,
-          {Iterable<Resource> included}) =>
-      _ok(_doc.toMany(target, identifiers, included: included));
-
-  @override
-  HttpResponse toOne(RelationshipTarget target, Identifier identifier,
-          {Iterable<Resource> included}) =>
-      _ok(_doc.toOne(target, identifier, included: included));
-
-  @override
-  HttpResponse noContent() => HttpResponse(204);
-
-  HttpResponse _ok(Document d,
-          {int status = 200, Map<String, String> 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<String, Map<String, Resource>> _collections;
-  final IdGenerator _nextId;
-
-  @override
-  FutureOr<Resource> 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<Resource> 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<Resource> 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<void> delete(ResourceTarget target) async {
-    await get(target);
-    _collections[target.type].remove(target.id);
-    return null;
-  }
-
-  @override
-  FutureOr<Collection<Resource>> getCollection(String collection,
-      {int limit, int offset, List<SortField> 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<T>(Controller<T> 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<String, List<String>> queryParameters;
-
-  @override
-  T handleWith<T>(Controller<T> 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<T>(Controller<T> 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<T>(Controller<T> 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<T>(Controller<T> 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<String, List<String>> queryParameters;
-
-  @override
-  T handleWith<T>(Controller<T> 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<String, List<String>> queryParameters;
-
-  @override
-  T handleWith<T>(Controller<T> 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<String, List<String>> queryParameters;
-
-  @override
-  T handleWith<T>(Controller<T> 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<Identifier> identifiers)
-      : identifiers = List.unmodifiable(identifiers);
-
-  final RelationshipTarget target;
-
-  /// The identifiers to delete
-  final List<Identifier> identifiers;
-
-  @override
-  T handleWith<T>(Controller<T> 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<T>(Controller<T> 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<T>(Controller<T> 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<Identifier> identifiers)
-      : identifiers = List.unmodifiable(identifiers);
-
-  final RelationshipTarget target;
-
-  /// The set of identifiers to replace the current ones
-  final List<Identifier> identifiers;
-
-  @override
-  T handleWith<T>(Controller<T> 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<Identifier> identifiers)
-      : identifiers = List.unmodifiable(identifiers);
-
-  final RelationshipTarget target;
-
-  /// The identifiers to be added to the existing ones
-  final List<Identifier> identifiers;
-
-  @override
-  T handleWith<T>(Controller<T> 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<T>(ResponseConverter<T> 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<T>(ResponseConverter<T> 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<Resource> resources,
-      {Iterable<Resource> included, this.total})
-      : resources = List.unmodifiable(resources),
-        included = included == null ? null : List.unmodifiable(included);
-
-  final List<Resource> resources;
-  final List<Resource> included;
-
-  final int total;
-
-  @override
-  T convert<T>(ResponseConverter<T> 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<T>(ResponseConverter<T> converter) => converter.accepted(resource);
-}
-
-/// A common error response.
-///
-/// See: https://jsonapi.org/format/#errors
-class ErrorResponse implements JsonApiResponse {
-  ErrorResponse(this.statusCode, Iterable<ErrorObject> errors,
-      {Map<String, String> 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<ErrorObject> 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<ErrorObject> 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<ErrorObject> errors) : this(404, errors);
-
-  /// HTTP 405 Method Not Allowed response.
-  /// The allowed methods can be specified in [allow]
-  ErrorResponse.methodNotAllowed(
-      Iterable<ErrorObject> errors, Iterable<String> 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<ErrorObject> errors) : this(409, errors);
-
-  /// HTTP 500 Internal Server Error response.
-  ErrorResponse.internalServerError(Iterable<ErrorObject> errors)
-      : this(500, errors);
-
-  /// HTTP 501 Not Implemented response.
-  ErrorResponse.notImplemented(Iterable<ErrorObject> errors)
-      : this(501, errors);
-
-  /// Error objects to send with the response
-  final List<ErrorObject> errors;
-
-  /// HTTP status code
-  final int statusCode;
-  final Map<String, String> _headers;
-
-  @override
-  T convert<T>(ResponseConverter<T> 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<String, Object> meta) : meta = Map.unmodifiable(meta);
-
-  final Map<String, Object> meta;
-
-  @override
-  T convert<T>(ResponseConverter<T> 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<Resource> included})
-      : included = included == null ? null : List.unmodifiable(included);
-
-  final Resource resource;
-
-  final List<Resource> included;
-
-  @override
-  T convert<T>(ResponseConverter<T> 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<T>(ResponseConverter<T> 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<T>(ResponseConverter<T> 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<Identifier> identifiers)
-      : identifiers =
-            identifiers == null ? null : List.unmodifiable(identifiers);
-
-  final RelationshipTarget target;
-  final List<Identifier> identifiers;
-
-  @override
-  T convert<T>(ResponseConverter<T> 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<T>(ResponseConverter<T> 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<FutureOr<JsonApiResponse>> _controller;
-
-  @override
-  Future<HttpResponse> 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<String, Link> resource();
-
-  /// Links for a collection (primary or related)
-  Map<String, Link> collection(int total, Pagination pagination);
-
-  /// Links for a newly created resource
-  Map<String, Link> createdResource(ResourceTarget target);
-
-  /// Links for a standalone relationship
-  Map<String, Link> relationship(RelationshipTarget target);
-
-  /// Links for a relationship inside a resource
-  Map<String, Link> 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<String, Link> collection(int total, Pagination pagination) => const {};
-
-  @override
-  Map<String, Link> createdResource(ResourceTarget target) => const {};
-
-  @override
-  Map<String, Link> relationship(RelationshipTarget target) => const {};
-
-  @override
-  Map<String, Link> resource() => const {};
-
-  @override
-  Map<String, Link> 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<String, Link> resource() => {'self': Link(_requested)};
-
-  @override
-  Map<String, Link> collection(int total, Pagination pagination) =>
-      {'self': Link(_requested), ..._navigation(total, pagination)};
-
-  @override
-  Map<String, Link> createdResource(ResourceTarget target) =>
-      {'self': Link(_route.resource(target.type, target.id))};
-
-  @override
-  Map<String, Link> relationship(RelationshipTarget target) => {
-        'self': Link(_requested),
-        'related':
-            Link(_route.related(target.type, target.id, target.relationship))
-      };
-
-  @override
-  Map<String, Link> resourceRelationship(RelationshipTarget target) => {
-        'self': Link(
-            _route.relationship(target.type, target.id, target.relationship)),
-        'related':
-            Link(_route.related(target.type, target.id, target.relationship))
-      };
-
-  Map<String, Link> _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<Resource> create(String collection, Resource resource);
-
-  /// Returns the resource by [target].
-  FutureOr<Resource> get(ResourceTarget target);
-
-  /// Updates the resource identified by [target].
-  /// If the resource was modified during update, returns the modified resource.
-  /// Otherwise returns null.
-  FutureOr<Resource> update(ResourceTarget target, Resource resource);
-
-  /// Deletes the resource identified by [target]
-  FutureOr<void> delete(ResourceTarget target);
-
-  /// Returns a collection of resources
-  FutureOr<Collection<Resource>> getCollection(String collection,
-      {int limit, int offset, List<SortField> sort});
-}
-
-/// A collection of elements (e.g. resources) returned by the server.
-class Collection<T> {
-  Collection(Iterable elements, [this.total])
-      : elements = List.unmodifiable(elements);
-
-  final List<T> 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<R> implements Controller<FutureOr<JsonApiResponse>> {
-  RepositoryController(this._repo, {Pagination pagination})
-      : _pagination = pagination ?? NoPagination();
-
-  final Repository _repo;
-  final Pagination _pagination;
-
-  @override
-  FutureOr<JsonApiResponse> addToRelationship(
-          RelationshipTarget target, Iterable<Identifier> 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<JsonApiResponse> createResource(String type, Resource resource) =>
-      _do(() async {
-        final modified = await _repo.create(type, resource);
-        if (modified == null) return NoContentResponse();
-        return ResourceCreatedResponse(modified);
-      });
-
-  @override
-  FutureOr<JsonApiResponse> deleteFromRelationship(
-          RelationshipTarget target, Iterable<Identifier> 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<JsonApiResponse> deleteResource(ResourceTarget target) =>
-      _do(() async {
-        await _repo.delete(target);
-        return NoContentResponse();
-      });
-
-  @override
-  FutureOr<JsonApiResponse> fetchCollection(
-          String type, Map<String, List<String>> 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 = <Resource>[];
-        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<JsonApiResponse> fetchRelated(RelationshipTarget target,
-          Map<String, List<String>> 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 = <Resource>[];
-          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<JsonApiResponse> fetchRelationship(RelationshipTarget target,
-          Map<String, List<String>> 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<JsonApiResponse> fetchResource(
-          ResourceTarget target, Map<String, List<String>> queryParameters) =>
-      _do(() async {
-        final include = Include.fromQueryParameters(queryParameters);
-        final resource = await _repo.get(target);
-        final resources = <Resource>[];
-        for (final path in include) {
-          resources.addAll(await _getRelated(resource, path.split('.')));
-        }
-        return ResourceResponse(resource,
-            included: include.isEmpty ? null : resources);
-      });
-
-  @override
-  FutureOr<JsonApiResponse> replaceToMany(
-          RelationshipTarget target, Iterable<Identifier> identifiers) =>
-      _do(() async {
-        await _repo.update(
-            target.resource,
-            Resource(target.type, target.id,
-                toMany: {target.relationship: identifiers}));
-        return NoContentResponse();
-      });
-
-  @override
-  FutureOr<JsonApiResponse> updateResource(
-          ResourceTarget target, Resource resource) =>
-      _do(() async {
-        final modified = await _repo.update(target, resource);
-        if (modified == null) return NoContentResponse();
-        return ResourceResponse(modified);
-      });
-
-  @override
-  FutureOr<JsonApiResponse> 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<JsonApiResponse> deleteToOne(RelationshipTarget target) =>
-      replaceToOne(target, null);
-
-  Future<Iterable<Resource>> _getRelated(
-    Resource resource,
-    Iterable<String> path,
-  ) async {
-    if (path.isEmpty) return [];
-    final resources = <Resource>[];
-    final ids = <Identifier>[];
-
-    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<Resource> _unique(Iterable<Resource> included) =>
-      Map<String, Resource>.fromIterable(included,
-          key: (_) => '${_.type}:${_.id}').values;
-
-  FutureOr<JsonApiResponse> _do(
-      FutureOr<JsonApiResponse> 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<String> allow)
-      : allow = List.unmodifiable(allow ?? const []);
-
-  /// List of allowed methods
-  final List<String> 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<D extends OutboundDocument> 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<T> {
-  /// A common error response.
-  ///
-  /// See: https://jsonapi.org/format/#errors
-  T error(Iterable<ErrorObject> errors, int statusCode,
-      Map<String, String> headers);
-
-  /// HTTP 200 OK response with a resource collection.
-  ///
-  /// See: https://jsonapi.org/format/#fetching-resources-responses-200
-  T collection(Iterable<Resource> resources,
-      {int total, Iterable<Resource> 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<String, Object> 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<Resource> 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<Identifier> identifiers,
-      {Iterable<Resource> 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<Resource> 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<HttpResponse> 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 = <Future Function()>[
+        () => 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<void> e2eTests(RoutingClient client) async {
+  await _testAllHttpMethods(client);
+  await _testLocationIsSet(client);
+}
+
+Future<void> _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<void> _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<void> 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 = <HttpRequest>[];
-  HttpResponse nextResponse;
-
-  @override
-  Future<HttpResponse> 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<ToOne>());
+      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<ToOne>());
+      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<ToOne>());
+      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<ToOne>());
+      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<ToMany>());
+      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<ToMany>());
+      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<ToMany>());
+      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<ToMany>());
+      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<ToMany>());
+      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<ToMany>());
+      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<int> 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<HttpResponse> 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<DocumentException>()));
-  });
-
-  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<DocumentException>()));
-  });
-}
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<DocumentException>()));
-  });
-}
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<Relationship>());
+        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<ToOne>());
+        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<ToOne>());
+        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<ToMany>());
+        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<ToMany>());
+        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<DocumentException>()));
-    expect(
-        () => Link.mapFromJson([]), throwsA(TypeMatcher<DocumentException>()));
   });
 }
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<String, dynamic> 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<DocumentException>()));
-    });
-  });
-
-  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');
-  });
-}

From 4c2dbe9c9c0751f434b646501079f0068867027d Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Sat, 17 Apr 2021 18:53:57 -0700
Subject: [PATCH 02/25] Encoding detection from #94 (#110)

---
 lib/src/client/client.dart             |  6 +--
 lib/src/client/disposable_handler.dart |  2 +-
 lib/src/client/persistent_handler.dart | 38 +++++++++++++++++--
 lib/src/http/media_type.dart           |  4 +-
 lib/src/server/response.dart           |  2 +-
 pubspec.yaml                           |  3 +-
 test/unit/client/response.dart         | 16 ++++----
 test/unit/http/enoding_test.dart       | 52 ++++++++++++++++++++++++++
 8 files changed, 103 insertions(+), 20 deletions(-)
 create mode 100644 test/unit/http/enoding_test.dart

diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index fe361fa3..60e35ccc 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -26,8 +26,8 @@ class Client {
             : uri.replace(queryParameters: request.query),
         body: body)
       ..headers.addAll({
-        'Accept': MediaType.jsonApi,
-        if (body.isNotEmpty) 'Content-Type': MediaType.jsonApi,
+        'Accept': mediaType,
+        if (body.isNotEmpty) 'Content-Type': mediaType,
         ...request.headers
       }));
 
@@ -49,5 +49,5 @@ class Client {
       response.body.isNotEmpty &&
       (response.headers['Content-Type'] ?? '')
           .toLowerCase()
-          .startsWith(MediaType.jsonApi);
+          .startsWith(mediaType);
 }
diff --git a/lib/src/client/disposable_handler.dart b/lib/src/client/disposable_handler.dart
index 79a0ade3..ab69d523 100644
--- a/lib/src/client/disposable_handler.dart
+++ b/lib/src/client/disposable_handler.dart
@@ -11,7 +11,7 @@ class DisposableHandler implements HttpHandler {
   Future<HttpResponse> handle(HttpRequest request) async {
     final client = Client();
     try {
-      return await PersistentHandler(client).call(request);
+      return await PersistentHandler(client).handle(request);
     } finally {
       client.close();
     }
diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart
index b2b62ea3..6974a8bb 100644
--- a/lib/src/client/persistent_handler.dart
+++ b/lib/src/client/persistent_handler.dart
@@ -1,4 +1,7 @@
+import 'dart:convert';
+
 import 'package:http/http.dart';
+import 'package:http_parser/http_parser.dart';
 import 'package:json_api/http.dart';
 
 /// Handler which relies on the built-in Dart HTTP client.
@@ -7,16 +10,45 @@ import 'package:json_api/http.dart';
 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);
+  PersistentHandler(this.client, {this.defaultEncoding = utf8});
 
   final Client client;
+  final Encoding defaultEncoding;
 
-  Future<HttpResponse> call(HttpRequest request) async {
+  Future<HttpResponse> handle(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)
+    final responseBody =
+        _encodingForHeaders(response.headers).decode(response.bodyBytes);
+    return HttpResponse(response.statusCode, body: responseBody)
       ..headers.addAll(response.headers);
   }
+
+  /// 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<String, String> 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<String, String> headers) {
+    final contentType = headers['content-type'];
+    if (contentType != null) return MediaType.parse(contentType);
+    return MediaType('application', 'octet-stream');
+  }
 }
diff --git a/lib/src/http/media_type.dart b/lib/src/http/media_type.dart
index facf0de6..58be18ed 100644
--- a/lib/src/http/media_type.dart
+++ b/lib/src/http/media_type.dart
@@ -1,3 +1 @@
-class MediaType {
-  static const jsonApi = 'application/vnd.api+json';
-}
+const mediaType = 'application/vnd.api+json';
diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart
index ba17cebb..90d092ef 100644
--- a/lib/src/server/response.dart
+++ b/lib/src/server/response.dart
@@ -8,7 +8,7 @@ import 'package:json_api/src/nullable.dart';
 class Response<D extends OutboundDocument> extends HttpResponse {
   Response(int statusCode, {this.document}) : super(statusCode) {
     if (document != null) {
-      headers['Content-Type'] = MediaType.jsonApi;
+      headers['Content-Type'] = mediaType;
     }
   }
 
diff --git a/pubspec.yaml b/pubspec.yaml
index d9f675de..adefbcfb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.0-rc.0
+version: 5.0.0-rc.1
 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:
@@ -7,6 +7,7 @@ environment:
 
 dependencies:
   http: ^0.13.0
+  http_parser: ^4.0.0
 
 dev_dependencies:
   pedantic: ^1.10.0
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index 10213a0f..5fbb377c 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -9,7 +9,7 @@ final collectionMin = HttpResponse(200,
         {'type': 'articles', 'id': '1'}
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final collectionFull = HttpResponse(200,
     body: jsonEncode({
@@ -80,7 +80,7 @@ final collectionFull = HttpResponse(200,
         }
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final primaryResource = HttpResponse(200,
     body: jsonEncode({
@@ -130,13 +130,13 @@ final primaryResource = HttpResponse(200,
         }
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 final relatedResourceNull = HttpResponse(200,
     body: jsonEncode({
       'links': {'self': 'http://example.com/articles/1/author'},
       'data': null
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 final one = HttpResponse(200,
     body: jsonEncode({
       'links': {
@@ -179,7 +179,7 @@ final one = HttpResponse(200,
         }
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final oneEmpty = HttpResponse(200,
     body: jsonEncode({
@@ -223,7 +223,7 @@ final oneEmpty = HttpResponse(200,
         }
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final many = HttpResponse(200,
     body: jsonEncode({
@@ -235,7 +235,7 @@ final many = HttpResponse(200,
         {'type': 'tags', 'id': '12'}
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final noContent = HttpResponse(204);
 
@@ -250,6 +250,6 @@ final error422 = HttpResponse(422,
         }
       ]
     }))
-  ..headers.addAll({'Content-Type': MediaType.jsonApi});
+  ..headers.addAll({'Content-Type': mediaType});
 
 final error500 = HttpResponse(500);
diff --git a/test/unit/http/enoding_test.dart b/test/unit/http/enoding_test.dart
new file mode 100644
index 00000000..173c1848
--- /dev/null
+++ b/test/unit/http/enoding_test.dart
@@ -0,0 +1,52 @@
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:http/testing.dart';
+import 'package:json_api/http.dart';
+import 'package:json_api/src/client/persistent_handler.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<int> bytesBody,
+      Encoding encoding,
+    ) async {
+      final dartHttp = PersistentHandler(
+        MockClient(
+          (request) async {
+            return http.Response.bytes(bytesBody, 200);
+          },
+        ),
+        defaultEncoding: encoding,
+      );
+
+      return dartHttp.handle(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));
+    });
+  });
+}

From 2bdec4c8871728e04267c55a1d6d4cee2206e156 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Wed, 21 Apr 2021 22:25:32 -0700
Subject: [PATCH 03/25] v5 (#112)

---
 CHANGELOG.md                                 | 74 +++++++--------
 README.md                                    | 42 ++++++++-
 example/client.dart                          | 36 +++++--
 lib/client.dart                              | 24 ++++-
 lib/src/client/client.dart                   | 26 ++----
 lib/src/client/request.dart                  | 11 ++-
 lib/src/client/response.dart                 | 11 ++-
 lib/src/client/response/request_failure.dart |  8 +-
 lib/src/client/routing_client.dart           | 98 +++++++++++---------
 lib/src/document/outbound_document.dart      | 22 +++--
 lib/src/http/http_response.dart              | 11 +++
 lib/src/http/payload_codec.dart              | 18 ++--
 pubspec.yaml                                 |  7 +-
 test/contract/errors_test.dart               |  8 +-
 14 files changed, 250 insertions(+), 146 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd152942..41100ed4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,13 +4,17 @@ All notable changes to this project will be documented in this file.
 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]
+## [5.0.0] - 2021-04-21
 ### Added
 - Sound null-safety support.
 
 ### Changed
 - Everything. Again. This is another major **BC-breaking** rework. Please refer to 
-  the API documentation, examples and tests.
+the API documentation, examples and tests.
+
+## [3.2.3] - 2020-08-06
+### Fixed
+- Call toJson() on resourceObject when serializing ([\#84](https://github.com/f3ath/json-api-dart/pull/84))
 
 ## [4.3.0] - 2020-07-30
 ### Added
@@ -34,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### 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))
+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
@@ -44,18 +48,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Changed
 - 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 ([#77](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 ([#74](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
@@ -81,13 +80,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [3.0.0] - 2019-12-17
 ### Added
-- Support for custom non-standard links ([#61](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`.
@@ -97,7 +95,7 @@ Most of the changes are **BC-BREAKING**.
 - The signature of `Controller`.
 - `Server` was renamed to `JsonApiServer`.
 - `Pagination` was renamed to `PaginationStrategy`.
- 
+
 ### Removed
 - (Server) `ResourceTarget`, `CollectionTarget`, `RelationshipTarget`  classes.
 - `QueryParameters` interface.
@@ -106,7 +104,7 @@ Most of the changes are **BC-BREAKING**.
 
 ## [2.1.0] - 2019-12-04
 ### Added
-- `onHttpCall` hook to enable raw http request/response logging ([#60](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
@@ -114,73 +112,71 @@ Most of the changes are **BC-BREAKING**.
 
 ## [2.0.2] - 2019-08-01
 ### Fixed
-- Meta members have incorrect type ([#54](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`.
 
-## [1.0.0] - 2019-03-20
-### Changed
-- JSON:API Server moved out
-
 ## [0.6.0] - 2019-03-25
 ### Changed
 - JSON:API Document moved out
 - 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 were incorrectly generated by Server
 
-### Added
-- Related collection pagination
-- Async operations support
+## [1.0.0] - 2019-03-20
+### Changed
+- JSON:API Server moved out
 
 ## [0.4.0] - 2019-03-17
+### Added
+- Compound documents support in Client (Server-side support is still very limited)
+
 ### 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
@@ -191,15 +187,15 @@ Most of the changes are **BC-BREAKING**.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
-[Unreleased]: https://github.com/f3ath/json-api-dart/compare/4.3.0..HEAD
+[5.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.3...5.0.0
+[3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2...3.2.3
 [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.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.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
@@ -210,10 +206,10 @@ Most of the changes are **BC-BREAKING**.
 [2.0.1]: https://github.com/f3ath/json-api-dart/compare/2.0.0...2.0.1
 [2.0.0]: https://github.com/f3ath/json-api-dart/compare/1.0.1...2.0.0
 [1.0.1]: https://github.com/f3ath/json-api-dart/compare/1.0.0...1.0.1
-[1.0.0]: https://github.com/f3ath/json-api-dart/compare/0.6.0...1.0.0
 [0.6.0]: https://github.com/f3ath/json-api-dart/compare/0.5.0...0.6.0
 [0.5.0]: https://github.com/f3ath/json-api-dart/compare/0.4.0...0.5.0
+[1.0.0]: https://github.com/f3ath/json-api-dart/compare/0.6.0...1.0.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
-[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0
+[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0
\ No newline at end of file
diff --git a/README.md b/README.md
index 7ab02beb..06f7d06b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,44 @@
-# JSON:API Client and Server for Dart/Flutter. Version 5.
+# JSON:API Client and Server
+
+TL;DR:
+```dart
+import 'package:json_api/client.dart';
+import 'package:json_api/routing.dart';
+
+void main() async {
+  /// Define the server's base URL
+  final baseUri = 'http://localhost:8080';
+
+  /// Use the standard recommended URL structure or implement your own
+  final uriDesign = StandardUriDesign(Uri.parse(baseUri));
+
+  /// The [RoutingClient] is most likely the right choice.
+  /// It has methods covering many standard use cases.
+  final client = RoutingClient(uriDesign);
+
+  try {
+    /// Fetch the collection.
+    /// See other methods to query and manipulate resources.
+    final response = await client.fetchCollection('colors');
+
+    final resources = response.collection;
+    resources.map((resource) => resource.attributes).forEach((attr) {
+      final name = attr['name'];
+      final red = attr['red'];
+      final green = attr['green'];
+      final blue = attr['blue'];
+      print('$name - $red:$green:$blue');
+    });
+  } on RequestFailure catch (e) {
+    /// Catch error response
+    e.errors.forEach((error) => print('${error.title}'));
+  }
+}
+```
+This is a work-in-progress. You can help it by submitting a PR with a feature or documentation improvements.
+
+
 
-[JSON:API] is a specification for building JSON APIs. The documentation is a work-in-progress.
 
 
 [JSON:API]: https://jsonapi.org
diff --git a/example/client.dart b/example/client.dart
index da5f693f..6e9c04a4 100644
--- a/example/client.dart
+++ b/example/client.dart
@@ -1,14 +1,32 @@
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 
-// START THE SERVER FIRST!
 void main() async {
-  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']}');
-  });
+  /// Define the server's base URL
+  final baseUri = 'http://localhost:8080';
+
+  /// Use the standard recommended URL structure or implement your own
+  final uriDesign = StandardUriDesign(Uri.parse(baseUri));
+
+  /// The [RoutingClient] is most likely the right choice.
+  /// It has methods covering many standard use cases.
+  final client = RoutingClient(uriDesign);
+
+  try {
+    /// Fetch the collection.
+    /// See other methods to query and manipulate resources.
+    final response = await client.fetchCollection('colors');
+
+    final resources = response.collection;
+    resources.map((resource) => resource.attributes).forEach((attr) {
+      final name = attr['name'];
+      final red = attr['red'];
+      final green = attr['green'];
+      final blue = attr['blue'];
+      print('$name - $red:$green:$blue');
+    });
+  } on RequestFailure catch (e) {
+    /// Catch error response
+    e.errors.forEach((error) => print('${error.title}'));
+  }
 }
diff --git a/lib/client.dart b/lib/client.dart
index 56e69047..9a383cba 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -1,4 +1,26 @@
-/// JSON:API client for Flutter, browsers and vm.
+/// Provides JSON:API client for Flutter, browsers and vm.
+///
+/// There are two clients implementation provided by this library.
+///
+/// The firs one, [Client], is the most flexible but low level. It operates
+/// generic [Request] and [Response] objects and performs basic operations
+/// such as JSON conversion and error handling. It is agnostic to the document
+/// structure and accepts any target URIs.
+///
+/// By default, the [DisposableHandler] is used which internally creates
+/// a new instance of Dart built-in HTTP client for each request and then
+/// disposes it. If you want more control of the underlying http client,
+/// one option can be to use the [PersistentHandler]. To use another HTTP client,
+/// such as [dio](https://pub.dev/packages/dio) implement your own wrapper.
+///
+/// The [codec] performs JSON encoding/decoding. The default implementation
+/// uses native `dart:convert`. Provide your own [PayloadCodec] if you need
+/// fine-grained control over JSON conversion.
+///
+/// The [RoutingClient] is a wrapper over [Client] containing methods
+/// representing the most common use cases of resource fetching and manipulation.
+/// It can conveniently construct and parse JSON:API documents and URIs.
+/// The [RoutingClient] should be your default choice.
 library client;
 
 export 'package:json_api/src/client/client.dart';
diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index 60e35ccc..1a126a4e 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -2,9 +2,12 @@ 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
+/// A basic JSON:API client.
+///
+/// The JSON:API [Request] is converted to [HttpRequest] and sent downstream
+/// using the [handler]. Received [HttpResponse] is then converted back to
+/// JSON:API [Response]. JSON conversion is performed by the [codec].
 class Client {
   const Client(
       {PayloadCodec codec = const PayloadCodec(),
@@ -15,8 +18,7 @@ class Client {
   final HttpHandler _http;
   final PayloadCodec _codec;
 
-  /// Sends the [request] to the server.
-  /// Throws a [RequestFailure] if the server responds with an error.
+  /// Sends the [request] to the given [uri].
   Future<Response> send(Uri uri, Request request) async {
     final body = await _encode(request.document);
     final response = await _http.handle(HttpRequest(
@@ -31,23 +33,13 @@ class Client {
         ...request.headers
       }));
 
-    final json = await _decode(response);
-    if (StatusCode(response.statusCode).isFailed) {
-      throw RequestFailure(response, json);
-    }
-    return Response(response, json);
+    final document = await _decode(response);
+    return Response(response, document);
   }
 
   Future<String> _encode(Object? doc) async =>
       doc == null ? '' : await _codec.encode(doc);
 
   Future<Map?> _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);
+      response.hasDocument ? await _codec.decode(response.body) : null;
 }
diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart
index 35b10aaf..e9fa7c22 100644
--- a/lib/src/client/request.dart
+++ b/lib/src/client/request.dart
@@ -1,21 +1,30 @@
 import 'package:json_api/http.dart';
 import 'package:json_api/query.dart';
+import 'package:json_api/src/client/client.dart';
 
-/// JSON:API request consumed by the client
+/// A generic JSON:API request.
 class Request with HttpHeaders {
+  /// Creates a new instance if the request with the specified HTTP [method]
+  /// and [document].
   Request(this.method, [this.document]);
 
+  /// Creates a GET request.
   Request.get() : this('get');
 
+  /// Creates a POST request.
   Request.post([Object? document]) : this('post', document);
 
+  /// Creates a DELETE request.
   Request.delete([Object? document]) : this('delete', document);
 
+  /// Creates a PATCH request.
   Request.patch([Object? document]) : this('patch', document);
 
   /// HTTP method
   final String method;
 
+  /// JSON:API document. This object can be of any type as long as it is
+  /// encodable by the [PayloadCodec] used in the [Client].
   final Object? document;
 
   /// Query parameters
diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart
index 4e8705fb..8c516221 100644
--- a/lib/src/client/response.dart
+++ b/lib/src/client/response.dart
@@ -1,11 +1,12 @@
-import 'package:json_api/http.dart' as h;
+import 'package:json_api/http.dart';
 
+/// A generic JSON:API response.
 class Response {
-  Response(this.http, this.json);
+  Response(this.http, this.document);
 
   /// HTTP response
-  final h.HttpResponse http;
+  final HttpResponse http;
 
-  /// Raw JSON response
-  final Map? json;
+  /// Decoded JSON document
+  final Map? document;
 }
diff --git a/lib/src/client/response/request_failure.dart b/lib/src/client/response/request_failure.dart
index 72934a9a..924c729d 100644
--- a/lib/src/client/response/request_failure.dart
+++ b/lib/src/client/response/request_failure.dart
@@ -3,10 +3,10 @@ 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());
+  RequestFailure(this.http, Map? document) {
+    if (document != null) {
+      errors.addAll(InboundDocument(document).errors());
+      meta.addAll(InboundDocument(document).meta());
     }
   }
 
diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index 13533832..48c8a2f9 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -1,3 +1,4 @@
+import 'package:json_api/client.dart';
 import 'package:json_api/document.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/src/client/client.dart';
@@ -13,10 +14,11 @@ import 'package:json_api/src/client/response/resource_updated.dart';
 
 /// A routing JSON:API client
 class RoutingClient {
-  RoutingClient(this._uri, {Client client = const Client()}) : _client = client;
+  RoutingClient(this.baseUri, {Client client = const Client()})
+      : _client = client;
 
   final Client _client;
-  final UriDesign _uri;
+  final UriDesign baseUri;
 
   /// Adds [identifiers] to a to-many relationship
   /// identified by [type], [id], [relationship].
@@ -30,11 +32,11 @@ class RoutingClient {
     List<Identifier> identifiers, {
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
         Request.post(OutboundDataDocument.many(ToMany(identifiers)))
           ..headers.addAll(headers));
-    return RelationshipUpdated.many(response.http, response.json);
+    return RelationshipUpdated.many(response.http, response.document);
   }
 
   /// Creates a new resource in the collection of type [type].
@@ -54,8 +56,8 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.collection(type),
+    final response = await send(
+        baseUri.collection(type),
         Request.post(OutboundDataDocument.newResource(NewResource(type)
           ..attributes.addAll(attributes)
           ..relationships.addAll({
@@ -66,7 +68,7 @@ class RoutingClient {
           ..headers.addAll(headers));
 
     return ResourceCreated(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   /// Deletes [identifiers] from a to-many relationship
@@ -81,12 +83,12 @@ class RoutingClient {
     List<Identifier> identifiers, {
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
         Request.delete(OutboundDataDocument.many(ToMany(identifiers)))
           ..headers.addAll(headers));
 
-    return RelationshipUpdated.many(response.http, response.json);
+    return RelationshipUpdated.many(response.http, response.document);
   }
 
   /// Fetches  a primary collection of type [type].
@@ -109,8 +111,8 @@ class RoutingClient {
     Iterable<String> sort = const [],
     Map<String, Iterable<String>> fields = const {},
   }) async {
-    final response = await _client.send(
-        _uri.collection(type),
+    final response = await send(
+        baseUri.collection(type),
         Request.get()
           ..headers.addAll(headers)
           ..query.addAll(query)
@@ -120,7 +122,7 @@ class RoutingClient {
           ..sort(sort)
           ..fields(fields));
     return CollectionFetched(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   /// Fetches a related resource collection
@@ -146,8 +148,8 @@ class RoutingClient {
     Map<String, Iterable<String>> fields = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await _client.send(
-        _uri.related(type, id, relationship),
+    final response = await send(
+        baseUri.related(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
           ..query.addAll(query)
@@ -157,7 +159,7 @@ class RoutingClient {
           ..sort(sort)
           ..fields(fields));
     return CollectionFetched(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   Future<RelationshipFetched<ToOne>> fetchToOne(
@@ -167,11 +169,10 @@ class RoutingClient {
     Map<String, String> headers = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(baseUri.relationship(type, id, relationship),
         Request.get()..headers.addAll(headers)..query.addAll(query));
     return RelationshipFetched.one(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   Future<RelationshipFetched<ToMany>> fetchToMany(
@@ -181,11 +182,10 @@ class RoutingClient {
     Map<String, String> headers = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(baseUri.relationship(type, id, relationship),
         Request.get()..headers.addAll(headers)..query.addAll(query));
     return RelationshipFetched.many(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   Future<RelatedResourceFetched> fetchRelatedResource(
@@ -198,8 +198,8 @@ class RoutingClient {
     Iterable<String> include = const [],
     Map<String, Iterable<String>> fields = const {},
   }) async {
-    final response = await _client.send(
-        _uri.related(type, id, relationship),
+    final response = await send(
+        baseUri.related(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
           ..query.addAll(query)
@@ -207,7 +207,7 @@ class RoutingClient {
           ..include(include)
           ..fields(fields));
     return RelatedResourceFetched(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   Future<ResourceFetched> fetchResource(
@@ -219,8 +219,8 @@ class RoutingClient {
     Map<String, Iterable<String>> fields = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await _client.send(
-        _uri.resource(type, id),
+    final response = await send(
+        baseUri.resource(type, id),
         Request.get()
           ..headers.addAll(headers)
           ..query.addAll(query)
@@ -229,7 +229,7 @@ class RoutingClient {
           ..fields(fields));
 
     return ResourceFetched(
-        response.http, response.json ?? (throw FormatException()));
+        response.http, response.document ?? (throw FormatException()));
   }
 
   Future<ResourceUpdated> updateResource(String type, String id,
@@ -238,8 +238,8 @@ class RoutingClient {
       Map<String, Iterable<Identifier>> many = const {},
       Map<String, Object?> meta = const {},
       Map<String, String> headers = const {}}) async {
-    final response = await _client.send(
-        _uri.resource(type, id),
+    final response = await send(
+        baseUri.resource(type, id),
         Request.patch(OutboundDataDocument.resource(Resource(type, id)
           ..attributes.addAll(attributes)
           ..relationships.addAll({
@@ -248,7 +248,7 @@ class RoutingClient {
           })
           ..meta.addAll(meta)))
           ..headers.addAll(headers));
-    return ResourceUpdated(response.http, response.json);
+    return ResourceUpdated(response.http, response.document);
   }
 
   /// Creates a new resource with the given id on the server.
@@ -261,8 +261,8 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.collection(type),
+    final response = await send(
+        baseUri.collection(type),
         Request.post(OutboundDataDocument.resource(Resource(type, id)
           ..attributes.addAll(attributes)
           ..relationships.addAll({
@@ -271,7 +271,7 @@ class RoutingClient {
           })
           ..meta.addAll(meta)))
           ..headers.addAll(headers));
-    return ResourceUpdated(response.http, response.json);
+    return ResourceUpdated(response.http, response.document);
   }
 
   Future<RelationshipUpdated<ToOne>> replaceToOne(
@@ -281,11 +281,11 @@ class RoutingClient {
     Identifier identifier, {
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
         Request.patch(OutboundDataDocument.one(ToOne(identifier)))
           ..headers.addAll(headers));
-    return RelationshipUpdated.one(response.http, response.json);
+    return RelationshipUpdated.one(response.http, response.document);
   }
 
   Future<RelationshipUpdated<ToMany>> replaceToMany(
@@ -295,23 +295,31 @@ class RoutingClient {
     Iterable<Identifier> identifiers, {
     Map<String, String> headers = const {},
   }) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
         Request.patch(OutboundDataDocument.many(ToMany(identifiers)))
           ..headers.addAll(headers));
-    return RelationshipUpdated.many(response.http, response.json);
+    return RelationshipUpdated.many(response.http, response.document);
   }
 
   Future<RelationshipUpdated<ToOne>> deleteToOne(
       String type, String id, String relationship,
       {Map<String, String> headers = const {}}) async {
-    final response = await _client.send(
-        _uri.relationship(type, id, relationship),
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
         Request.patch(OutboundDataDocument.one(ToOne.empty()))
           ..headers.addAll(headers));
-    return RelationshipUpdated.one(response.http, response.json);
+    return RelationshipUpdated.one(response.http, response.document);
   }
 
   Future<Response> deleteResource(String type, String id) =>
-      _client.send(_uri.resource(type, id), Request.delete());
+      send(baseUri.resource(type, id), Request.delete());
+
+  Future<Response> send(Uri uri, Request request) async {
+    final response = await _client.send(uri, request);
+    if (response.http.isFailed) {
+      throw RequestFailure(response.http, response.document);
+    }
+    return response;
+  }
 }
diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart
index aded1a14..381b7645 100644
--- a/lib/src/document/outbound_document.dart
+++ b/lib/src/document/outbound_document.dart
@@ -2,15 +2,16 @@ 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.
+/// A sever-to-client document.
 class OutboundDocument {
   /// The document "meta" object.
   final meta = <String, Object?>{};
 
+  /// Returns the JSON representation.
   Map<String, Object?> toJson() => {'meta': meta};
 }
 
-/// An outbound error document.
+/// A sever-to-client document with errors.
 class OutboundErrorDocument extends OutboundDocument {
   OutboundErrorDocument(Iterable<ErrorObject> errors) {
     this.errors.addAll(errors);
@@ -26,31 +27,32 @@ class OutboundErrorDocument extends OutboundDocument {
       };
 }
 
-/// An outbound data document.
+/// A sever-to-client document with data.
 class OutboundDataDocument extends OutboundDocument {
   /// Creates an instance of a document containing a single resource as the primary data.
-  OutboundDataDocument.resource(Resource? resource) : _data = resource;
+  OutboundDataDocument.resource(Resource? this.data);
 
   /// 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;
+  OutboundDataDocument.newResource(NewResource this.data);
 
   /// Creates an instance of a document containing a collection of resources as the primary data.
   OutboundDataDocument.collection(Iterable<Resource> collection)
-      : _data = collection.toList();
+      : data = collection.toList();
 
   /// Creates an instance of a document containing a to-one relationship.
-  OutboundDataDocument.one(ToOne one) : _data = one.identifier {
+  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() {
+  OutboundDataDocument.many(ToMany many) : data = many.toList() {
     meta.addAll(many.meta);
     links.addAll(many.links);
   }
 
-  final Object? _data;
+  /// Document data.
+  final Object? data;
 
   /// Links related to the primary data.
   final links = <String, Link>{};
@@ -60,7 +62,7 @@ class OutboundDataDocument extends OutboundDocument {
 
   @override
   Map<String, Object?> toJson() => {
-        'data': _data,
+        'data': data,
         if (links.isNotEmpty) 'links': links,
         if (included.isNotEmpty) 'included': included,
         if (meta.isNotEmpty) 'meta': meta,
diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response.dart
index b0b23a32..76c39617 100644
--- a/lib/src/http/http_response.dart
+++ b/lib/src/http/http_response.dart
@@ -1,4 +1,6 @@
 import 'package:json_api/src/http/http_message.dart';
+import 'package:json_api/src/http/media_type.dart';
+import 'package:json_api/src/http/status_code.dart';
 
 /// The response sent by the server and received by the client
 class HttpResponse extends HttpMessage {
@@ -6,4 +8,13 @@ class HttpResponse extends HttpMessage {
 
   /// Response status code
   final int statusCode;
+
+  /// True if the body is not empty and the Content-Type
+  /// is `application/vnd.api+json`
+  bool get hasDocument =>
+      body.isNotEmpty &&
+      (headers['Content-Type'] ?? '').toLowerCase().startsWith(mediaType);
+
+  /// Returns true if the [statusCode] represents a failure
+  bool get isFailed => StatusCode(statusCode).isFailed;
 }
diff --git a/lib/src/http/payload_codec.dart b/lib/src/http/payload_codec.dart
index 9c6d272a..e3ed74be 100644
--- a/lib/src/http/payload_codec.dart
+++ b/lib/src/http/payload_codec.dart
@@ -1,14 +1,20 @@
+import 'dart:async';
 import 'dart:convert';
 
-/// Encodes/decodes JSON payload
+/// Encodes/decodes JSON payload.
+///
+/// The methods are designed to be asynchronous to allow for conversion to be
+/// performed in isolates if needed.
 class PayloadCodec {
   const PayloadCodec();
 
-  Future<Map> decode(String body) async {
-    final json = jsonDecode(body);
-    if (json is Map) return json;
-    throw FormatException('Invalid JSON payload: ${json.runtimeType}');
+  /// Decodes a JSON string into a Map
+  FutureOr<Map> decode(String json) {
+    final decoded = jsonDecode(json);
+    if (decoded is Map) return decoded;
+    throw FormatException('Invalid JSON payload: ${decoded.runtimeType}');
   }
 
-  Future<String> encode(Object document) async => jsonEncode(document);
+  /// Encodes a JSON:API document into a JSON string.
+  FutureOr<String> encode(Object document) => jsonEncode(document);
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index adefbcfb..3125a85e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.0-rc.1
+version: 5.0.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:
@@ -16,3 +16,8 @@ dev_dependencies:
   uuid: ^3.0.0
   coverage: ^1.0.2
   check_coverage: ^0.0.2
+
+cider:
+  link_template:
+    diff: https://github.com/f3ath/json-api-dart/compare/%from%...%to%
+    tag: https://github.com/f3ath/json-api-dart/releases/tag/%tag%
\ No newline at end of file
diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart
index 559e93ae..d399bcbd 100644
--- a/test/contract/errors_test.dart
+++ b/test/contract/errors_test.dart
@@ -21,12 +21,8 @@ void main() {
             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);
-        }
+        final response = await action();
+        expect(response.http.statusCode, 405);
       }
     });
     test('Bad request when target can not be matched', () async {

From b2c79f7ba5a4c093fb7b49cb4765fcaa44fee4bd Mon Sep 17 00:00:00 2001
From: TeChengHung <dan12411@gmail.com>
Date: Sat, 8 May 2021 11:16:45 +0800
Subject: [PATCH 04/25] Add missing handlers (#114)

---
 lib/client.dart | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/lib/client.dart b/lib/client.dart
index 9a383cba..4ab25dcd 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -24,6 +24,8 @@
 library client;
 
 export 'package:json_api/src/client/client.dart';
+export 'package:json_api/src/client/disposable_handler.dart';
+export 'package:json_api/src/client/persistent_handler.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';

From db26bccda0a7090791d0473c8b8993cb480de23c Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Tue, 11 May 2021 11:16:42 -0700
Subject: [PATCH 05/25] Release 5.0.1 (#115)

---
 CHANGELOG.md                                       | 14 ++++++++++----
 pubspec.yaml                                       |  2 +-
 .../http/{enoding_test.dart => encoding_test.dart} |  2 +-
 3 files changed, 12 insertions(+), 6 deletions(-)
 rename test/unit/http/{enoding_test.dart => encoding_test.dart} (94%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41100ed4..4f914e93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.0.1] - 2021-05-11
+### Fixed
+- Missing http client exports
+- Failing test
+
 ## [5.0.0] - 2021-04-21
 ### Added
 - Sound null-safety support.
@@ -121,14 +126,14 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https
 ## [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.
@@ -187,6 +192,7 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.0.1]: https://github.com/f3ath/json-api-dart/compare/5.0.0...5.0.1
 [5.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.3...5.0.0
 [3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2...3.2.3
 [4.3.0]: https://github.com/f3ath/json-api-dart/compare/4.2.2...4.3.0
diff --git a/pubspec.yaml b/pubspec.yaml
index 3125a85e..f4f64103 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.0
+version: 5.0.1
 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:
diff --git a/test/unit/http/enoding_test.dart b/test/unit/http/encoding_test.dart
similarity index 94%
rename from test/unit/http/enoding_test.dart
rename to test/unit/http/encoding_test.dart
index 173c1848..e0de8284 100644
--- a/test/unit/http/enoding_test.dart
+++ b/test/unit/http/encoding_test.dart
@@ -26,7 +26,7 @@ void main() {
         defaultEncoding: encoding,
       );
 
-      return dartHttp.handle(HttpRequest('', Uri.parse('http://test.com')));
+      return dartHttp.handle(HttpRequest('get', Uri.parse('http://test.com')));
     };
 
     test('UTF-8 ru', () async {

From feabfc56305e4df65b7e64d1df9baee6d1db769b Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Wed, 12 May 2021 18:50:21 -0700
Subject: [PATCH 06/25] Fix for #116 (#117)

---
 CHANGELOG.md                           | 5 +++++
 lib/src/client/persistent_handler.dart | 3 ++-
 pubspec.yaml                           | 2 +-
 3 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f914e93..48a765a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.0.2] - 2021-05-12
+### Fixed
+- PersistentHandler to implement HttpHandler
+
 ## [5.0.1] - 2021-05-11
 ### Fixed
 - Missing http client exports
@@ -192,6 +196,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.0.2]: https://github.com/f3ath/json-api-dart/compare/5.0.1...5.0.2
 [5.0.1]: https://github.com/f3ath/json-api-dart/compare/5.0.0...5.0.1
 [5.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.3...5.0.0
 [3.2.3]: https://github.com/f3ath/json-api-dart/compare/3.2.2...3.2.3
diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart
index 6974a8bb..117b9003 100644
--- a/lib/src/client/persistent_handler.dart
+++ b/lib/src/client/persistent_handler.dart
@@ -7,7 +7,7 @@ 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 {
+class PersistentHandler implements HttpHandler {
   /// 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, {this.defaultEncoding = utf8});
@@ -15,6 +15,7 @@ class PersistentHandler {
   final Client client;
   final Encoding defaultEncoding;
 
+  @override
   Future<HttpResponse> handle(HttpRequest request) async {
     final response = await Response.fromStream(
         await client.send(Request(request.method, request.uri)
diff --git a/pubspec.yaml b/pubspec.yaml
index f4f64103..9e223949 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.1
+version: 5.0.2
 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:

From 223b41f49c77e25d76a4a0a2734e180af534fffa Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Wed, 19 May 2021 11:09:29 -0700
Subject: [PATCH 07/25] Update link_test.dart

---
 test/unit/document/link_test.dart | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/unit/document/link_test.dart b/test/unit/document/link_test.dart
index 2d7489c3..2f527485 100644
--- a/test/unit/document/link_test.dart
+++ b/test/unit/document/link_test.dart
@@ -1,5 +1,4 @@
 import 'dart:convert';
-import 'dart:io';
 
 import 'package:json_api/document.dart';
 import 'package:test/test.dart';

From 50111be2be6799bec2c8ad25c8e04397b494b925 Mon Sep 17 00:00:00 2001
From: TeChengHung <dan12411@gmail.com>
Date: Thu, 20 May 2021 02:13:48 +0800
Subject: [PATCH 08/25] Add documentMeta parameter in `RoutingClient` createNew
 method (#119)

---
 lib/src/client/routing_client.dart | 5 ++++-
 test/unit/client/client_test.dart  | 5 ++++-
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index 48c8a2f9..f20bea6d 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -47,6 +47,7 @@ class RoutingClient {
   /// - [one] - resource to-one relationships
   /// - [many] - resource to-many relationships
   /// - [meta] - resource meta data
+  /// - [documentMeta] - document meta
   /// - [headers] - any extra HTTP headers
   Future<ResourceCreated> createNew(
     String type, {
@@ -54,6 +55,7 @@ class RoutingClient {
     Map<String, Identifier> one = const {},
     Map<String, Iterable<Identifier>> many = const {},
     Map<String, Object?> meta = const {},
+    Map<String, Object?> documentMeta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
@@ -64,7 +66,8 @@ class RoutingClient {
             ...one.map((key, value) => MapEntry(key, ToOne(value))),
             ...many.map((key, value) => MapEntry(key, ToMany(value))),
           })
-          ..meta.addAll(meta)))
+          ..meta.addAll(meta))
+          ..meta.addAll(documentMeta))
           ..headers.addAll(headers));
 
     return ResourceCreated(
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index eb27eb31..b72fd696 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -256,6 +256,8 @@ void main() {
         'tags': [Identifier('tags', '1'), Identifier('tags', '2')]
       }, meta: {
         'answer': 42
+      }, documentMeta: {
+        'hello': 'world'
       }, headers: {
         'foo': 'bar'
       });
@@ -290,7 +292,8 @@ void main() {
             }
           },
           'meta': {'answer': 42}
-        }
+        },
+        'meta': {'hello': 'world'}
       });
     });
   });

From 9b8a46a36c85d734c4069e17e6874cef62334b25 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Wed, 19 May 2021 21:42:11 -0700
Subject: [PATCH 09/25] Release 5.0.3 (#120)

---
 CHANGELOG.md                       |  5 ++++
 example/README.md                  |  4 +++
 lib/src/client/routing_client.dart | 39 +++++++++++++++++++++---------
 pubspec.yaml                       |  2 +-
 test/unit/client/client_test.dart  | 30 +++++++++++++++--------
 5 files changed, 57 insertions(+), 23 deletions(-)
 create mode 100644 example/README.md

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48a765a6..68d9c20c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.0.3] - 2021-05-19
+### Fixed
+- Missing "meta" arguments in RoutingClient
+
 ## [5.0.2] - 2021-05-12
 ### Fixed
 - PersistentHandler to implement HttpHandler
@@ -196,6 +200,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.0.3]: https://github.com/f3ath/json-api-dart/compare/5.0.2...5.0.3
 [5.0.2]: https://github.com/f3ath/json-api-dart/compare/5.0.1...5.0.2
 [5.0.1]: https://github.com/f3ath/json-api-dart/compare/5.0.0...5.0.1
 [5.0.0]: https://github.com/f3ath/json-api-dart/compare/3.2.3...5.0.0
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 00000000..a3264887
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,4 @@
+The client and server examples are meant to run together. 
+
+- Open a new terminal window and run `dart server.dart`
+- While the server is running, open another window and run `dart client.dart`
\ No newline at end of file
diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index f20bea6d..2ab0d258 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -30,11 +30,13 @@ class RoutingClient {
     String id,
     String relationship,
     List<Identifier> identifiers, {
+    Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
         baseUri.relationship(type, id, relationship),
-        Request.post(OutboundDataDocument.many(ToMany(identifiers)))
+        Request.post(
+            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
           ..headers.addAll(headers));
     return RelationshipUpdated.many(response.http, response.document);
   }
@@ -84,11 +86,13 @@ class RoutingClient {
     String id,
     String relationship,
     List<Identifier> identifiers, {
+    Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
         baseUri.relationship(type, id, relationship),
-        Request.delete(OutboundDataDocument.many(ToMany(identifiers)))
+        Request.delete(
+            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
           ..headers.addAll(headers));
 
     return RelationshipUpdated.many(response.http, response.document);
@@ -235,12 +239,16 @@ class RoutingClient {
         response.http, response.document ?? (throw FormatException()));
   }
 
-  Future<ResourceUpdated> updateResource(String type, String id,
-      {Map<String, Object?> attributes = const {},
-      Map<String, Identifier> one = const {},
-      Map<String, Iterable<Identifier>> many = const {},
-      Map<String, Object?> meta = const {},
-      Map<String, String> headers = const {}}) async {
+  Future<ResourceUpdated> updateResource(
+    String type,
+    String id, {
+    Map<String, Object?> attributes = const {},
+    Map<String, Identifier> one = const {},
+    Map<String, Iterable<Identifier>> many = const {},
+    Map<String, Object?> meta = const {},
+    Map<String, Object?> documentMeta = const {},
+    Map<String, String> headers = const {},
+  }) async {
     final response = await send(
         baseUri.resource(type, id),
         Request.patch(OutboundDataDocument.resource(Resource(type, id)
@@ -249,7 +257,8 @@ class RoutingClient {
             ...one.map((key, value) => MapEntry(key, ToOne(value))),
             ...many.map((key, value) => MapEntry(key, ToMany(value))),
           })
-          ..meta.addAll(meta)))
+          ..meta.addAll(meta))
+          ..meta.addAll(documentMeta))
           ..headers.addAll(headers));
     return ResourceUpdated(response.http, response.document);
   }
@@ -262,6 +271,7 @@ class RoutingClient {
     Map<String, Identifier> one = const {},
     Map<String, Iterable<Identifier>> many = const {},
     Map<String, Object?> meta = const {},
+    Map<String, Object?> documentMeta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
@@ -272,7 +282,8 @@ class RoutingClient {
             ...one.map((key, value) => MapEntry(key, ToOne(value))),
             ...many.map((key, value) => MapEntry(key, ToMany(value))),
           })
-          ..meta.addAll(meta)))
+          ..meta.addAll(meta))
+          ..meta.addAll(documentMeta))
           ..headers.addAll(headers));
     return ResourceUpdated(response.http, response.document);
   }
@@ -282,11 +293,13 @@ class RoutingClient {
     String id,
     String relationship,
     Identifier identifier, {
+    Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
         baseUri.relationship(type, id, relationship),
-        Request.patch(OutboundDataDocument.one(ToOne(identifier)))
+        Request.patch(
+            OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta)))
           ..headers.addAll(headers));
     return RelationshipUpdated.one(response.http, response.document);
   }
@@ -296,11 +309,13 @@ class RoutingClient {
     String id,
     String relationship,
     Iterable<Identifier> identifiers, {
+    Map<String, Object?> meta = const {},
     Map<String, String> headers = const {},
   }) async {
     final response = await send(
         baseUri.relationship(type, id, relationship),
-        Request.patch(OutboundDataDocument.many(ToMany(identifiers)))
+        Request.patch(
+            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
           ..headers.addAll(headers));
     return RelationshipUpdated.many(response.http, response.document);
   }
diff --git a/pubspec.yaml b/pubspec.yaml
index 9e223949..f9ae6193 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.2
+version: 5.0.3
 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:
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index b72fd696..ec4f3737 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -339,6 +339,8 @@ void main() {
         'tags': [Identifier('tags', '1'), Identifier('tags', '2')]
       }, meta: {
         'answer': 42
+      }, documentMeta: {
+        'hello': 'world'
       }, headers: {
         'foo': 'bar'
       });
@@ -371,7 +373,8 @@ void main() {
             }
           },
           'meta': {'answer': 42}
-        }
+        },
+        'meta': {'hello': 'world'}
       });
     });
   });
@@ -418,6 +421,8 @@ void main() {
         'tags': [Identifier('tags', '1'), Identifier('tags', '2')]
       }, meta: {
         'answer': 42
+      }, documentMeta: {
+        'hello': 'world'
       }, headers: {
         'foo': 'bar'
       });
@@ -450,7 +455,8 @@ void main() {
             }
           },
           'meta': {'answer': 42}
-        }
+        },
+        'meta': {'hello': 'world'}
       });
     });
   });
@@ -476,7 +482,7 @@ void main() {
       http.response = mock.one;
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'),
-          headers: {'foo': 'bar'});
+          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
       expect(response.relationship, isA<ToOne>());
       expect(http.request.method, 'patch');
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
@@ -486,7 +492,8 @@ void main() {
         'foo': 'bar'
       });
       expect(jsonDecode(http.request.body), {
-        'data': {'type': 'people', 'id': '42'}
+        'data': {'type': 'people', 'id': '42'},
+        'meta': {'hello': 'world'}
       });
     });
 
@@ -583,7 +590,7 @@ void main() {
       http.response = mock.many;
       final response = await client.deleteFromMany(
           'articles', '1', 'tags', [Identifier('tags', '1')],
-          headers: {'foo': 'bar'});
+          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
       expect(response.relationship, isA<ToMany>());
       expect(http.request.method, 'delete');
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
@@ -595,7 +602,8 @@ void main() {
       expect(jsonDecode(http.request.body), {
         'data': [
           {'type': 'tags', 'id': '1'}
-        ]
+        ],
+        'meta': {'hello': 'world'}
       });
     });
 
@@ -643,7 +651,7 @@ void main() {
       http.response = mock.many;
       final response = await client.replaceToMany(
           'articles', '1', 'tags', [Identifier('tags', '1')],
-          headers: {'foo': 'bar'});
+          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
       expect(response.relationship, isA<ToMany>());
       expect(http.request.method, 'patch');
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
@@ -655,7 +663,8 @@ void main() {
       expect(jsonDecode(http.request.body), {
         'data': [
           {'type': 'tags', 'id': '1'}
-        ]
+        ],
+        'meta': {'hello': 'world'}
       });
     });
 
@@ -703,7 +712,7 @@ void main() {
       http.response = mock.many;
       final response = await client.addMany(
           'articles', '1', 'tags', [Identifier('tags', '1')],
-          headers: {'foo': 'bar'});
+          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
       expect(response.relationship, isA<ToMany>());
       expect(http.request.method, 'post');
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
@@ -715,7 +724,8 @@ void main() {
       expect(jsonDecode(http.request.body), {
         'data': [
           {'type': 'tags', 'id': '1'}
-        ]
+        ],
+        'meta': {'hello': 'world'}
       });
     });
 

From 13816de549e012f0adfd4cec5d58753613a28c35 Mon Sep 17 00:00:00 2001
From: TeChengHung <dan12411@gmail.com>
Date: Fri, 21 May 2021 08:49:35 +0800
Subject: [PATCH 10/25] Add meta property in `ResourceCreated` (#121)

---
 lib/src/client/response/resource_created.dart | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart
index ad3c88c8..00dab5a6 100644
--- a/lib/src/client/response/resource_created.dart
+++ b/lib/src/client/response/resource_created.dart
@@ -8,6 +8,7 @@ import 'package:json_api/http.dart';
 class ResourceCreated {
   ResourceCreated(this.http, Map json)
       : resource = InboundDocument(json).dataAsResource() {
+    meta.addAll(InboundDocument(json).meta());
     links.addAll(InboundDocument(json).links());
     included.addAll(InboundDocument(json).included());
   }
@@ -16,6 +17,7 @@ class ResourceCreated {
 
   /// Created resource.
   final Resource resource;
+  final meta = <String, Object?>{};
   final links = <String, Link>{};
 
   /// Included resources

From 821a386affd3ae3c7704148717531c9b150bbe14 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Thu, 20 May 2021 23:28:45 -0700
Subject: [PATCH 11/25] Release 5.0.4 (#122)

---
 CHANGELOG.md                                  |  5 +++++
 lib/src/client/response/resource_created.dart |  4 ++++
 lib/src/client/response/resource_fetched.dart |  6 ++++--
 lib/src/client/response/resource_updated.dart | 17 ++++++++++++++++-
 pubspec.yaml                                  |  2 +-
 test/unit/client/client_test.dart             | 14 ++++++++++++++
 test/unit/client/response.dart                |  7 +++++++
 7 files changed, 51 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68d9c20c..d6b1b199 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.0.4] - 2021-05-20
+### Fixed
+- Missing meta properties in responses
+
 ## [5.0.3] - 2021-05-19
 ### Fixed
 - Missing "meta" arguments in RoutingClient
@@ -200,6 +204,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.0.4]: https://github.com/f3ath/json-api-dart/compare/5.0.3...5.0.4
 [5.0.3]: https://github.com/f3ath/json-api-dart/compare/5.0.2...5.0.3
 [5.0.2]: https://github.com/f3ath/json-api-dart/compare/5.0.1...5.0.2
 [5.0.1]: https://github.com/f3ath/json-api-dart/compare/5.0.0...5.0.1
diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart
index 00dab5a6..7cd3069d 100644
--- a/lib/src/client/response/resource_created.dart
+++ b/lib/src/client/response/resource_created.dart
@@ -17,7 +17,11 @@ class ResourceCreated {
 
   /// Created resource.
   final Resource resource;
+
+  /// Top-level meta data
   final meta = <String, Object?>{};
+
+  /// Top-level links
   final links = <String, Link>{};
 
   /// Included resources
diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart
index 76d656ef..8cb44966 100644
--- a/lib/src/client/response/resource_fetched.dart
+++ b/lib/src/client/response/resource_fetched.dart
@@ -12,11 +12,13 @@ class ResourceFetched {
 
   final HttpResponse http;
   final Resource resource;
-  final included = ResourceCollection();
 
   /// Top-level meta data
   final meta = <String, Object?>{};
 
-  /// Top-level links object
+  /// Top-level links
   final links = <String, Link>{};
+
+  /// Included resources
+  final included = ResourceCollection();
 }
diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart
index cfd6c95c..8ca5a2d5 100644
--- a/lib/src/client/response/resource_updated.dart
+++ b/lib/src/client/response/resource_updated.dart
@@ -2,7 +2,13 @@ import 'package:json_api/document.dart';
 import 'package:json_api/http.dart';
 
 class ResourceUpdated {
-  ResourceUpdated(this.http, Map? json) : resource = _resource(json);
+  ResourceUpdated(this.http, Map? json) : resource = _resource(json) {
+    if (json != null) {
+      included.addAll(InboundDocument(json).included());
+      meta.addAll(InboundDocument(json).meta());
+      links.addAll(InboundDocument(json).links());
+    }
+  }
 
   static Resource? _resource(Map? json) {
     if (json != null) {
@@ -17,4 +23,13 @@ class ResourceUpdated {
 
   /// The created resource. Null for "204 No Content" responses.
   late final Resource? resource;
+
+  /// Top-level meta data
+  final meta = <String, Object?>{};
+
+  /// Top-level links
+  final links = <String, Link>{};
+
+  /// Included resources
+  final included = ResourceCollection();
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index f9ae6193..5d81ba9f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.3
+version: 5.0.4
 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:
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index ec4f3737..c01b5180 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -79,6 +79,8 @@ void main() {
     });
     expect(http.request.headers,
         {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+
+    expect(response.meta, {'hello': 'world'});
   });
 
   group('Fetch Related Collection', () {
@@ -123,6 +125,8 @@ void main() {
       });
       expect(http.request.headers,
           {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+
+      expect(response.meta, {'hello': 'world'});
     });
   });
 
@@ -155,6 +159,8 @@ void main() {
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
       expect(http.request.headers,
           {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+
+      expect(response.meta, {'hello': 'world'});
     });
   });
 
@@ -190,6 +196,8 @@ void main() {
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
       expect(http.request.headers,
           {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+
+      expect(response.meta, {'hello': 'world'});
     });
 
     test('Missing resource', () async {
@@ -295,6 +303,8 @@ void main() {
         },
         'meta': {'hello': 'world'}
       });
+
+      expect(response.meta, {'hello': 'world'});
     });
   });
 
@@ -376,6 +386,8 @@ void main() {
         },
         'meta': {'hello': 'world'}
       });
+
+      expect(response.meta, {'hello': 'world'});
     });
   });
 
@@ -458,6 +470,8 @@ void main() {
         },
         'meta': {'hello': 'world'}
       });
+
+      expect(response.meta, {'hello': 'world'});
     });
   });
 
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index 5fbb377c..613a7914 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -18,6 +18,7 @@ final collectionFull = HttpResponse(200,
         'next': 'http://example.com/articles?page[offset]=2',
         'last': 'http://example.com/articles?page[offset]=10'
       },
+      'meta': {'hello': 'world'},
       'data': [
         {
           'type': 'articles',
@@ -85,6 +86,7 @@ final collectionFull = HttpResponse(200,
 final primaryResource = HttpResponse(200,
     body: jsonEncode({
       'links': {'self': 'http://example.com/articles/1'},
+      'meta': {'hello': 'world'},
       'data': {
         'type': 'articles',
         'id': '1',
@@ -134,6 +136,7 @@ final primaryResource = HttpResponse(200,
 final relatedResourceNull = HttpResponse(200,
     body: jsonEncode({
       'links': {'self': 'http://example.com/articles/1/author'},
+      'meta': {'hello': 'world'},
       'data': null
     }))
   ..headers.addAll({'Content-Type': mediaType});
@@ -143,6 +146,7 @@ final one = HttpResponse(200,
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
       },
+      'meta': {'hello': 'world'},
       'data': {'type': 'people', 'id': '12'},
       'included': [
         {
@@ -187,6 +191,7 @@ final oneEmpty = HttpResponse(200,
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
       },
+      'meta': {'hello': 'world'},
       'data': null,
       'included': [
         {
@@ -231,6 +236,7 @@ final many = HttpResponse(200,
         'self': '/articles/1/relationships/tags',
         'related': '/articles/1/tags'
       },
+      'meta': {'hello': 'world'},
       'data': [
         {'type': 'tags', 'id': '12'}
       ]
@@ -241,6 +247,7 @@ final noContent = HttpResponse(204);
 
 final error422 = HttpResponse(422,
     body: jsonEncode({
+      'meta': {'hello': 'world'},
       'errors': [
         {
           'status': '422',

From 58f7d632567baaf13265d2d77b2e1fb919521e62 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Mon, 19 Jul 2021 14:35:54 -0700
Subject: [PATCH 12/25] Links with null values (#124)

---
 CHANGELOG.md                                  | 5 +++++
 lib/src/document/inbound_document.dart        | 8 +++++---
 pubspec.yaml                                  | 2 +-
 test/unit/document/inbound_document_test.dart | 8 ++++++++
 test/unit/document/payload.dart               | 9 +++++++++
 5 files changed, 28 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6b1b199..89a17ca7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.0.5] - 2021-07-19
+### Fixed
+- Pagination with null values crashes json parser (#123)
+
 ## [5.0.4] - 2021-05-20
 ### Fixed
 - Missing meta properties in responses
@@ -204,6 +208,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.0.5]: https://github.com/f3ath/json-api-dart/compare/5.0.4...5.0.5
 [5.0.4]: https://github.com/f3ath/json-api-dart/compare/5.0.3...5.0.4
 [5.0.3]: https://github.com/f3ath/json-api-dart/compare/5.0.2...5.0.3
 [5.0.2]: https://github.com/f3ath/json-api-dart/compare/5.0.1...5.0.2
diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart
index 1c169524..2d5b30a7 100644
--- a/lib/src/document/inbound_document.dart
+++ b/lib/src/document/inbound_document.dart
@@ -67,9 +67,11 @@ class _Parser {
   Map<String, Object?> meta(Map json) =>
       json.get<Map<String, Object?>>('meta', orGet: () => {});
 
-  Map<String, Link> links(Map json) => json
-      .get<Map>('links', orGet: () => {})
-      .map((k, v) => MapEntry(k.toString(), _link(v)));
+  Map<String, Link> links(Map json) {
+    var links = json.get<Map>('links', orGet: () => {});
+    links.removeWhere((key, value) => value == null);
+    return links.map((k, v) => MapEntry(k.toString(), _link(v)));
+  }
 
   Relationship relationship(Map json) {
     final rel = json.containsKey('data') ? _rel(json['data']) : Relationship();
diff --git a/pubspec.yaml b/pubspec.yaml
index 5d81ba9f..f875176d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.0.4
+version: 5.0.5
 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:
diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart
index 75669439..0123e389 100644
--- a/test/unit/document/inbound_document_test.dart
+++ b/test/unit/document/inbound_document_test.dart
@@ -78,6 +78,14 @@ void main() {
         expect(doc.meta(), isEmpty);
       });
 
+      test('can parse the null link', () {
+        final doc = InboundDocument(payload.nullLink);
+        expect(doc.links()['self'].toString(), 'http://example.com/articles');
+        expect(doc.links()['prev'], isNull);
+        expect(doc.links()['next'], isNull);
+        expect(doc.links()['foobar'], isNull);
+      });
+
       test('can parse primary resource', () {
         final doc = InboundDocument(payload.resource);
         final article = doc.dataAsResource();
diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart
index fc1758c2..c7d6cb06 100644
--- a/test/unit/document/payload.dart
+++ b/test/unit/document/payload.dart
@@ -67,6 +67,15 @@ final example = {
   ]
 };
 
+final nullLink = {
+  'links': {
+    'self': 'http://example.com/articles',
+    'next': null,
+    'last': null,
+  },
+  'data': []
+};
+
 final newResource = {
   'data': {
     'type': 'articles',

From 16f2bc04d872496951bc53efc9b68c0381565a4b Mon Sep 17 00:00:00 2001
From: Kara <f3ath@users.noreply.github.com>
Date: Thu, 12 May 2022 19:28:59 -0700
Subject: [PATCH 13/25] v5.1.0 (#125)

---
 .github/workflows/dart.yml                    |  6 +++---
 CHANGELOG.md                                  |  6 ++++++
 analysis_options.yaml                         |  2 +-
 example/client.dart                           |  4 +++-
 example/server.dart                           |  4 ++--
 example/server/in_memory_repo.dart            |  6 +++---
 lib/src/client/response/resource_updated.dart |  1 +
 lib/src/client/routing_client.dart            | 16 +++++++++++-----
 lib/src/document/inbound_document.dart        |  1 -
 lib/src/document/many.dart                    | 11 ++++++-----
 lib/src/document/outbound_document.dart       |  5 ++++-
 lib/src/document/relationship.dart            |  2 +-
 lib/src/document/resource_properties.dart     |  1 +
 lib/src/query/filter.dart                     |  2 +-
 lib/src/query/page.dart                       |  2 +-
 pubspec.yaml                                  | 14 +++++++-------
 test/unit/client/response.dart                |  1 -
 test/unit/http/encoding_test.dart             |  4 ++--
 18 files changed, 53 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index 532f2059..0297fc11 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -12,17 +12,17 @@ jobs:
     runs-on: ubuntu-latest
 
     container:
-      image:  google/dart:latest
+      image:  dart:stable
 
     steps:
       - uses: actions/checkout@v2
       - name: Install dependencies
         run: dart pub get
       - name: Format
-        run: dartfmt --dry-run --set-exit-if-changed lib test
+        run: dart format --output none --set-exit-if-changed example 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
+        run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89a17ca7..df84aa26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.1.0] - 2022-05-11
+### Changed
+- Dependency versions bump
+- Minor formatting improvements
+
 ## [5.0.5] - 2021-07-19
 ### Fixed
 - Pagination with null values crashes json parser (#123)
@@ -208,6 +213,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.1.0]: https://github.com/f3ath/json-api-dart/compare/5.0.5...5.1.0
 [5.0.5]: https://github.com/f3ath/json-api-dart/compare/5.0.4...5.0.5
 [5.0.4]: https://github.com/f3ath/json-api-dart/compare/5.0.3...5.0.4
 [5.0.3]: https://github.com/f3ath/json-api-dart/compare/5.0.2...5.0.3
diff --git a/analysis_options.yaml b/analysis_options.yaml
index d9406f33..1ccb251c 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,4 +1,4 @@
-include: package:pedantic/analysis_options.yaml
+include: package:lints/recommended.yaml
 linter:
   rules:
     - sort_constructors_first
diff --git a/example/client.dart b/example/client.dart
index 6e9c04a4..1a7df5a2 100644
--- a/example/client.dart
+++ b/example/client.dart
@@ -27,6 +27,8 @@ void main() async {
     });
   } on RequestFailure catch (e) {
     /// Catch error response
-    e.errors.forEach((error) => print('${error.title}'));
+    for (var error in e.errors) {
+      print(error.title);
+    }
   }
 }
diff --git a/example/server.dart b/example/server.dart
index fbb647bd..9293f902 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -35,9 +35,9 @@ Future<void> main() async {
 
   print('The server is listening at $host:$port.'
       ' Try opening the following URL(s) in your browser:');
-  resources.forEach((resource) {
+  for (var resource in resources) {
     print('http://$host:$port/$resource');
-  });
+  }
 }
 
 Future addColors(Repository repo) async {
diff --git a/example/server/in_memory_repo.dart b/example/server/in_memory_repo.dart
index d6639514..d9a625f8 100644
--- a/example/server/in_memory_repo.dart
+++ b/example/server/in_memory_repo.dart
@@ -5,9 +5,9 @@ import 'repository.dart';
 
 class InMemoryRepo implements Repository {
   InMemoryRepo(Iterable<String> types) {
-    types.forEach((_) {
-      _storage[_] = {};
-    });
+    for (var type in types) {
+      _storage[type] = {};
+    }
   }
 
   final _storage = <String, Map<String, Model>>{};
diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart
index 8ca5a2d5..1a8b67a6 100644
--- a/lib/src/client/response/resource_updated.dart
+++ b/lib/src/client/response/resource_updated.dart
@@ -17,6 +17,7 @@ class ResourceUpdated {
         return doc.dataAsResource();
       }
     }
+    return null;
   }
 
   final HttpResponse http;
diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index 2ab0d258..25f533d5 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -1,4 +1,3 @@
-import 'package:json_api/client.dart';
 import 'package:json_api/document.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/src/client/client.dart';
@@ -8,6 +7,7 @@ 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/request_failure.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';
@@ -176,8 +176,11 @@ class RoutingClient {
     Map<String, String> headers = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await send(baseUri.relationship(type, id, relationship),
-        Request.get()..headers.addAll(headers)..query.addAll(query));
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
+        Request.get()
+          ..headers.addAll(headers)
+          ..query.addAll(query));
     return RelationshipFetched.one(
         response.http, response.document ?? (throw FormatException()));
   }
@@ -189,8 +192,11 @@ class RoutingClient {
     Map<String, String> headers = const {},
     Map<String, String> query = const {},
   }) async {
-    final response = await send(baseUri.relationship(type, id, relationship),
-        Request.get()..headers.addAll(headers)..query.addAll(query));
+    final response = await send(
+        baseUri.relationship(type, id, relationship),
+        Request.get()
+          ..headers.addAll(headers)
+          ..query.addAll(query));
     return RelationshipFetched.many(
         response.http, response.document ?? (throw FormatException()));
   }
diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart
index 2d5b30a7..82f11220 100644
--- a/lib/src/document/inbound_document.dart
+++ b/lib/src/document/inbound_document.dart
@@ -1,4 +1,3 @@
-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';
diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart
index 05696739..93605b1a 100644
--- a/lib/src/document/many.dart
+++ b/lib/src/document/many.dart
@@ -1,11 +1,13 @@
-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.dart';
 import 'package:json_api/src/document/resource_collection.dart';
 
 class ToMany extends Relationship {
   ToMany(Iterable<Identifier> identifiers) {
-    identifiers.forEach((_) => _map[_.key] = _);
+    for (var id in identifiers) {
+      _map[id.key] = id;
+    }
   }
 
   final _map = <String, Identifier>{};
@@ -20,7 +22,6 @@ class ToMany extends Relationship {
   /// 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<Resource> findIn(ResourceCollection collection) {
-    return _map.keys.map((key) => collection[key]).whereType();
-  }
+  Iterable<Resource> findIn(ResourceCollection collection) =>
+      _map.keys.map((key) => collection[key]).whereType();
 }
diff --git a/lib/src/document/outbound_document.dart b/lib/src/document/outbound_document.dart
index 381b7645..a74d431c 100644
--- a/lib/src/document/outbound_document.dart
+++ b/lib/src/document/outbound_document.dart
@@ -1,5 +1,8 @@
-import 'package:json_api/document.dart';
+import 'package:json_api/src/document/error_object.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/resource.dart';
 
 /// A sever-to-client document.
diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart
index 59935672..2eecb4a7 100644
--- a/lib/src/document/relationship.dart
+++ b/lib/src/document/relationship.dart
@@ -3,7 +3,7 @@ import 'dart:collection';
 import 'package:json_api/src/document/identifier.dart';
 import 'package:json_api/src/document/link.dart';
 
-class Relationship with IterableMixin<Identifier /*!*/ > {
+class Relationship with IterableMixin<Identifier> {
   final links = <String, Link>{};
   final meta = <String, Object?>{};
 
diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart
index 7e52cc85..ef522fb0 100644
--- a/lib/src/document/resource_properties.dart
+++ b/lib/src/document/resource_properties.dart
@@ -26,5 +26,6 @@ mixin ResourceProperties {
   R? _rel<R extends Relationship>(String name) {
     final r = relationships[name];
     if (r is R) return r;
+    return null;
   }
 }
diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart
index 85852a41..43387400 100644
--- a/lib/src/query/filter.dart
+++ b/lib/src/query/filter.dart
@@ -15,7 +15,7 @@ class Filter with MapMixin<String, String> {
 
   static Filter fromUri(Uri uri) => Filter(uri.queryParametersAll
       .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last))
-        ..removeWhere((k, v) => k.isEmpty));
+    ..removeWhere((k, v) => k.isEmpty));
 
   static final _regex = RegExp(r'^filter\[(.+)\]$');
 
diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart
index 301b0741..d4f441e5 100644
--- a/lib/src/query/page.dart
+++ b/lib/src/query/page.dart
@@ -18,7 +18,7 @@ class Page with MapMixin<String, String> {
 
   static Page fromUri(Uri uri) => Page(uri.queryParametersAll
       .map((k, v) => MapEntry(_regex.firstMatch(k)?.group(1) ?? '', v.last))
-        ..removeWhere((k, v) => k.isEmpty));
+    ..removeWhere((k, v) => k.isEmpty));
   static final _regex = RegExp(r'^page\[(.+)\]$');
 
   final _ = <String, String>{};
diff --git a/pubspec.yaml b/pubspec.yaml
index f875176d..0636c662 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,21 +1,21 @@
 name: json_api
-version: 5.0.5
+version: 5.1.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'
+  sdk: '>=2.17.0 <3.0.0'
 
 dependencies:
-  http: ^0.13.0
+  http: ^0.13.4
   http_parser: ^4.0.0
 
 dev_dependencies:
-  pedantic: ^1.10.0
-  test: ^1.16.0
+  lints: ^1.0.1
+  test: ^1.21.1
   stream_channel: ^2.1.0
   uuid: ^3.0.0
-  coverage: ^1.0.2
-  check_coverage: ^0.0.2
+  coverage: ^1.3.0
+  check_coverage: ^0.0.4
 
 cider:
   link_template:
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index 613a7914..a89e5f0b 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -1,7 +1,6 @@
 import 'dart:convert';
 
 import 'package:json_api/http.dart';
-import 'package:json_api/src/http/media_type.dart';
 
 final collectionMin = HttpResponse(200,
     body: jsonEncode({
diff --git a/test/unit/http/encoding_test.dart b/test/unit/http/encoding_test.dart
index e0de8284..6bae98e9 100644
--- a/test/unit/http/encoding_test.dart
+++ b/test/unit/http/encoding_test.dart
@@ -13,7 +13,7 @@ void main() {
     final stringBodyEn = 'qwerty';
     final bytesBodyEn = utf8.encode(stringBodyEn);
 
-    final buildResponse = (
+    buildResponse(
       List<int> bytesBody,
       Encoding encoding,
     ) async {
@@ -27,7 +27,7 @@ void main() {
       );
 
       return dartHttp.handle(HttpRequest('get', Uri.parse('http://test.com')));
-    };
+    }
 
     test('UTF-8 ru', () async {
       final response = await buildResponse(bytesBodyRu, utf8);

From 5b07debf07018c33c0d735f94e2b60ddd5cce1d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kamil=20Szcz=C4=99k?= <k_szczek@protonmail.ch>
Date: Tue, 24 May 2022 23:00:03 +0200
Subject: [PATCH 14/25] Add include parameters to create and update methods of
 the routing client (#126)

---
 lib/src/client/routing_client.dart | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index 25f533d5..108880b9 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -59,6 +59,7 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, Object?> documentMeta = const {},
     Map<String, String> headers = const {},
+    Iterable<String> include = const [],
   }) async {
     final response = await send(
         baseUri.collection(type),
@@ -70,7 +71,8 @@ class RoutingClient {
           })
           ..meta.addAll(meta))
           ..meta.addAll(documentMeta))
-          ..headers.addAll(headers));
+          ..headers.addAll(headers)
+          ..include(include));
 
     return ResourceCreated(
         response.http, response.document ?? (throw FormatException()));
@@ -254,6 +256,7 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, Object?> documentMeta = const {},
     Map<String, String> headers = const {},
+    Iterable<String> include = const [],
   }) async {
     final response = await send(
         baseUri.resource(type, id),
@@ -265,7 +268,8 @@ class RoutingClient {
           })
           ..meta.addAll(meta))
           ..meta.addAll(documentMeta))
-          ..headers.addAll(headers));
+          ..headers.addAll(headers)
+          ..include(include));
     return ResourceUpdated(response.http, response.document);
   }
 
@@ -279,6 +283,7 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, Object?> documentMeta = const {},
     Map<String, String> headers = const {},
+    Iterable<String> include = const [],
   }) async {
     final response = await send(
         baseUri.collection(type),
@@ -290,7 +295,8 @@ class RoutingClient {
           })
           ..meta.addAll(meta))
           ..meta.addAll(documentMeta))
-          ..headers.addAll(headers));
+          ..headers.addAll(headers)
+          ..include(include));
     return ResourceUpdated(response.http, response.document);
   }
 

From 6fb4da3ea182781a426d5d6812cf327022147f29 Mon Sep 17 00:00:00 2001
From: f3ath <karapetov@gmail.com>
Date: Wed, 1 Jun 2022 20:01:23 -0700
Subject: [PATCH 15/25] v5.2.0

---
 CHANGELOG.md | 5 +++++
 pubspec.yaml | 2 +-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index df84aa26..c138a85c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.2.0] - 2022-06-01
+### Added
+- Support for included resources in create and update methods. Author: @kszczek
+
 ## [5.1.0] - 2022-05-11
 ### Changed
 - Dependency versions bump
@@ -213,6 +217,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.2.0]: https://github.com/f3ath/json-api-dart/compare/5.1.0...5.2.0
 [5.1.0]: https://github.com/f3ath/json-api-dart/compare/5.0.5...5.1.0
 [5.0.5]: https://github.com/f3ath/json-api-dart/compare/5.0.4...5.0.5
 [5.0.4]: https://github.com/f3ath/json-api-dart/compare/5.0.3...5.0.4
diff --git a/pubspec.yaml b/pubspec.yaml
index 0636c662..16ef3149 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.1.0
+version: 5.2.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:

From 1df709b89ac1b900cdeeea3e5267315a95de6df1 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Thu, 29 Dec 2022 22:05:32 -0800
Subject: [PATCH 16/25] Do not set empty body. Fixes #129 (#130)

---
 lib/client.dart                               |  3 +-
 lib/src/client/message_converter.dart         | 67 +++++++++++++++++++
 lib/src/client/persistent_handler.dart        | 52 +++++---------
 test/unit/{http => client}/encoding_test.dart |  3 +-
 test/unit/client/message_converter_test.dart  | 19 ++++++
 5 files changed, 106 insertions(+), 38 deletions(-)
 create mode 100644 lib/src/client/message_converter.dart
 rename test/unit/{http => client}/encoding_test.dart (93%)
 create mode 100644 test/unit/client/message_converter_test.dart

diff --git a/lib/client.dart b/lib/client.dart
index 4ab25dcd..7455c695 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -2,7 +2,7 @@
 ///
 /// There are two clients implementation provided by this library.
 ///
-/// The firs one, [Client], is the most flexible but low level. It operates
+/// The first one, [Client], is the most flexible but low level. It operates
 /// generic [Request] and [Response] objects and performs basic operations
 /// such as JSON conversion and error handling. It is agnostic to the document
 /// structure and accepts any target URIs.
@@ -25,6 +25,7 @@ library client;
 
 export 'package:json_api/src/client/client.dart';
 export 'package:json_api/src/client/disposable_handler.dart';
+export 'package:json_api/src/client/message_converter.dart';
 export 'package:json_api/src/client/persistent_handler.dart';
 export 'package:json_api/src/client/request.dart';
 export 'package:json_api/src/client/response/collection_fetched.dart';
diff --git a/lib/src/client/message_converter.dart b/lib/src/client/message_converter.dart
new file mode 100644
index 00000000..9e830b5d
--- /dev/null
+++ b/lib/src/client/message_converter.dart
@@ -0,0 +1,67 @@
+import 'dart:convert';
+
+import 'package:http/http.dart';
+import 'package:http_parser/http_parser.dart';
+import 'package:json_api/http.dart';
+
+/// Converts HTTP messages to and from to ones used by the `http` package.
+class MessageConverter {
+  /// Creates an instance of the converter.
+  ///
+  /// Pass [defaultResponseEncoding] to use in cases when the server does
+  /// not specify the encoding.
+  MessageConverter({Encoding defaultResponseEncoding = utf8})
+      : _defaultResponseEncoding = defaultResponseEncoding;
+
+  final Encoding _defaultResponseEncoding;
+
+  /// Converts [HttpRequest] to [Request].
+  Request request(HttpRequest request) {
+    final converted = Request(request.method, request.uri);
+    final hasBody = !(request.isGet || request.isOptions);
+    if (hasBody) {
+      // The Request would set the content-type header if the body is assigned
+      // a value (even an empty string). We want to avoid this extra header for
+      // GET and OPTIONS requests.
+      // See https://github.com/dart-lang/http/issues/841
+      converted.body = request.body;
+    }
+    converted.headers.addAll(request.headers);
+    return converted;
+  }
+
+  /// Converts [Response] to [HttpResponse].
+  HttpResponse response(Response response) {
+    final encoding = _encodingForHeaders(response.headers);
+    final body = encoding.decode(response.bodyBytes);
+    return HttpResponse(response.statusCode, body: body)
+      ..headers.addAll(response.headers);
+  }
+
+  /// Returns the [Encoding] that corresponds to [charset].
+  ///
+  /// Returns [defaultResponseEncoding] if [charset] is null or if no [Encoding]
+  /// was found that corresponds to [charset].
+  Encoding _encodingForCharset(String? charset) {
+    if (charset == null) return _defaultResponseEncoding;
+    return Encoding.getByName(charset) ?? _defaultResponseEncoding;
+  }
+
+  /// Returns the [MediaType] object for the given content-type.
+  ///
+  /// Defaults to `application/octet-stream`.
+  MediaType _mediaType(String? contentType) {
+    if (contentType != null) return MediaType.parse(contentType);
+    return MediaType('application', 'octet-stream');
+  }
+
+  /// Returns the encoding to use for a response with the given headers.
+  ///
+  /// Defaults to [defaultEncoding] if the headers don't specify the charset
+  /// or if that charset is unknown.
+  Encoding _encodingForHeaders(Map<String, String> headers) {
+    final mediaType = _mediaType(headers['content-type']);
+    final charset = mediaType.parameters['charset'];
+    return _encodingForCharset(charset);
+  }
+}
diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart
index 117b9003..7eacb9bc 100644
--- a/lib/src/client/persistent_handler.dart
+++ b/lib/src/client/persistent_handler.dart
@@ -1,8 +1,8 @@
 import 'dart:convert';
 
 import 'package:http/http.dart';
-import 'package:http_parser/http_parser.dart';
 import 'package:json_api/http.dart';
+import 'package:json_api/src/client/message_converter.dart';
 
 /// Handler which relies on the built-in Dart HTTP client.
 /// It is the developer's responsibility to instantiate the client and
@@ -10,46 +10,26 @@ import 'package:json_api/http.dart';
 class PersistentHandler implements HttpHandler {
   /// 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, {this.defaultEncoding = utf8});
+  ///
+  /// Use [messageConverter] to fine tune the HTTP request/response conversion.
+  PersistentHandler(
+      this.client,
+      {@Deprecated('Deprecated in favor of MessageConverter.'
+          ' To be removed in version 6.0.0')
+          this.defaultEncoding = utf8,
+      MessageConverter? messageConverter})
+      : _converter = messageConverter ??
+            MessageConverter(defaultResponseEncoding: defaultEncoding);
 
   final Client client;
   final Encoding defaultEncoding;
+  final MessageConverter _converter;
 
   @override
   Future<HttpResponse> handle(HttpRequest request) async {
-    final response = await Response.fromStream(
-        await client.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.addAll(response.headers);
-  }
-
-  /// 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<String, String> 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<String, String> headers) {
-    final contentType = headers['content-type'];
-    if (contentType != null) return MediaType.parse(contentType);
-    return MediaType('application', 'octet-stream');
+    final convertedRequest = _converter.request(request);
+    final streamedResponse = await client.send(convertedRequest);
+    final response = await Response.fromStream(streamedResponse);
+    return _converter.response(response);
   }
 }
diff --git a/test/unit/http/encoding_test.dart b/test/unit/client/encoding_test.dart
similarity index 93%
rename from test/unit/http/encoding_test.dart
rename to test/unit/client/encoding_test.dart
index 6bae98e9..67605d1f 100644
--- a/test/unit/http/encoding_test.dart
+++ b/test/unit/client/encoding_test.dart
@@ -2,8 +2,8 @@ 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:json_api/src/client/persistent_handler.dart';
 import 'package:test/test.dart';
 
 void main() {
@@ -23,6 +23,7 @@ void main() {
             return http.Response.bytes(bytesBody, 200);
           },
         ),
+        // ignore: deprecated_member_use_from_same_package
         defaultEncoding: encoding,
       );
 
diff --git a/test/unit/client/message_converter_test.dart b/test/unit/client/message_converter_test.dart
new file mode 100644
index 00000000..966d7187
--- /dev/null
+++ b/test/unit/client/message_converter_test.dart
@@ -0,0 +1,19 @@
+import 'package:json_api/client.dart';
+import 'package:json_api/http.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+void main() {
+  final converter = MessageConverter();
+  final uri = Uri.parse('https://example.com');
+
+  test('No headers are set for GET requests', () {
+    final r = converter.request(HttpRequest('GET', uri));
+    expect(r.headers, isEmpty);
+  });
+
+  test('No headers are set for OPTIONS requests', () {
+    final r = converter.request(HttpRequest('OPTIONS', uri));
+    expect(r.headers, isEmpty);
+  });
+}

From 0254cb14eb15fd7fbb9193653228511aed130ab1 Mon Sep 17 00:00:00 2001
From: f3ath <karapetov@gmail.com>
Date: Thu, 29 Dec 2022 22:17:39 -0800
Subject: [PATCH 17/25] v5.3.0

---
 CHANGELOG.md | 8 ++++++++
 pubspec.yaml | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c138a85c..a0ddbdc5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.3.0] - 2022-12-29
+### Added
+- Client MessageConverter class to control HTTP request/response conversion.
+
+### Fixed
+- Content-Type being set for GET/OPTIONS requests ([issue](https://github.com/f3ath/json-api-dart/issues/129)).
+
 ## [5.2.0] - 2022-06-01
 ### Added
 - Support for included resources in create and update methods. Author: @kszczek
@@ -217,6 +224,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.3.0]: https://github.com/f3ath/json-api-dart/compare/5.2.0...5.3.0
 [5.2.0]: https://github.com/f3ath/json-api-dart/compare/5.1.0...5.2.0
 [5.1.0]: https://github.com/f3ath/json-api-dart/compare/5.0.5...5.1.0
 [5.0.5]: https://github.com/f3ath/json-api-dart/compare/5.0.4...5.0.5
diff --git a/pubspec.yaml b/pubspec.yaml
index 16ef3149..4fdedbc3 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 5.2.0
+version: 5.3.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:

From 8695d7ddc47cddd2832e68373d0242d67646d415 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Mon, 1 May 2023 00:36:10 -0400
Subject: [PATCH 18/25] v5.4.0 (#132)

---
 .github/workflows/dart.yml                    |  2 +
 CHANGELOG.md                                  | 20 ++++--
 example/README.md                             |  2 +-
 example/server/json_api_server.dart           | 15 +----
 lib/client.dart                               |  3 +-
 lib/http.dart                                 | 15 +++--
 lib/src/client/client.dart                    |  2 +-
 lib/src/client/disposable_handler.dart        | 19 ------
 lib/src/client/message_converter.dart         | 67 -------------------
 lib/src/client/persistent_handler.dart        | 22 ++----
 lib/src/client/routing_client.dart            |  1 +
 lib/src/http/http_handler.dart                |  6 --
 lib/src/http/http_headers.dart                |  8 ---
 lib/src/http/http_message.dart                |  9 ---
 lib/src/http/http_request.dart                | 24 -------
 ...p_response.dart => http_response_ext.dart} |  9 +--
 lib/src/http/logging_handler.dart             | 20 ------
 lib/src/http/payload_codec.dart               |  3 -
 pubspec.yaml                                  |  7 +-
 test/unit/client/message_converter_test.dart  |  5 ++
 20 files changed, 48 insertions(+), 211 deletions(-)
 delete mode 100644 lib/src/client/disposable_handler.dart
 delete mode 100644 lib/src/client/message_converter.dart
 delete mode 100644 lib/src/http/http_handler.dart
 delete mode 100644 lib/src/http/http_headers.dart
 delete mode 100644 lib/src/http/http_message.dart
 delete mode 100644 lib/src/http/http_request.dart
 rename lib/src/http/{http_response.dart => http_response_ext.dart} (70%)
 delete mode 100644 lib/src/http/logging_handler.dart

diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index 0297fc11..38a2c18f 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -18,6 +18,8 @@ jobs:
       - uses: actions/checkout@v2
       - name: Install dependencies
         run: dart pub get
+      - name: Print Dart version
+        run: dart --version
       - name: Format
         run: dart format --output none --set-exit-if-changed example lib test
       - name: Analyzer
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0ddbdc5..302bb29f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [5.4.0] - 2023-04-30
+### Changed
+- Switch to http\_interop packages.
+- Bump min SDK version to 2.19.
+
 ## [5.3.0] - 2022-12-29
 ### Added
 - Client MessageConverter class to control HTTP request/response conversion.
@@ -46,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Sound null-safety support.
 
 ### Changed
-- Everything. Again. This is another major **BC-breaking** rework. Please refer to 
+- Everything. Again. This is another major **BC-breaking** rework. Please refer to
 the API documentation, examples and tests.
 
 ## [3.2.3] - 2020-08-06
@@ -87,11 +92,11 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https
 
 ## [3.2.2] - 2020-01-07
 ### Fixed
--  Can not decode related resource which is null ([\#77](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 ([\#74](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
@@ -157,14 +162,14 @@ is missing. Before in such cases a `FormatException` would be thrown ([pr](https
 
 ## [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 
+- 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`.
 - 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**. 
+- Both the Document and the Server have been refactored with lots of **BREAKING CHANGES**.
 See the examples and the functional tests for details.
-- Meta properties are not defensively copied, but set directly. Meta property behavior is unified across 
+- Meta properties are not defensively copied, but set directly. Meta property behavior is unified across
 the Document model.
 
 ### Removed
@@ -224,6 +229,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[5.4.0]: https://github.com/f3ath/json-api-dart/compare/5.3.0...5.4.0
 [5.3.0]: https://github.com/f3ath/json-api-dart/compare/5.2.0...5.3.0
 [5.2.0]: https://github.com/f3ath/json-api-dart/compare/5.1.0...5.2.0
 [5.1.0]: https://github.com/f3ath/json-api-dart/compare/5.0.5...5.1.0
diff --git a/example/README.md b/example/README.md
index a3264887..4b5eecfa 100644
--- a/example/README.md
+++ b/example/README.md
@@ -1,4 +1,4 @@
-The client and server examples are meant to run together. 
+The client and server examples are meant to run together.
 
 - Open a new terminal window and run `dart server.dart`
 - While the server is running, open another window and run `dart client.dart`
\ No newline at end of file
diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart
index f53b7e83..defdb7e8 100644
--- a/example/server/json_api_server.dart
+++ b/example/server/json_api_server.dart
@@ -1,6 +1,6 @@
-import 'dart:convert';
 import 'dart:io';
 
+import 'package:http_interop_io/http_interop_io.dart';
 import 'package:json_api/http.dart';
 
 class JsonApiServer {
@@ -41,18 +41,7 @@ class JsonApiServer {
 
   Future<HttpServer> _createServer() async {
     final server = await HttpServer.bind(host, port);
-    server.listen((request) async {
-      final headers = <String, String>{};
-      request.headers.forEach((k, v) => headers[k] = v.join(','));
-      final response = await _handler.handle(HttpRequest(
-          request.method, request.requestedUri,
-          body: await request.cast<List<int>>().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();
-    });
+    server.listen(listener(_handler));
     return server;
   }
 }
diff --git a/lib/client.dart b/lib/client.dart
index 7455c695..9f4f0efa 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -23,9 +23,8 @@
 /// The [RoutingClient] should be your default choice.
 library client;
 
+export 'package:http_interop_http/http_interop_http.dart';
 export 'package:json_api/src/client/client.dart';
-export 'package:json_api/src/client/disposable_handler.dart';
-export 'package:json_api/src/client/message_converter.dart';
 export 'package:json_api/src/client/persistent_handler.dart';
 export 'package:json_api/src/client/request.dart';
 export 'package:json_api/src/client/response/collection_fetched.dart';
diff --git a/lib/http.dart b/lib/http.dart
index 724805be..f7c29309 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -1,12 +1,15 @@
 /// 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_handler.dart';
+export 'package:http_interop/http_interop.dart'
+    show
+        HttpMessage,
+        HttpHeaders,
+        HttpRequest,
+        HttpResponse,
+        HttpHandler,
+        LoggingHandler;
+export 'package:json_api/src/http/http_response_ext.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/src/client/client.dart b/lib/src/client/client.dart
index 1a126a4e..be1a50d1 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -1,5 +1,5 @@
+import 'package:http_interop_http/http_interop_http.dart';
 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';
 
diff --git a/lib/src/client/disposable_handler.dart b/lib/src/client/disposable_handler.dart
deleted file mode 100644
index ab69d523..00000000
--- a/lib/src/client/disposable_handler.dart
+++ /dev/null
@@ -1,19 +0,0 @@
-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<HttpResponse> handle(HttpRequest request) async {
-    final client = Client();
-    try {
-      return await PersistentHandler(client).handle(request);
-    } finally {
-      client.close();
-    }
-  }
-}
diff --git a/lib/src/client/message_converter.dart b/lib/src/client/message_converter.dart
deleted file mode 100644
index 9e830b5d..00000000
--- a/lib/src/client/message_converter.dart
+++ /dev/null
@@ -1,67 +0,0 @@
-import 'dart:convert';
-
-import 'package:http/http.dart';
-import 'package:http_parser/http_parser.dart';
-import 'package:json_api/http.dart';
-
-/// Converts HTTP messages to and from to ones used by the `http` package.
-class MessageConverter {
-  /// Creates an instance of the converter.
-  ///
-  /// Pass [defaultResponseEncoding] to use in cases when the server does
-  /// not specify the encoding.
-  MessageConverter({Encoding defaultResponseEncoding = utf8})
-      : _defaultResponseEncoding = defaultResponseEncoding;
-
-  final Encoding _defaultResponseEncoding;
-
-  /// Converts [HttpRequest] to [Request].
-  Request request(HttpRequest request) {
-    final converted = Request(request.method, request.uri);
-    final hasBody = !(request.isGet || request.isOptions);
-    if (hasBody) {
-      // The Request would set the content-type header if the body is assigned
-      // a value (even an empty string). We want to avoid this extra header for
-      // GET and OPTIONS requests.
-      // See https://github.com/dart-lang/http/issues/841
-      converted.body = request.body;
-    }
-    converted.headers.addAll(request.headers);
-    return converted;
-  }
-
-  /// Converts [Response] to [HttpResponse].
-  HttpResponse response(Response response) {
-    final encoding = _encodingForHeaders(response.headers);
-    final body = encoding.decode(response.bodyBytes);
-    return HttpResponse(response.statusCode, body: body)
-      ..headers.addAll(response.headers);
-  }
-
-  /// Returns the [Encoding] that corresponds to [charset].
-  ///
-  /// Returns [defaultResponseEncoding] if [charset] is null or if no [Encoding]
-  /// was found that corresponds to [charset].
-  Encoding _encodingForCharset(String? charset) {
-    if (charset == null) return _defaultResponseEncoding;
-    return Encoding.getByName(charset) ?? _defaultResponseEncoding;
-  }
-
-  /// Returns the [MediaType] object for the given content-type.
-  ///
-  /// Defaults to `application/octet-stream`.
-  MediaType _mediaType(String? contentType) {
-    if (contentType != null) return MediaType.parse(contentType);
-    return MediaType('application', 'octet-stream');
-  }
-
-  /// Returns the encoding to use for a response with the given headers.
-  ///
-  /// Defaults to [defaultEncoding] if the headers don't specify the charset
-  /// or if that charset is unknown.
-  Encoding _encodingForHeaders(Map<String, String> headers) {
-    final mediaType = _mediaType(headers['content-type']);
-    final charset = mediaType.parameters['charset'];
-    return _encodingForCharset(charset);
-  }
-}
diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart
index 7eacb9bc..b6d5007f 100644
--- a/lib/src/client/persistent_handler.dart
+++ b/lib/src/client/persistent_handler.dart
@@ -1,35 +1,25 @@
 import 'dart:convert';
 
 import 'package:http/http.dart';
-import 'package:json_api/http.dart';
-import 'package:json_api/src/client/message_converter.dart';
+import 'package:http_interop_http/http_interop_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 implements HttpHandler {
+class PersistentHandler extends HandlerWrapper {
   /// Creates a new instance of the handler. Do not forget to call `close()` on
   /// the [client] when it's not longer needed.
   ///
   /// Use [messageConverter] to fine tune the HTTP request/response conversion.
   PersistentHandler(
-      this.client,
+      Client client,
       {@Deprecated('Deprecated in favor of MessageConverter.'
           ' To be removed in version 6.0.0')
           this.defaultEncoding = utf8,
       MessageConverter? messageConverter})
-      : _converter = messageConverter ??
-            MessageConverter(defaultResponseEncoding: defaultEncoding);
+      : super(client,
+            messageConverter: messageConverter ??
+                MessageConverter(defaultResponseEncoding: defaultEncoding));
 
-  final Client client;
   final Encoding defaultEncoding;
-  final MessageConverter _converter;
-
-  @override
-  Future<HttpResponse> handle(HttpRequest request) async {
-    final convertedRequest = _converter.request(request);
-    final streamedResponse = await client.send(convertedRequest);
-    final response = await Response.fromStream(streamedResponse);
-    return _converter.response(response);
-  }
 }
diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index 108880b9..f4c03c66 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -1,4 +1,5 @@
 import 'package:json_api/document.dart';
+import 'package:json_api/http.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/src/client/client.dart';
 import 'package:json_api/src/client/request.dart';
diff --git a/lib/src/http/http_handler.dart b/lib/src/http/http_handler.dart
deleted file mode 100644
index 234c125e..00000000
--- a/lib/src/http/http_handler.dart
+++ /dev/null
@@ -1,6 +0,0 @@
-import 'package:json_api/src/http/http_request.dart';
-import 'package:json_api/src/http/http_response.dart';
-
-abstract class HttpHandler {
-  Future<HttpResponse> handle(HttpRequest request);
-}
diff --git a/lib/src/http/http_headers.dart b/lib/src/http/http_headers.dart
deleted file mode 100644
index a7111041..00000000
--- a/lib/src/http/http_headers.dart
+++ /dev/null
@@ -1,8 +0,0 @@
-import 'dart:collection';
-
-mixin HttpHeaders {
-  /// Message headers. Case-insensitive.
-  final headers = LinkedHashMap<String, String>(
-      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
deleted file mode 100644
index 64142ac9..00000000
--- a/lib/src/http/http_message.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index 2eef17c0..00000000
--- a/lib/src/http/http_request.dart
+++ /dev/null
@@ -1,24 +0,0 @@
-import 'package:json_api/src/http/http_message.dart';
-
-/// The request which is sent by the client and received by the server
-class HttpRequest extends HttpMessage {
-  HttpRequest(String method, this.uri, {String body = ''})
-      : method = method.toLowerCase(),
-        super(body);
-
-  /// Requested URI
-  final Uri uri;
-
-  /// Request method, lowercase
-  final String method;
-
-  bool get isGet => method == 'get';
-
-  bool get isPost => method == 'post';
-
-  bool get isDelete => method == 'delete';
-
-  bool get isPatch => method == 'patch';
-
-  bool get isOptions => method == 'options';
-}
diff --git a/lib/src/http/http_response.dart b/lib/src/http/http_response_ext.dart
similarity index 70%
rename from lib/src/http/http_response.dart
rename to lib/src/http/http_response_ext.dart
index 76c39617..62598108 100644
--- a/lib/src/http/http_response.dart
+++ b/lib/src/http/http_response_ext.dart
@@ -1,14 +1,9 @@
-import 'package:json_api/src/http/http_message.dart';
+import 'package:http_interop/http_interop.dart' as interop;
 import 'package:json_api/src/http/media_type.dart';
 import 'package:json_api/src/http/status_code.dart';
 
 /// The response sent by the server and received by the client
-class HttpResponse extends HttpMessage {
-  HttpResponse(this.statusCode, {String body = ''}) : super(body);
-
-  /// Response status code
-  final int statusCode;
-
+extension HttpResponseExt on interop.HttpResponse {
   /// True if the body is not empty and the Content-Type
   /// is `application/vnd.api+json`
   bool get hasDocument =>
diff --git a/lib/src/http/logging_handler.dart b/lib/src/http/logging_handler.dart
deleted file mode 100644
index 1ae8c38a..00000000
--- a/lib/src/http/logging_handler.dart
+++ /dev/null
@@ -1,20 +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 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<HttpResponse> handle(HttpRequest request) async {
-    onRequest?.call(request);
-    final response = await handler.handle(request);
-    onResponse?.call(response);
-    return response;
-  }
-}
diff --git a/lib/src/http/payload_codec.dart b/lib/src/http/payload_codec.dart
index e3ed74be..54cf3aa5 100644
--- a/lib/src/http/payload_codec.dart
+++ b/lib/src/http/payload_codec.dart
@@ -2,9 +2,6 @@ import 'dart:async';
 import 'dart:convert';
 
 /// Encodes/decodes JSON payload.
-///
-/// The methods are designed to be asynchronous to allow for conversion to be
-/// performed in isolates if needed.
 class PayloadCodec {
   const PayloadCodec();
 
diff --git a/pubspec.yaml b/pubspec.yaml
index 4fdedbc3..18d21161 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,13 +1,15 @@
 name: json_api
-version: 5.3.0
+version: 5.4.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.17.0 <3.0.0'
+  sdk: '>=2.19.0 <3.0.0'
 
 dependencies:
   http: ^0.13.4
   http_parser: ^4.0.0
+  http_interop: ^0.1.0
+  http_interop_http: ^0.1.1
 
 dev_dependencies:
   lints: ^1.0.1
@@ -16,6 +18,7 @@ dev_dependencies:
   uuid: ^3.0.0
   coverage: ^1.3.0
   check_coverage: ^0.0.4
+  http_interop_io: ^0.1.0
 
 cider:
   link_template:
diff --git a/test/unit/client/message_converter_test.dart b/test/unit/client/message_converter_test.dart
index 966d7187..be0b862d 100644
--- a/test/unit/client/message_converter_test.dart
+++ b/test/unit/client/message_converter_test.dart
@@ -16,4 +16,9 @@ void main() {
     final r = converter.request(HttpRequest('OPTIONS', uri));
     expect(r.headers, isEmpty);
   });
+
+  test('No headers are set for DELETE requests', () {
+    final r = converter.request(HttpRequest('DELETE', uri));
+    expect(r.headers, isEmpty);
+  });
 }

From 2cb84022ba4df524848ba8fc1765ec53116e2576 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Thu, 7 Sep 2023 23:53:27 -0700
Subject: [PATCH 19/25] Release v6 (#135)

---
 .github/workflows/dart.yml                    |   4 +-
 CHANGELOG.md                                  |  18 +-
 README.md                                     |  10 +-
 example/client.dart                           |  45 +-
 example/server.dart                           |  54 +-
 example/server/cors_handler.dart              |  36 +-
 example/server/demo_handler.dart              |  53 --
 example/server/in_memory_repo.dart            |  41 +-
 example/server/json_api_server.dart           |   4 +-
 example/server/repository.dart                |  45 +-
 example/server/repository_controller.dart     |  69 +--
 example/server/try_catch_handler.dart         |  22 -
 lib/client.dart                               |   2 -
 lib/document.dart                             |  11 +-
 lib/http.dart                                 |  74 ++-
 lib/query.dart                                |   3 +-
 lib/server.dart                               |   7 +-
 lib/src/client/client.dart                    |  56 +-
 lib/src/{http => client}/payload_codec.dart   |   3 +
 lib/src/client/persistent_handler.dart        |  25 -
 lib/src/client/request.dart                   |  43 +-
 lib/src/client/response.dart                  |   8 +-
 .../client/response/collection_fetched.dart   |  10 +-
 .../response/related_resource_fetched.dart    |   8 +-
 .../client/response/relationship_fetched.dart |  16 +-
 .../client/response/relationship_updated.dart |  14 +-
 lib/src/client/response/request_failure.dart  |   8 +-
 lib/src/client/response/resource_created.dart |   8 +-
 lib/src/client/response/resource_fetched.dart |   8 +-
 lib/src/client/response/resource_updated.dart |   8 +-
 lib/src/client/routing_client.dart            | 345 +++++++------
 lib/src/document/identifier.dart              |  20 -
 lib/src/document/identity.dart                |   9 -
 lib/src/document/inbound_document.dart        |  86 +++-
 lib/src/document/many.dart                    |  27 -
 lib/src/document/new_identifier.dart          |  69 +++
 lib/src/document/new_relationship.dart        |  19 +
 lib/src/document/new_resource.dart            |  75 ++-
 lib/src/document/new_to_many.dart             |  19 +
 lib/src/document/new_to_one.dart              |  17 +
 lib/src/document/one.dart                     |  23 -
 lib/src/document/outbound_document.dart       |   4 +-
 lib/src/document/relationship.dart            |   4 +-
 lib/src/document/resource.dart                |  42 +-
 lib/src/document/resource_collection.dart     |  21 -
 lib/src/document/resource_properties.dart     |  31 --
 lib/src/document/to_many.dart                 |  19 +
 lib/src/document/to_one.dart                  |  17 +
 lib/src/http/http_response_ext.dart           |  15 -
 lib/src/http/status_code.dart                 |  23 -
 lib/src/{http => }/media_type.dart            |   0
 lib/src/nullable.dart                         |   1 +
 lib/src/query/fields.dart                     |   9 +-
 lib/src/query/filter.dart                     |  42 --
 lib/src/query/include.dart                    |  10 +-
 lib/src/query/page.dart                       |   9 +-
 lib/src/query/query.dart                      |  42 ++
 lib/src/query/query_encodable.dart            |   7 +
 lib/src/query/sort.dart                       |  10 +-
 lib/src/server/controller.dart                |  36 +-
 lib/src/server/controller_router.dart         |  80 +++
 lib/src/server/error_converter.dart           |  70 +++
 .../server/errors/collection_not_found.dart   |   7 +
 .../server/errors/relationship_not_found.dart |   8 +
 lib/src/server/errors/resource_not_found.dart |   7 +
 lib/src/server/errors/unacceptable.dart       |   1 +
 .../server/errors/unsupported_media_type.dart |   1 +
 lib/src/server/response.dart                  |  28 +-
 lib/src/server/router.dart                    |  60 ---
 lib/src/server/try_catch_handler.dart         |  26 +
 pubspec.yaml                                  |  15 +-
 test/contract/crud_test.dart                  |  75 ++-
 test/contract/errors_test.dart                |  34 +-
 test/contract/resource_creation_test.dart     |  29 +-
 test/e2e/browser_test.dart                    |  18 +-
 test/e2e/e2e_test_set.dart                    |  48 +-
 test/e2e/hybrid_server.dart                   |   4 +-
 test/e2e/vm_test.dart                         |  28 +-
 test/test_handler.dart                        |  31 ++
 test/unit/client/client_test.dart             | 478 ++++++++++--------
 test/unit/client/encoding_test.dart           |  53 --
 test/unit/client/message_converter_test.dart  |  24 -
 test/unit/client/mock_handler.dart            |  10 +-
 .../{http => client}/payload_codec_test.dart  |   2 +-
 test/unit/client/response.dart                |  86 ++--
 test/unit/document/new_resource_test.dart     |  30 +-
 .../unit/document/outbound_document_test.dart |   4 +-
 test/unit/http/request_test.dart              |  30 --
 test/unit/query/fields_test.dart              |   7 +-
 test/unit/query/filter_test.dart              |  37 --
 test/unit/query/include_test.dart             |   5 +-
 test/unit/query/page_test.dart                |   6 +-
 test/unit/query/sort_test.dart                |   5 +-
 test/unit/server/error_converter_test.dart    |  14 +
 94 files changed, 1751 insertions(+), 1374 deletions(-)
 delete mode 100644 example/server/demo_handler.dart
 delete mode 100644 example/server/try_catch_handler.dart
 rename lib/src/{http => client}/payload_codec.dart (80%)
 delete mode 100644 lib/src/client/persistent_handler.dart
 delete mode 100644 lib/src/document/identifier.dart
 delete mode 100644 lib/src/document/identity.dart
 delete mode 100644 lib/src/document/many.dart
 create mode 100644 lib/src/document/new_identifier.dart
 create mode 100644 lib/src/document/new_relationship.dart
 create mode 100644 lib/src/document/new_to_many.dart
 create mode 100644 lib/src/document/new_to_one.dart
 delete mode 100644 lib/src/document/one.dart
 delete mode 100644 lib/src/document/resource_collection.dart
 delete mode 100644 lib/src/document/resource_properties.dart
 create mode 100644 lib/src/document/to_many.dart
 create mode 100644 lib/src/document/to_one.dart
 delete mode 100644 lib/src/http/http_response_ext.dart
 delete mode 100644 lib/src/http/status_code.dart
 rename lib/src/{http => }/media_type.dart (100%)
 delete mode 100644 lib/src/query/filter.dart
 create mode 100644 lib/src/query/query.dart
 create mode 100644 lib/src/query/query_encodable.dart
 create mode 100644 lib/src/server/controller_router.dart
 create mode 100644 lib/src/server/error_converter.dart
 create mode 100644 lib/src/server/errors/collection_not_found.dart
 create mode 100644 lib/src/server/errors/relationship_not_found.dart
 create mode 100644 lib/src/server/errors/resource_not_found.dart
 create mode 100644 lib/src/server/errors/unacceptable.dart
 create mode 100644 lib/src/server/errors/unsupported_media_type.dart
 delete mode 100644 lib/src/server/router.dart
 create mode 100644 lib/src/server/try_catch_handler.dart
 create mode 100644 test/test_handler.dart
 delete mode 100644 test/unit/client/encoding_test.dart
 delete mode 100644 test/unit/client/message_converter_test.dart
 rename test/unit/{http => client}/payload_codec_test.dart (77%)
 delete mode 100644 test/unit/http/request_test.dart
 delete mode 100644 test/unit/query/filter_test.dart
 create mode 100644 test/unit/server/error_converter_test.dart

diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index 38a2c18f..f8c2d358 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -18,8 +18,6 @@ jobs:
       - uses: actions/checkout@v2
       - name: Install dependencies
         run: dart pub get
-      - name: Print Dart version
-        run: dart --version
       - name: Format
         run: dart format --output none --set-exit-if-changed example lib test
       - name: Analyzer
@@ -27,4 +25,4 @@ jobs:
       - name: Tests
         run: dart test --coverage=.coverage -j1
       - name: Coverage
-        run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage
+        run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage 98
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 302bb29f..f882fd3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [6.0.0] - 2023-09-07
+### Added
+- Partial support for JSON:API v1.1
+
+### Changed
+- A bunch of BC-breaking changes. Please refer to the tests.
+- Min SDK version is 3.0.0
+- Migrated to `http_interop`. You'll have to install `http_interop_http` or another implementation to get the HTTP client.
+
+### Removed
+- Query filter.
+
 ## [5.4.0] - 2023-04-30
 ### Changed
 - Switch to http\_interop packages.
@@ -13,9 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - Client MessageConverter class to control HTTP request/response conversion.
 
-### Fixed
-- Content-Type being set for GET/OPTIONS requests ([issue](https://github.com/f3ath/json-api-dart/issues/129)).
-
 ## [5.2.0] - 2022-06-01
 ### Added
 - Support for included resources in create and update methods. Author: @kszczek
@@ -229,6 +238,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[6.0.0]: https://github.com/f3ath/json-api-dart/compare/5.4.0...6.0.0
 [5.4.0]: https://github.com/f3ath/json-api-dart/compare/5.3.0...5.4.0
 [5.3.0]: https://github.com/f3ath/json-api-dart/compare/5.2.0...5.3.0
 [5.2.0]: https://github.com/f3ath/json-api-dart/compare/5.1.0...5.2.0
@@ -263,4 +273,4 @@ the Document model.
 [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
-[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0
\ No newline at end of file
+[0.1.0]: https://github.com/f3ath/json-api-dart/releases/tag/0.1.0
diff --git a/README.md b/README.md
index 06f7d06b..90948700 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
 
 TL;DR:
 ```dart
+import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 
@@ -14,7 +15,7 @@ void main() async {
 
   /// The [RoutingClient] is most likely the right choice.
   /// It has methods covering many standard use cases.
-  final client = RoutingClient(uriDesign);
+  final client = RoutingClient(uriDesign, Client(OneOffHandler()));
 
   try {
     /// Fetch the collection.
@@ -31,14 +32,13 @@ void main() async {
     });
   } on RequestFailure catch (e) {
     /// Catch error response
-    e.errors.forEach((error) => print('${error.title}'));
+    for (var error in e.errors) {
+      print(error.title);
+    }
   }
 }
 ```
 This is a work-in-progress. You can help it by submitting a PR with a feature or documentation improvements.
 
 
-
-
-
 [JSON:API]: https://jsonapi.org
diff --git a/example/client.dart b/example/client.dart
index 1a7df5a2..a9b2387d 100644
--- a/example/client.dart
+++ b/example/client.dart
@@ -1,3 +1,5 @@
+import 'package:http/http.dart' as http;
+import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 
@@ -8,27 +10,50 @@ void main() async {
   /// Use the standard recommended URL structure or implement your own
   final uriDesign = StandardUriDesign(Uri.parse(baseUri));
 
+  /// This is the Dart's standard HTTP client.
+  /// Do not forget to close it in the end.
+  final httpClient = http.Client();
+
+  /// This is the adapter which decouples this JSON:API implementation
+  /// from the HTTP client.
+  /// Learn more: https://pub.dev/packages/http_interop
+  final httpHandler = ClientWrapper(httpClient);
+
+  /// This is the basic JSON:API client. It is flexible but not very convenient
+  /// to use, because you would need to remember a lot of JSON:API protocol details.
+  /// We will use another wrapper on top of it.
+  final jsonApiClient = Client(httpHandler);
+
   /// The [RoutingClient] is most likely the right choice.
-  /// It has methods covering many standard use cases.
-  final client = RoutingClient(uriDesign);
+  /// It is called routing because it routes the calls to the correct
+  /// URLs depending on the use case. Take a look at its methods, they cover
+  /// all the standard scenarios specified by the JSON:API standard.
+  final client = RoutingClient(uriDesign, jsonApiClient);
 
   try {
     /// Fetch the collection.
     /// See other methods to query and manipulate resources.
     final response = await client.fetchCollection('colors');
 
-    final resources = response.collection;
-    resources.map((resource) => resource.attributes).forEach((attr) {
-      final name = attr['name'];
-      final red = attr['red'];
-      final green = attr['green'];
-      final blue = attr['blue'];
+    /// The fetched collection allows us to iterate over the resources
+    /// and to look into their attributes
+    for (final resource in response.collection) {
+      final {
+        'name': name,
+        'red': red,
+        'green': green,
+        'blue': blue,
+      } = resource.attributes;
+      print('${resource.type}:${resource.id}');
       print('$name - $red:$green:$blue');
-    });
+    }
   } on RequestFailure catch (e) {
     /// Catch error response
-    for (var error in e.errors) {
+    for (final error in e.errors) {
       print(error.title);
     }
   }
+
+  /// Free up the resources before exit.
+  httpClient.close();
 }
diff --git a/example/server.dart b/example/server.dart
index 9293f902..57dccb93 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -1,5 +1,6 @@
 import 'dart:io';
 
+import 'package:http_interop/http_interop.dart' as interop;
 import 'package:json_api/document.dart';
 import 'package:json_api/http.dart';
 import 'package:json_api/routing.dart';
@@ -10,19 +11,24 @@ 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';
 
 Future<void> main() async {
   final host = 'localhost';
   final port = 8080;
-  final resources = ['colors'];
-  final repo = InMemoryRepo(resources);
-  await addColors(repo);
+  final repo = InMemoryRepo(['colors']);
+  await initRepo(repo);
   final controller = RepositoryController(repo, Uuid().v4);
-  HttpHandler handler = Router(controller, StandardUriDesign.matchTarget);
-  handler = TryCatchHandler(handler, onError: convertError);
+  interop.Handler handler =
+      ControllerRouter(controller, StandardUriDesign.matchTarget);
+  handler = TryCatchHandler(handler,
+      onError: ErrorConverter(onError: (e, stack) async {
+    stderr.writeln(e);
+    return Response(500,
+        document: OutboundErrorDocument(
+            [ErrorObject(title: 'Internal Server Error')]));
+  }));
   handler = LoggingHandler(handler,
-      onRequest: (r) => print('${r.method.toUpperCase()} ${r.uri}'),
+      onRequest: (r) => print('${r.method} ${r.uri}'),
       onResponse: (r) => print('${r.statusCode}'));
   final server = JsonApiServer(handler, host: host, port: port);
 
@@ -33,14 +39,10 @@ Future<void> main() async {
 
   await server.start();
 
-  print('The server is listening at $host:$port.'
-      ' Try opening the following URL(s) in your browser:');
-  for (var resource in resources) {
-    print('http://$host:$port/$resource');
-  }
+  print('The server is listening at $host:$port.');
 }
 
-Future addColors(Repository repo) async {
+Future initRepo(Repository repo) async {
   final models = {
     {'name': 'Salmon', 'r': 250, 'g': 128, 'b': 114},
     {'name': 'Pink', 'r': 255, 'g': 192, 'b': 203},
@@ -55,29 +57,3 @@ Future addColors(Repository repo) async {
     await repo.persist('colors', model);
   }
 }
-
-Future<HttpResponse> 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
index e42f2efa..c419a0a9 100644
--- a/example/server/cors_handler.dart
+++ b/example/server/cors_handler.dart
@@ -1,31 +1,31 @@
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/http.dart';
 
-class CorsHandler implements HttpHandler {
+class CorsHandler implements Handler {
   CorsHandler(this._inner);
 
-  final HttpHandler _inner;
+  final Handler _inner;
 
   @override
-  Future<HttpResponse> handle(HttpRequest request) async {
+  Future<Response> handle(Request request) async {
     final headers = {
-      'Access-Control-Allow-Origin': request.headers['origin'] ?? '*',
-      'Access-Control-Expose-Headers': 'Location',
+      'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'],
+      'Access-Control-Expose-Headers': ['Location'],
     };
 
-    if (request.isOptions) {
+    if (request.method.equals('OPTIONS')) {
       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 Response(
+          204,
+          Body.empty(),
+          Headers({
+            ...headers,
+            'Access-Control-Allow-Methods':
+                request.headers['Access-Control-Request-Method'] ?? methods,
+            'Access-Control-Allow-Headers':
+                request.headers['Access-Control-Request-Headers'] ?? ['*'],
+          }));
     }
-    return await _inner.handle(request)
-      ..headers.addAll(headers);
+    return await _inner.handle(request..headers.addAll(headers));
   }
 }
diff --git a/example/server/demo_handler.dart b/example/server/demo_handler.dart
deleted file mode 100644
index 9f973cd8..00000000
--- a/example/server/demo_handler.dart
+++ /dev/null
@@ -1,53 +0,0 @@
-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<String> 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<HttpResponse> _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
index d9a625f8..0871d219 100644
--- a/example/server/in_memory_repo.dart
+++ b/example/server/in_memory_repo.dart
@@ -1,4 +1,5 @@
 import 'package:json_api/document.dart';
+import 'package:json_api/server.dart';
 import 'package:json_api/src/nullable.dart';
 
 import 'repository.dart';
@@ -28,11 +29,11 @@ class InMemoryRepo implements Repository {
   }
 
   @override
-  Stream<Identity> addMany(
-      String type, String id, String rel, Iterable<Identity> ids) {
+  Stream<Identifier> addMany(
+      String type, String id, String rel, Iterable<Identifier> ids) {
     final many = _many(type, id, rel);
-    many.addAll(ids.map(Ref.of));
-    return Stream.fromIterable(many);
+    many.addAll(ids.map(Reference.of));
+    return Stream.fromIterable(many).map((e) => e.toIdentifier());
   }
 
   @override
@@ -47,28 +48,32 @@ class InMemoryRepo implements Repository {
 
   @override
   Future<void> replaceOne(
-      String type, String id, String rel, Identity? one) async {
-    _model(type, id).one[rel] = nullable(Ref.of)(one);
+      String type, String id, String rel, Identifier? one) async {
+    _model(type, id).one[rel] = nullable(Reference.of)(one);
   }
 
   @override
-  Stream<Identity> deleteMany(
-          String type, String id, String rel, Iterable<Identity> many) =>
-      Stream.fromIterable(_many(type, id, rel)..removeAll(many.map(Ref.of)));
+  Stream<Identifier> deleteMany(
+          String type, String id, String rel, Iterable<Identifier> many) =>
+      Stream.fromIterable(
+              _many(type, id, rel)..removeAll(many.map(Reference.of)))
+          .map((it) => it.toIdentifier());
 
   @override
-  Stream<Identity> replaceMany(
-          String type, String id, String rel, Iterable<Identity> many) =>
-      Stream.fromIterable(_many(type, id, rel)
-        ..clear()
-        ..addAll(many.map(Ref.of)));
+  Stream<Identifier> replaceMany(
+      String type, String id, String rel, Iterable<Identifier> many) {
+    final set = _many(type, id, rel);
+    set.clear();
+    set.addAll(many.map(Reference.of));
+    return Stream.fromIterable(set).map((it) => it.toIdentifier());
+  }
 
   Map<String, Model> _collection(String type) =>
-      (_storage[type] ?? (throw CollectionNotFound()));
+      (_storage[type] ?? (throw CollectionNotFound(type)));
 
   Model _model(String type, String id) =>
-      _collection(type)[id] ?? (throw ResourceNotFound());
+      _collection(type)[id] ?? (throw ResourceNotFound(type, id));
 
-  Set<Ref> _many(String type, String id, String rel) =>
-      _model(type, id).many[rel] ?? (throw RelationshipNotFound());
+  Set<Reference> _many(String type, String id, String rel) =>
+      _model(type, id).many[rel] ?? (throw RelationshipNotFound(type, id, rel));
 }
diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart
index defdb7e8..1d8510d6 100644
--- a/example/server/json_api_server.dart
+++ b/example/server/json_api_server.dart
@@ -1,7 +1,7 @@
 import 'dart:io';
 
+import 'package:http_interop/http_interop.dart';
 import 'package:http_interop_io/http_interop_io.dart';
-import 'package:json_api/http.dart';
 
 class JsonApiServer {
   JsonApiServer(
@@ -16,7 +16,7 @@ class JsonApiServer {
   /// Server port
   final int port;
 
-  final HttpHandler _handler;
+  final Handler _handler;
   HttpServer? _server;
 
   /// Server uri
diff --git a/example/server/repository.dart b/example/server/repository.dart
index 2cd63ed6..e8383533 100644
--- a/example/server/repository.dart
+++ b/example/server/repository.dart
@@ -16,8 +16,8 @@ abstract class Repository {
   /// Throws [CollectionNotFound].
   /// Throws [ResourceNotFound].
   /// Throws [RelationshipNotFound].
-  Stream<Identity> addMany(
-      String type, String id, String rel, Iterable<Identity> refs);
+  Stream<Identifier> addMany(
+      String type, String id, String rel, Iterable<Identifier> many);
 
   /// Delete the resource
   Future<void> delete(String type, String id);
@@ -25,33 +25,26 @@ abstract class Repository {
   /// Updates the model
   Future<void> update(String type, String id, ModelProps props);
 
-  Future<void> replaceOne(String type, String id, String rel, Identity? ref);
+  Future<void> replaceOne(String type, String id, String rel, Identifier? one);
 
   /// Deletes refs from the to-many relationship.
   /// Returns the new actual refs.
-  Stream<Identity> deleteMany(
-      String type, String id, String rel, Iterable<Identity> refs);
+  Stream<Identifier> deleteMany(
+      String type, String id, String rel, Iterable<Identifier> many);
 
   /// Replaces refs in the to-many relationship.
   /// Returns the new actual refs.
-  Stream<Identity> replaceMany(
-      String type, String id, String rel, Iterable<Identity> refs);
+  Stream<Identifier> replaceMany(
+      String type, String id, String rel, Iterable<Identifier> many);
 }
 
-class CollectionNotFound implements Exception {}
+class Reference {
+  Reference(this.type, this.id);
 
-class ResourceNotFound implements Exception {}
+  static Reference of(Identifier id) => Reference(id.type, id.id);
 
-class RelationshipNotFound implements Exception {}
-
-class Ref with Identity {
-  Ref(this.type, this.id);
-
-  static Ref of(Identity identity) => Ref(identity.type, identity.id);
-
-  @override
+  Identifier toIdentifier() => Identifier(type, id);
   final String type;
-  @override
   final String id;
 
   @override
@@ -59,22 +52,22 @@ class Ref with Identity {
 
   @override
   bool operator ==(Object other) =>
-      other is Ref && type == other.type && id == other.id;
+      other is Reference && type == other.type && id == other.id;
 }
 
 class ModelProps {
-  static ModelProps fromResource(ResourceProperties res) {
+  static ModelProps fromResource(Resource 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);
+        props.one[key] = nullable(Reference.of)(value.identifier);
         return;
       }
       if (value is ToMany) {
-        props.many[key] = Set.of(value.map(Ref.of));
+        props.many[key] = Set.of(value.map(Reference.of));
         return;
       }
     });
@@ -82,8 +75,8 @@ class ModelProps {
   }
 
   final attributes = <String, Object?>{};
-  final one = <String, Ref?>{};
-  final many = <String, Set<Ref>>{};
+  final one = <String, Reference?>{};
+  final many = <String, Set<Reference>>{};
 
   void setFrom(ModelProps other) {
     other.attributes.forEach((key, value) {
@@ -111,10 +104,10 @@ class Model extends ModelProps {
     });
     one.forEach((key, value) {
       res.relationships[key] =
-          (value == null ? ToOne.empty() : ToOne(Identifier.of(value)));
+          (value == null ? ToOne.empty() : ToOne(value.toIdentifier()));
     });
     many.forEach((key, value) {
-      res.relationships[key] = ToMany(value.map(Identifier.of));
+      res.relationships[key] = ToMany(value.map((it) => it.toIdentifier()));
     });
     return res;
   }
diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart
index 5e4100d1..c1f9dd95 100644
--- a/example/server/repository_controller.dart
+++ b/example/server/repository_controller.dart
@@ -1,8 +1,12 @@
+import 'dart:convert';
+
+import 'package:http_interop/extensions.dart';
+import 'package:http_interop/http_interop.dart' as http;
 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/client/payload_codec.dart';
 import 'package:json_api/src/nullable.dart';
 
 import 'relationship_node.dart';
@@ -18,7 +22,7 @@ class RepositoryController implements Controller {
   final design = StandardUriDesign.pathOnly;
 
   @override
-  Future<Response> fetchCollection(HttpRequest request, Target target) async {
+  Future<Response> fetchCollection(http.Request request, Target target) async {
     final resources = await _fetchAll(target.type).toList();
     final doc = OutboundDataDocument.collection(resources)
       ..links['self'] = Link(design.collection(target.type));
@@ -33,7 +37,7 @@ class RepositoryController implements Controller {
 
   @override
   Future<Response> fetchResource(
-      HttpRequest request, ResourceTarget target) async {
+      http.Request 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));
@@ -45,14 +49,16 @@ class RepositoryController implements Controller {
   }
 
   @override
-  Future<Response> createResource(HttpRequest request, Target target) async {
-    final res = (await _decode(request)).dataAsNewResource();
-    final ref = Ref(res.type, res.id ?? getId());
+  Future<Response> createResource(http.Request request, Target target) async {
+    final document = await _decode(request);
+    final newResource = document.dataAsNewResource();
+    final res = newResource.toResource(getId);
     await repo.persist(
-        res.type, Model(ref.id)..setFrom(ModelProps.fromResource(res)));
-    if (res.id != null) {
+        res.type, Model(res.id)..setFrom(ModelProps.fromResource(res)));
+    if (newResource.id != null) {
       return Response.noContent();
     }
+    final ref = Reference.of(res.toIdentifier());
     final self = Link(design.resource(ref.type, ref.id));
     final resource = (await _fetchResource(ref.type, ref.id))
       ..links['self'] = self;
@@ -63,25 +69,24 @@ class RepositoryController implements Controller {
 
   @override
   Future<Response> addMany(
-      HttpRequest request, RelationshipTarget target) async {
+      http.Request request, RelationshipTarget target) async {
     final many = (await _decode(request)).asRelationship<ToMany>();
     final refs = await repo
         .addMany(target.type, target.id, target.relationship, many)
         .toList();
-    return Response.ok(
-        OutboundDataDocument.many(ToMany(refs.map(Identifier.of))));
+    return Response.ok(OutboundDataDocument.many(ToMany(refs)));
   }
 
   @override
   Future<Response> deleteResource(
-      HttpRequest request, ResourceTarget target) async {
+      http.Request request, ResourceTarget target) async {
     await repo.delete(target.type, target.id);
     return Response.noContent();
   }
 
   @override
   Future<Response> updateResource(
-      HttpRequest request, ResourceTarget target) async {
+      http.Request request, ResourceTarget target) async {
     await repo.update(target.type, target.id,
         ModelProps.fromResource((await _decode(request)).dataAsResource()));
     return Response.noContent();
@@ -89,18 +94,17 @@ class RepositoryController implements Controller {
 
   @override
   Future<Response> replaceRelationship(
-      HttpRequest request, RelationshipTarget target) async {
+      http.Request 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))));
+      return Response.ok(
+          OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(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)));
     }
@@ -109,35 +113,35 @@ class RepositoryController implements Controller {
 
   @override
   Future<Response> deleteMany(
-      HttpRequest request, RelationshipTarget target) async {
+      http.Request 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<Response> fetchRelationship(
-      HttpRequest request, RelationshipTarget target) async {
+      http.Request 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]))));
+          ToOne(model.one[target.relationship]?.toIdentifier())));
     }
-    final many = model.many[target.relationship];
+    final many =
+        model.many[target.relationship]?.map((it) => it.toIdentifier());
     if (many != null) {
-      final doc = OutboundDataDocument.many(ToMany(many.map(Identifier.of)));
+      final doc = OutboundDataDocument.many(ToMany(many));
       return Response.ok(doc);
     }
-    throw RelationshipNotFound();
+    throw RelationshipNotFound(target.type, target.id, target.relationship);
   }
 
   @override
   Future<Response> fetchRelated(
-      HttpRequest request, RelatedTarget target) async {
+      http.Request request, RelatedTarget target) async {
     final model = await repo.fetch(target.type, target.id);
     if (model.one.containsKey(target.relationship)) {
       final related =
@@ -151,7 +155,7 @@ class RepositoryController implements Controller {
           await _fetchRelatedCollection(many).toList());
       return Response.ok(doc);
     }
-    throw RelationshipNotFound();
+    throw RelationshipNotFound(target.type, target.id, target.relationship);
   }
 
   /// Returns a stream of related resources recursively
@@ -168,7 +172,8 @@ class RepositoryController implements Controller {
   /// Returns a stream of related resources
   Stream<Resource> _getRelated(Resource resource, String relationship) async* {
     for (final _ in resource.relationships[relationship] ??
-        (throw RelationshipNotFound())) {
+        (throw RelationshipNotFound(
+            resource.type, resource.id, relationship))) {
       yield await _fetchLinkedResource(_.type, _.id);
     }
   }
@@ -187,19 +192,21 @@ class RepositoryController implements Controller {
     return (await repo.fetch(type, id)).toResource(type);
   }
 
-  Future<Resource?> _fetchRelatedResource(Ref ref) {
+  Future<Resource?> _fetchRelatedResource(Reference ref) {
     return _fetchLinkedResource(ref.type, ref.id);
   }
 
-  Stream<Resource> _fetchRelatedCollection(Iterable<Ref> refs) async* {
+  Stream<Resource> _fetchRelatedCollection(Iterable<Reference> refs) async* {
     for (final ref in refs) {
       final r = await _fetchRelatedResource(ref);
       if (r != null) yield r;
     }
   }
 
-  Future<InboundDocument> _decode(HttpRequest r) async =>
-      InboundDocument(await PayloadCodec().decode(r.body));
+  Future<InboundDocument> _decode(http.Request r) => r.body
+      .decode(utf8)
+      .then(const PayloadCodec().decode)
+      .then(InboundDocument.new);
 }
 
 typedef IdGenerator = String Function();
diff --git a/example/server/try_catch_handler.dart b/example/server/try_catch_handler.dart
deleted file mode 100644
index a9a02405..00000000
--- a/example/server/try_catch_handler.dart
+++ /dev/null
@@ -1,22 +0,0 @@
-import 'package:json_api/http.dart';
-
-class TryCatchHandler implements HttpHandler {
-  TryCatchHandler(this._inner, {this.onError = sendInternalServerError});
-
-  final HttpHandler _inner;
-  final Future<HttpResponse> Function(dynamic error) onError;
-
-  static Future<HttpResponse> sendInternalServerError(dynamic e) async =>
-      HttpResponse(500);
-
-  @override
-  Future<HttpResponse> 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 9f4f0efa..034266e7 100644
--- a/lib/client.dart
+++ b/lib/client.dart
@@ -23,9 +23,7 @@
 /// The [RoutingClient] should be your default choice.
 library client;
 
-export 'package:http_interop_http/http_interop_http.dart';
 export 'package:json_api/src/client/client.dart';
-export 'package:json_api/src/client/persistent_handler.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';
diff --git a/lib/document.dart b/lib/document.dart
index 03bac6c9..2618a546 100644
--- a/lib/document.dart
+++ b/lib/document.dart
@@ -2,15 +2,14 @@
 library document;
 
 export 'package:json_api/src/document/error_object.dart';
-export 'package:json_api/src/document/identifier.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/many.dart';
+export 'package:json_api/src/document/new_identifier.dart';
 export 'package:json_api/src/document/new_resource.dart';
-export 'package:json_api/src/document/one.dart';
+export 'package:json_api/src/document/new_to_many.dart';
+export 'package:json_api/src/document/new_to_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.dart';
-export 'package:json_api/src/document/resource_properties.dart';
+export 'package:json_api/src/document/to_many.dart';
+export 'package:json_api/src/document/to_one.dart';
diff --git a/lib/http.dart b/lib/http.dart
index f7c29309..0cd26e6f 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -1,15 +1,59 @@
-/// This is a thin HTTP layer abstraction used by the client and the server
-library http;
-
-export 'package:http_interop/http_interop.dart'
-    show
-        HttpMessage,
-        HttpHeaders,
-        HttpRequest,
-        HttpResponse,
-        HttpHandler,
-        LoggingHandler;
-export 'package:json_api/src/http/http_response_ext.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';
+import 'dart:convert';
+
+import 'package:http_interop/http_interop.dart';
+
+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;
+  static const unacceptable = 406;
+  static const unsupportedMediaType = 415;
+
+  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;
+}
+
+class Json extends Body {
+  Json(Map<String, Object?> json) : super(jsonEncode(json), utf8);
+}
+
+class LoggingHandler implements Handler {
+  LoggingHandler(this.handler, {this.onRequest, this.onResponse});
+
+  final Handler handler;
+  final Function(Request request)? onRequest;
+  final Function(Response response)? onResponse;
+
+  @override
+  Future<Response> handle(Request request) async {
+    onRequest?.call(request);
+    final response = await handler.handle(request);
+    onResponse?.call(response);
+    return response;
+  }
+}
+
+extension HeaderExt on Headers {
+  String? last(String key) {
+    final v = this[key];
+    if (v != null && v.isNotEmpty) {
+      return v.last;
+    }
+    return null;
+  }
+}
diff --git a/lib/query.dart b/lib/query.dart
index 1265a94c..335eb69c 100644
--- a/lib/query.dart
+++ b/lib/query.dart
@@ -2,7 +2,8 @@
 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.dart';
+export 'package:json_api/src/query/query_encodable.dart';
 export 'package:json_api/src/query/sort.dart';
diff --git a/lib/server.dart b/lib/server.dart
index f3913a8b..08f79a38 100644
--- a/lib/server.dart
+++ b/lib/server.dart
@@ -2,7 +2,12 @@
 library server;
 
 export 'package:json_api/src/server/controller.dart';
+export 'package:json_api/src/server/controller_router.dart';
+export 'package:json_api/src/server/error_converter.dart';
+export 'package:json_api/src/server/errors/collection_not_found.dart';
 export 'package:json_api/src/server/errors/method_not_allowed.dart';
+export 'package:json_api/src/server/errors/relationship_not_found.dart';
+export 'package:json_api/src/server/errors/resource_not_found.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';
+export 'package:json_api/src/server/try_catch_handler.dart';
diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index be1a50d1..083f05dd 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -1,37 +1,39 @@
-import 'package:http_interop_http/http_interop_http.dart';
+import 'dart:convert';
+
+import 'package:http_interop/extensions.dart';
+import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/http.dart';
+import 'package:json_api/src/client/payload_codec.dart';
 import 'package:json_api/src/client/request.dart';
 import 'package:json_api/src/client/response.dart';
+import 'package:json_api/src/media_type.dart';
 
 /// A basic JSON:API client.
 ///
 /// The JSON:API [Request] is converted to [HttpRequest] and sent downstream
-/// using the [handler]. Received [HttpResponse] is then converted back to
+/// using the [_handler]. Received [HttpResponse] is then converted back to
 /// JSON:API [Response]. JSON conversion is performed by the [codec].
 class Client {
-  const Client(
-      {PayloadCodec codec = const PayloadCodec(),
-      HttpHandler handler = const DisposableHandler()})
-      : _codec = codec,
-        _http = handler;
+  const Client(this._handler, {PayloadCodec codec = const PayloadCodec()})
+      : _codec = codec;
 
-  final HttpHandler _http;
+  final http.Handler _handler;
   final PayloadCodec _codec;
 
   /// Sends the [request] to the given [uri].
   Future<Response> 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,
-        if (body.isNotEmpty) 'Content-Type': mediaType,
-        ...request.headers
-      }));
+    final json = await _encode(request.document);
+    final body = http.Body(json, utf8);
+    final headers = http.Headers({
+      'Accept': [mediaType],
+      if (json.isNotEmpty) 'Content-Type': [mediaType],
+      ...request.headers
+    });
+    final url = request.query.isEmpty
+        ? uri
+        : uri.replace(queryParameters: request.query.toQuery());
+    final response = await _handler
+        .handle(http.Request(http.Method(request.method), url, body, headers));
 
     final document = await _decode(response);
     return Response(response, document);
@@ -40,6 +42,16 @@ class Client {
   Future<String> _encode(Object? doc) async =>
       doc == null ? '' : await _codec.encode(doc);
 
-  Future<Map?> _decode(HttpResponse response) async =>
-      response.hasDocument ? await _codec.decode(response.body) : null;
+  Future<Map?> _decode(http.Response response) async {
+    final json = await response.body.decode(utf8);
+    if (json.isNotEmpty &&
+        response.headers
+                .last('Content-Type')
+                ?.toLowerCase()
+                .startsWith(mediaType) ==
+            true) {
+      return await _codec.decode(json);
+    }
+    return null;
+  }
 }
diff --git a/lib/src/http/payload_codec.dart b/lib/src/client/payload_codec.dart
similarity index 80%
rename from lib/src/http/payload_codec.dart
rename to lib/src/client/payload_codec.dart
index 54cf3aa5..e3ed74be 100644
--- a/lib/src/http/payload_codec.dart
+++ b/lib/src/client/payload_codec.dart
@@ -2,6 +2,9 @@ import 'dart:async';
 import 'dart:convert';
 
 /// Encodes/decodes JSON payload.
+///
+/// The methods are designed to be asynchronous to allow for conversion to be
+/// performed in isolates if needed.
 class PayloadCodec {
   const PayloadCodec();
 
diff --git a/lib/src/client/persistent_handler.dart b/lib/src/client/persistent_handler.dart
deleted file mode 100644
index b6d5007f..00000000
--- a/lib/src/client/persistent_handler.dart
+++ /dev/null
@@ -1,25 +0,0 @@
-import 'dart:convert';
-
-import 'package:http/http.dart';
-import 'package:http_interop_http/http_interop_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 extends HandlerWrapper {
-  /// Creates a new instance of the handler. Do not forget to call `close()` on
-  /// the [client] when it's not longer needed.
-  ///
-  /// Use [messageConverter] to fine tune the HTTP request/response conversion.
-  PersistentHandler(
-      Client client,
-      {@Deprecated('Deprecated in favor of MessageConverter.'
-          ' To be removed in version 6.0.0')
-          this.defaultEncoding = utf8,
-      MessageConverter? messageConverter})
-      : super(client,
-            messageConverter: messageConverter ??
-                MessageConverter(defaultResponseEncoding: defaultEncoding));
-
-  final Encoding defaultEncoding;
-}
diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart
index e9fa7c22..d2f30bfb 100644
--- a/lib/src/client/request.dart
+++ b/lib/src/client/request.dart
@@ -1,9 +1,9 @@
-import 'package:json_api/http.dart';
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/query.dart';
 import 'package:json_api/src/client/client.dart';
 
 /// A generic JSON:API request.
-class Request with HttpHeaders {
+class Request {
   /// Creates a new instance if the request with the specified HTTP [method]
   /// and [document].
   Request(this.method, [this.document]);
@@ -20,43 +20,16 @@ class Request with HttpHeaders {
   /// Creates a PATCH request.
   Request.patch([Object? document]) : this('patch', document);
 
-  /// HTTP method
+  /// HTTP method.
   final String method;
 
   /// JSON:API document. This object can be of any type as long as it is
   /// encodable by the [PayloadCodec] used in the [Client].
   final Object? document;
 
-  /// Query parameters
-  final query = <String, String>{};
-
-  /// Requests inclusion of related resources.
-  /// See https://jsonapi.org/format/#fetching-includes
-  void include(Iterable<String> include) {
-    query.addAll(Include(include).asQueryParameters);
-  }
-
-  /// Sets sorting parameters.
-  /// See https://jsonapi.org/format/#fetching-sorting
-  void sort(Iterable<String> sort) {
-    query.addAll(Sort(sort).asQueryParameters);
-  }
-
-  /// Requests sparse fieldsets.
-  /// See https://jsonapi.org/format/#fetching-sparse-fieldsets
-  void fields(Map<String, Iterable<String>> fields) {
-    query.addAll(Fields(fields).asQueryParameters);
-  }
-
-  /// Sets pagination parameters.
-  /// See https://jsonapi.org/format/#fetching-pagination
-  void page(Map<String, String> page) {
-    query.addAll(Page(page).asQueryParameters);
-  }
-
-  /// Response filtering.
-  /// https://jsonapi.org/format/#fetching-filtering
-  void filter(Map<String, String> filter) {
-    query.addAll(Filter(filter).asQueryParameters);
-  }
+  /// Query parameters.
+  final query = Query();
+
+  /// HTTP headers.
+  final headers = Headers({});
 }
diff --git a/lib/src/client/response.dart b/lib/src/client/response.dart
index 8c516221..20732c10 100644
--- a/lib/src/client/response.dart
+++ b/lib/src/client/response.dart
@@ -1,12 +1,16 @@
+import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/http.dart';
 
 /// A generic JSON:API response.
 class Response {
-  Response(this.http, this.document);
+  Response(this.httpResponse, this.document);
 
   /// HTTP response
-  final HttpResponse http;
+  final http.Response httpResponse;
 
   /// Decoded JSON document
   final Map? document;
+
+  /// Returns true if the [statusCode] represents a failure
+  bool get isFailed => StatusCode(httpResponse.statusCode).isFailed;
 }
diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart
index 99c94e4e..d643258b 100644
--- a/lib/src/client/response/collection_fetched.dart
+++ b/lib/src/client/response/collection_fetched.dart
@@ -1,8 +1,8 @@
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
-import 'package:json_api/http.dart';
 
 class CollectionFetched {
-  CollectionFetched(this.http, Map json) {
+  CollectionFetched(this.httpResponse, Map json) {
     final document = InboundDocument(json);
     collection.addAll(document.dataAsCollection());
     included.addAll(document.included());
@@ -10,13 +10,13 @@ class CollectionFetched {
     links.addAll(document.links());
   }
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// The resource collection fetched from the server
-  final collection = ResourceCollection();
+  final collection = <Resource>[];
 
   /// Included resources
-  final included = ResourceCollection();
+  final included = <Resource>[];
 
   /// Top-level meta data
   final meta = <String, Object?>{};
diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart
index a6fb4ad3..39a71a73 100644
--- a/lib/src/client/response/related_resource_fetched.dart
+++ b/lib/src/client/response/related_resource_fetched.dart
@@ -1,11 +1,11 @@
+import 'package:http_interop/http_interop.dart';
 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)
+  RelatedResourceFetched(this.httpResponse, Map json)
       : resource = InboundDocument(json).dataAsResourceOrNull() {
     final document = InboundDocument(json);
     included.addAll(document.included());
@@ -13,13 +13,13 @@ class RelatedResourceFetched {
     links.addAll(document.links());
   }
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// Related resource. May be null
   final Resource? resource;
 
   /// Included resources
-  final included = ResourceCollection();
+  final included = <Resource>[];
 
   /// Top-level meta data
   final meta = <String, Object?>{};
diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart
index fa6ff0e4..a99c78eb 100644
--- a/lib/src/client/response/relationship_fetched.dart
+++ b/lib/src/client/response/relationship_fetched.dart
@@ -1,19 +1,19 @@
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
-import 'package:json_api/http.dart';
 
 /// A response to a relationship fetch request.
 class RelationshipFetched<R extends Relationship> {
-  RelationshipFetched(this.http, this.relationship);
+  RelationshipFetched(this.httpResponse, this.relationship);
 
-  static RelationshipFetched<ToMany> many(HttpResponse http, Map json) =>
-      RelationshipFetched(http, InboundDocument(json).asToMany())
+  static RelationshipFetched<ToMany> many(Response httpResponse, Map json) =>
+      RelationshipFetched(httpResponse, InboundDocument(json).asToMany())
         ..included.addAll(InboundDocument(json).included());
 
-  static RelationshipFetched<ToOne> one(HttpResponse http, Map json) =>
-      RelationshipFetched(http, InboundDocument(json).asToOne())
+  static RelationshipFetched<ToOne> one(Response httpResponse, Map json) =>
+      RelationshipFetched(httpResponse, InboundDocument(json).asToOne())
         ..included.addAll(InboundDocument(json).included());
 
-  final HttpResponse http;
+  final Response httpResponse;
   final R relationship;
-  final included = ResourceCollection();
+  final included = <Resource>[];
 }
diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart
index e596d925..2adb7421 100644
--- a/lib/src/client/response/relationship_updated.dart
+++ b/lib/src/client/response/relationship_updated.dart
@@ -1,19 +1,19 @@
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
-import 'package:json_api/http.dart';
 
 /// A response to a relationship request.
 class RelationshipUpdated<R extends Relationship> {
-  RelationshipUpdated(this.http, this.relationship);
+  RelationshipUpdated(this.httpResponse, this.relationship);
 
-  static RelationshipUpdated<ToMany> many(HttpResponse http, Map? json) =>
+  static RelationshipUpdated<ToMany> many(Response httpResponse, Map? json) =>
       RelationshipUpdated(
-          http, json == null ? null : InboundDocument(json).asToMany());
+          httpResponse, json == null ? null : InboundDocument(json).asToMany());
 
-  static RelationshipUpdated<ToOne> one(HttpResponse http, Map? json) =>
+  static RelationshipUpdated<ToOne> one(Response httpResponse, Map? json) =>
       RelationshipUpdated(
-          http, json == null ? null : InboundDocument(json).asToOne());
+          httpResponse, json == null ? null : InboundDocument(json).asToOne());
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// 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
index 924c729d..096a84ca 100644
--- a/lib/src/client/response/request_failure.dart
+++ b/lib/src/client/response/request_failure.dart
@@ -1,16 +1,16 @@
+import 'package:http_interop/http_interop.dart';
 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? document) {
+  RequestFailure(this.httpResponse, Map? document) {
     if (document != null) {
       errors.addAll(InboundDocument(document).errors());
       meta.addAll(InboundDocument(document).meta());
     }
   }
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// Error objects returned by the server
   final errors = <ErrorObject>[];
@@ -20,5 +20,5 @@ class RequestFailure implements Exception {
 
   @override
   String toString() =>
-      'JSON:API request failed with HTTP status ${http.statusCode}';
+      'JSON:API request failed with HTTP status ${httpResponse.statusCode}.';
 }
diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart
index 7cd3069d..3b157810 100644
--- a/lib/src/client/response/resource_created.dart
+++ b/lib/src/client/response/resource_created.dart
@@ -1,19 +1,19 @@
+import 'package:http_interop/http_interop.dart';
 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)
+  ResourceCreated(this.httpResponse, Map json)
       : resource = InboundDocument(json).dataAsResource() {
     meta.addAll(InboundDocument(json).meta());
     links.addAll(InboundDocument(json).links());
     included.addAll(InboundDocument(json).included());
   }
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// Created resource.
   final Resource resource;
@@ -25,5 +25,5 @@ class ResourceCreated {
   final links = <String, Link>{};
 
   /// Included resources
-  final included = ResourceCollection();
+  final included = <Resource>[];
 }
diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart
index 8cb44966..3e628298 100644
--- a/lib/src/client/response/resource_fetched.dart
+++ b/lib/src/client/response/resource_fetched.dart
@@ -1,16 +1,16 @@
+import 'package:http_interop/http_interop.dart';
 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)
+  ResourceFetched(this.httpResponse, 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 Response httpResponse;
   final Resource resource;
 
   /// Top-level meta data
@@ -20,5 +20,5 @@ class ResourceFetched {
   final links = <String, Link>{};
 
   /// Included resources
-  final included = ResourceCollection();
+  final included = <Resource>[];
 }
diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart
index 1a8b67a6..ca9b9198 100644
--- a/lib/src/client/response/resource_updated.dart
+++ b/lib/src/client/response/resource_updated.dart
@@ -1,8 +1,8 @@
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
-import 'package:json_api/http.dart';
 
 class ResourceUpdated {
-  ResourceUpdated(this.http, Map? json) : resource = _resource(json) {
+  ResourceUpdated(this.httpResponse, Map? json) : resource = _resource(json) {
     if (json != null) {
       included.addAll(InboundDocument(json).included());
       meta.addAll(InboundDocument(json).meta());
@@ -20,7 +20,7 @@ class ResourceUpdated {
     return null;
   }
 
-  final HttpResponse http;
+  final Response httpResponse;
 
   /// The created resource. Null for "204 No Content" responses.
   late final Resource? resource;
@@ -32,5 +32,5 @@ class ResourceUpdated {
   final links = <String, Link>{};
 
   /// Included resources
-  final included = ResourceCollection();
+  final included = <Resource>[];
 }
diff --git a/lib/src/client/routing_client.dart b/lib/src/client/routing_client.dart
index f4c03c66..9a250d07 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -1,5 +1,5 @@
 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/src/client/client.dart';
 import 'package:json_api/src/client/request.dart';
@@ -15,13 +15,12 @@ import 'package:json_api/src/client/response/resource_updated.dart';
 
 /// A routing JSON:API client
 class RoutingClient {
-  RoutingClient(this.baseUri, {Client client = const Client()})
-      : _client = client;
+  RoutingClient(this._baseUri, this._client);
 
   final Client _client;
-  final UriDesign baseUri;
+  final UriDesign _baseUri;
 
-  /// Adds [identifiers] to a to-many relationship
+  /// Adds the [identifiers] to the to-many relationship
   /// identified by [type], [id], [relationship].
   ///
   /// Optional arguments:
@@ -32,18 +31,17 @@ class RoutingClient {
     String relationship,
     List<Identifier> identifiers, {
     Map<String, Object?> meta = const {},
-    Map<String, String> headers = const {},
+    Map<String, List<String>> headers = const {},
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.post(
             OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
           ..headers.addAll(headers));
-    return RelationshipUpdated.many(response.http, response.document);
+    return RelationshipUpdated.many(response.httpResponse, response.document);
   }
 
-  /// Creates a new resource in the collection of type [type].
-  /// The server is responsible for assigning the resource id.
+  /// Creates a new resource with the given [type] and [id] on the server.
   ///
   /// Optional arguments:
   /// - [attributes] - resource attributes
@@ -52,19 +50,21 @@ class RoutingClient {
   /// - [meta] - resource meta data
   /// - [documentMeta] - document meta
   /// - [headers] - any extra HTTP headers
-  Future<ResourceCreated> createNew(
-    String type, {
+  /// - [query] - a collection of parameters to be included in the URI query
+  Future<ResourceUpdated> create(
+    String type,
+    String id, {
     Map<String, Object?> attributes = const {},
     Map<String, Identifier> one = const {},
     Map<String, Iterable<Identifier>> many = const {},
     Map<String, Object?> meta = const {},
     Map<String, Object?> documentMeta = const {},
-    Map<String, String> headers = const {},
-    Iterable<String> include = const [],
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.collection(type),
-        Request.post(OutboundDataDocument.newResource(NewResource(type)
+        _baseUri.collection(type),
+        Request.post(OutboundDataDocument.resource(Resource(type, id)
           ..attributes.addAll(attributes)
           ..relationships.addAll({
             ...one.map((key, value) => MapEntry(key, ToOne(value))),
@@ -73,181 +73,212 @@ class RoutingClient {
           ..meta.addAll(meta))
           ..meta.addAll(documentMeta))
           ..headers.addAll(headers)
-          ..include(include));
+          ..query.mergeAll(query));
+    return ResourceUpdated(response.httpResponse, response.document);
+  }
+
+  /// Creates a new resource in the collection of type [type].
+  /// The server is responsible for assigning the resource id.
+  ///
+  /// Optional arguments:
+  /// - [lid] - local resource id
+  /// - [attributes] - resource attributes
+  /// - [one] - resource to-one relationships
+  /// - [many] - resource to-many relationships
+  /// - [meta] - resource meta data
+  /// - [documentMeta] - document meta
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
+  Future<ResourceCreated> createNew(
+    String type, {
+    String? lid,
+    Map<String, Object?> attributes = const {},
+    Map<String, NewIdentifier> one = const {},
+    Map<String, Iterable<NewIdentifier>> many = const {},
+    Map<String, Object?> meta = const {},
+    Map<String, Object?> documentMeta = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
+  }) async {
+    final response = await send(
+        _baseUri.collection(type),
+        Request.post(
+            OutboundDataDocument.newResource(NewResource(type, lid: lid)
+              ..attributes.addAll(attributes)
+              ..relationships.addAll({
+                ...one.map((key, value) => MapEntry(key, NewToOne(value))),
+                ...many.map((key, value) => MapEntry(key, NewToMany(value))),
+              })
+              ..meta.addAll(meta))
+              ..meta.addAll(documentMeta))
+          ..headers.addAll(headers)
+          ..query.mergeAll(query));
 
     return ResourceCreated(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
-  /// Deletes [identifiers] from a to-many relationship
+  /// Deletes the [identifiers] from the to-many relationship
   /// identified by [type], [id], [relationship].
   ///
   /// Optional arguments:
   /// - [headers] - any extra HTTP headers
+  /// - [meta] - relationship meta data
   Future<RelationshipUpdated> deleteFromMany(
     String type,
     String id,
     String relationship,
     List<Identifier> identifiers, {
     Map<String, Object?> meta = const {},
-    Map<String, String> headers = const {},
+    Map<String, List<String>> headers = const {},
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.delete(
             OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
           ..headers.addAll(headers));
 
-    return RelationshipUpdated.many(response.http, response.document);
+    return RelationshipUpdated.many(response.httpResponse, response.document);
   }
 
-  /// Fetches  a primary collection of type [type].
+  /// Fetches the 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
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<CollectionFetched> fetchCollection(
     String type, {
-    Map<String, String> headers = const {},
-    Map<String, String> query = const {},
-    Map<String, String> page = const {},
-    Map<String, String> filter = const {},
-    Iterable<String> include = const [],
-    Iterable<String> sort = const [],
-    Map<String, Iterable<String>> fields = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.collection(type),
+        _baseUri.collection(type),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query)
-          ..page(page)
-          ..filter(filter)
-          ..include(include)
-          ..sort(sort)
-          ..fields(fields));
+          ..query.mergeAll(query));
     return CollectionFetched(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
-  /// Fetches a related resource collection
+  /// Fetches the 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
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<CollectionFetched> fetchRelatedCollection(
     String type,
     String id,
     String relationship, {
-    Map<String, String> headers = const {},
-    Map<String, String> page = const {},
-    Map<String, String> filter = const {},
-    Iterable<String> include = const [],
-    Iterable<String> sort = const [],
-    Map<String, Iterable<String>> fields = const {},
-    Map<String, String> query = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.related(type, id, relationship),
+        _baseUri.related(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query)
-          ..page(page)
-          ..filter(filter)
-          ..include(include)
-          ..sort(sort)
-          ..fields(fields));
+          ..query.mergeAll(query));
     return CollectionFetched(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
+  /// Fetches the to-one relationship
+  /// identified by [type], [id], [relationship].
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelationshipFetched<ToOne>> fetchToOne(
     String type,
     String id,
     String relationship, {
-    Map<String, String> headers = const {},
-    Map<String, String> query = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query));
+          ..query.mergeAll(query));
     return RelationshipFetched.one(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
+  /// Fetches the to-many relationship
+  /// identified by [type], [id], [relationship].
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelationshipFetched<ToMany>> fetchToMany(
     String type,
     String id,
     String relationship, {
-    Map<String, String> headers = const {},
-    Map<String, String> query = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query));
+          ..query.mergeAll(query));
     return RelationshipFetched.many(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
+  /// Fetches the related resource
+  /// identified by [type], [id], [relationship].
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelatedResourceFetched> fetchRelatedResource(
     String type,
     String id,
     String relationship, {
-    Map<String, String> headers = const {},
-    Map<String, String> query = const {},
-    Map<String, String> filter = const {},
-    Iterable<String> include = const [],
-    Map<String, Iterable<String>> fields = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.related(type, id, relationship),
+        _baseUri.related(type, id, relationship),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query)
-          ..filter(filter)
-          ..include(include)
-          ..fields(fields));
+          ..query.mergeAll(query));
     return RelatedResourceFetched(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
+  /// Fetches the resource identified by [type] and [id].
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<ResourceFetched> fetchResource(
     String type,
     String id, {
-    Map<String, String> headers = const {},
-    Map<String, String> filter = const {},
-    Iterable<String> include = const [],
-    Map<String, Iterable<String>> fields = const {},
-    Map<String, String> query = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.resource(type, id),
+        _baseUri.resource(type, id),
         Request.get()
           ..headers.addAll(headers)
-          ..query.addAll(query)
-          ..filter(filter)
-          ..include(include)
-          ..fields(fields));
+          ..query.mergeAll(query));
 
     return ResourceFetched(
-        response.http, response.document ?? (throw FormatException()));
+        response.httpResponse, response.document ?? (throw FormatException()));
   }
 
+  /// Updates the resource identified by [type] and [id].
+  ///
+  /// Optional arguments:
+  /// - [attributes] - attributes to update
+  /// - [one] - to-one relationships to update
+  /// - [many] - to-many relationships to update
+  /// - [meta] - resource meta data to update
+  /// - [documentMeta] - document meta data
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<ResourceUpdated> updateResource(
     String type,
     String id, {
@@ -256,11 +287,11 @@ class RoutingClient {
     Map<String, Iterable<Identifier>> many = const {},
     Map<String, Object?> meta = const {},
     Map<String, Object?> documentMeta = const {},
-    Map<String, String> headers = const {},
-    Iterable<String> include = const [],
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.resource(type, id),
+        _baseUri.resource(type, id),
         Request.patch(OutboundDataDocument.resource(Resource(type, id)
           ..attributes.addAll(attributes)
           ..relationships.addAll({
@@ -270,86 +301,106 @@ class RoutingClient {
           ..meta.addAll(meta))
           ..meta.addAll(documentMeta))
           ..headers.addAll(headers)
-          ..include(include));
-    return ResourceUpdated(response.http, response.document);
-  }
-
-  /// Creates a new resource with the given id on the server.
-  Future<ResourceUpdated> create(
-    String type,
-    String id, {
-    Map<String, Object?> attributes = const {},
-    Map<String, Identifier> one = const {},
-    Map<String, Iterable<Identifier>> many = const {},
-    Map<String, Object?> meta = const {},
-    Map<String, Object?> documentMeta = const {},
-    Map<String, String> headers = const {},
-    Iterable<String> include = const [],
-  }) async {
-    final response = await send(
-        baseUri.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))
-          ..meta.addAll(documentMeta))
-          ..headers.addAll(headers)
-          ..include(include));
-    return ResourceUpdated(response.http, response.document);
+          ..query.mergeAll(query));
+    return ResourceUpdated(response.httpResponse, response.document);
   }
 
+  /// Replaces the to-one relationship
+  /// identified by [type], [id], and [relationship] by setting
+  /// the new [identifier].
+  ///
+  /// Optional arguments:
+  /// - [meta] - relationship metadata
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelationshipUpdated<ToOne>> replaceToOne(
     String type,
     String id,
     String relationship,
     Identifier identifier, {
     Map<String, Object?> meta = const {},
-    Map<String, String> headers = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.patch(
             OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta)))
-          ..headers.addAll(headers));
-    return RelationshipUpdated.one(response.http, response.document);
+          ..headers.addAll(headers)
+          ..query.mergeAll(query));
+    return RelationshipUpdated.one(response.httpResponse, response.document);
   }
 
+  /// Replaces the to-many relationship
+  /// identified by [type], [id], and [relationship] by setting
+  /// the new [identifiers].
+  ///
+  /// Optional arguments:
+  /// - [meta] - relationship metadata
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelationshipUpdated<ToMany>> replaceToMany(
     String type,
     String id,
     String relationship,
     Iterable<Identifier> identifiers, {
     Map<String, Object?> meta = const {},
-    Map<String, String> headers = const {},
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
   }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.patch(
             OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
-          ..headers.addAll(headers));
-    return RelationshipUpdated.many(response.http, response.document);
+          ..headers.addAll(headers)
+          ..query.mergeAll(query));
+    return RelationshipUpdated.many(response.httpResponse, response.document);
   }
 
+  /// Removes the to-one relationship
+  /// identified by [type], [id], and [relationship]..
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
   Future<RelationshipUpdated<ToOne>> deleteToOne(
-      String type, String id, String relationship,
-      {Map<String, String> headers = const {}}) async {
+    String type,
+    String id,
+    String relationship, {
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
+  }) async {
     final response = await send(
-        baseUri.relationship(type, id, relationship),
+        _baseUri.relationship(type, id, relationship),
         Request.patch(OutboundDataDocument.one(ToOne.empty()))
-          ..headers.addAll(headers));
-    return RelationshipUpdated.one(response.http, response.document);
+          ..headers.addAll(headers)
+          ..query.mergeAll(query));
+    return RelationshipUpdated.one(response.httpResponse, response.document);
   }
 
-  Future<Response> deleteResource(String type, String id) =>
-      send(baseUri.resource(type, id), Request.delete());
+  /// Deletes the resource identified by [type] and [id].
+  ///
+  /// Optional arguments:
+  /// - [headers] - any extra HTTP headers
+  /// - [query] - a collection of parameters to be included in the URI query
+  Future<Response> deleteResource(
+    String type,
+    String id, {
+    Map<String, List<String>> headers = const {},
+    Iterable<QueryEncodable> query = const [],
+  }) =>
+      send(
+          _baseUri.resource(type, id),
+          Request.delete()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query));
 
+  /// Sends the [request] to the [uri] on the server.
+  /// This method can be used to send any non-standard requests.
   Future<Response> send(Uri uri, Request request) async {
     final response = await _client.send(uri, request);
-    if (response.http.isFailed) {
-      throw RequestFailure(response.http, response.document);
+    if (response.isFailed) {
+      throw RequestFailure(response.httpResponse, response.document);
     }
     return response;
   }
diff --git a/lib/src/document/identifier.dart b/lib/src/document/identifier.dart
deleted file mode 100644
index 76a0c137..00000000
--- a/lib/src/document/identifier.dart
+++ /dev/null
@@ -1,20 +0,0 @@
-import 'package:json_api/src/document/identity.dart';
-
-/// A Resource Identifier object
-class Identifier with Identity {
-  Identifier(this.type, this.id);
-
-  static Identifier of(Identity identity) =>
-      Identifier(identity.type, identity.id);
-
-  @override
-  final String type;
-  @override
-  final String id;
-
-  /// Identifier meta-data.
-  final meta = <String, Object?>{};
-
-  Map<String, Object> toJson() =>
-      {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta};
-}
diff --git a/lib/src/document/identity.dart b/lib/src/document/identity.dart
deleted file mode 100644
index 9a16a541..00000000
--- a/lib/src/document/identity.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-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
index 82f11220..962052aa 100644
--- a/lib/src/document/inbound_document.dart
+++ b/lib/src/document/inbound_document.dart
@@ -1,12 +1,15 @@
 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_identifier.dart';
+import 'package:json_api/src/document/new_relationship.dart';
 import 'package:json_api/src/document/new_resource.dart';
-import 'package:json_api/src/document/one.dart';
+import 'package:json_api/src/document/new_to_many.dart';
+import 'package:json_api/src/document/new_to_one.dart';
 import 'package:json_api/src/document/relationship.dart';
 import 'package:json_api/src/document/resource.dart';
+import 'package:json_api/src/document/to_many.dart';
+import 'package:json_api/src/document/to_one.dart';
 import 'package:json_api/src/nullable.dart';
 
 /// Inbound JSON:API document
@@ -79,18 +82,31 @@ class _Parser {
     return rel;
   }
 
-  Resource resource(Map json) =>
-      Resource(json.get<String>('type'), json.get<String>('id'))
+  NewRelationship newRelationship(Map json) {
+    final rel =
+        json.containsKey('data') ? _newRel(json['data']) : NewRelationship();
+    rel.links.addAll(links(json));
+    rel.meta.addAll(meta(json));
+    return rel;
+  }
+
+  Resource resource(Map json) => Resource(
+        json.get<String>('type'),
+        json.get<String>('id'),
+      )
         ..attributes.addAll(_getAttributes(json))
         ..relationships.addAll(_getRelationships(json))
         ..links.addAll(links(json))
         ..meta.addAll(meta(json));
 
-  NewResource newResource(Map json) => NewResource(json.get<String>('type'),
-      json.containsKey('id') ? json.get<String>('id') : null)
-    ..attributes.addAll(_getAttributes(json))
-    ..relationships.addAll(_getRelationships(json))
-    ..meta.addAll(meta(json));
+  NewResource newResource(Map json) => NewResource(
+        json.get<String>('type'),
+        id: json.getIfDefined('id'),
+        lid: json.getIfDefined('lid'),
+      )
+        ..attributes.addAll(_getAttributes(json))
+        ..relationships.addAll(_getNewRelationships(json))
+        ..meta.addAll(meta(json));
 
   /// Decodes Identifier from [json]. Returns the decoded object.
   /// If the [json] has incorrect format, throws  [FormatException].
@@ -98,6 +114,21 @@ class _Parser {
       Identifier(json.get<String>('type'), json.get<String>('id'))
         ..meta.addAll(meta(json));
 
+  /// Decodes NewIdentifier from [json]. Returns the decoded object.
+  /// If the [json] has incorrect format, throws  [FormatException].
+  NewIdentifier newIdentifier(Map json) {
+    final type = json.get<String>('type');
+    final id = json.getIfDefined<String>('id');
+    final lid = json.getIfDefined<String>('lid');
+    if (id != null) {
+      return Identifier(type, id)..meta.addAll(meta(json));
+    }
+    if (lid != null) {
+      return LocalIdentifier(type, lid)..meta.addAll(meta(json));
+    }
+    throw FormatException('Invalid JSON');
+  }
+
   ErrorObject errorObject(Map json) => ErrorObject(
       id: json.get<String>('id', orGet: () => ''),
       status: json.get<String>('status', orGet: () => ''),
@@ -131,23 +162,46 @@ class _Parser {
       .get<Map>('relationships', orGet: () => {})
       .map((key, value) => MapEntry(key, relationship(value)));
 
+  Map<String, NewRelationship> _getNewRelationships(Map json) => json
+      .get<Map>('relationships', orGet: () => {})
+      .map((key, value) => MapEntry(key, newRelationship(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>().map(identifier));
     throw FormatException('Invalid relationship object');
   }
+
+  NewRelationship _newRel(data) {
+    if (data == null) {
+      return NewToOne.empty();
+    }
+    if (data is Map) {
+      return NewToOne(newIdentifier(data));
+    }
+    if (data is List) {
+      return NewToMany(data.whereType<Map>().map(newIdentifier));
+    }
+    throw FormatException('Invalid relationship object');
+  }
 }
 
 extension _TypedGeter on Map {
   T get<T>(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 (containsKey(key)) return _get(key);
     if (orGet != null) return orGet();
     throw FormatException('Key "$key" does not exist');
   }
+
+  T? getIfDefined<T>(String key, {T Function()? orGet}) {
+    if (containsKey(key)) return _get(key);
+    return null;
+  }
+
+  T _get<T>(String key) {
+    final val = this[key];
+    if (val is T) return val;
+    throw FormatException('Key "$key": expected $T, found ${val.runtimeType}');
+  }
 }
diff --git a/lib/src/document/many.dart b/lib/src/document/many.dart
deleted file mode 100644
index 93605b1a..00000000
--- a/lib/src/document/many.dart
+++ /dev/null
@@ -1,27 +0,0 @@
-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 ToMany extends Relationship {
-  ToMany(Iterable<Identifier> identifiers) {
-    for (var id in identifiers) {
-      _map[id.key] = id;
-    }
-  }
-
-  final _map = <String, Identifier>{};
-
-  @override
-  Map<String, Object> toJson() =>
-      {'data': _map.values.toList(), ...super.toJson()};
-
-  @override
-  Iterator<Identifier> 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<Resource> findIn(ResourceCollection collection) =>
-      _map.keys.map((key) => collection[key]).whereType();
-}
diff --git a/lib/src/document/new_identifier.dart b/lib/src/document/new_identifier.dart
new file mode 100644
index 00000000..1c0653d6
--- /dev/null
+++ b/lib/src/document/new_identifier.dart
@@ -0,0 +1,69 @@
+/// A new Resource Identifier object, used when creating new resources on the server.
+sealed class NewIdentifier {
+  /// Resource type.
+  String get type;
+
+  /// Resource id.
+  String? get id;
+
+  /// Local Resource id.
+  String? get lid;
+
+  /// Identifier meta-data.
+  Map<String, Object?> get meta;
+
+  Map<String, Object> toJson();
+}
+
+/// A Resource Identifier object
+class Identifier implements NewIdentifier {
+  Identifier(this.type, this.id);
+
+  /// Resource type.
+  @override
+  final String type;
+
+  /// Resource id.
+  @override
+  final String id;
+
+  @override
+  final lid = null;
+
+  /// Identifier meta-data.
+  @override
+  final meta = <String, Object?>{};
+
+  @override
+  Map<String, Object> toJson() =>
+      {'type': type, 'id': id, if (meta.isNotEmpty) 'meta': meta};
+}
+
+class LocalIdentifier implements NewIdentifier {
+  LocalIdentifier(this.type, this.lid);
+
+  /// Resource type.
+  @override
+  final String type;
+
+  /// Resource id.
+  @override
+  final id = null;
+
+  /// Local Resource id.
+  @override
+  final String lid;
+
+  /// Identifier meta-data.
+  @override
+  final meta = <String, Object?>{};
+
+  @override
+  Map<String, Object> toJson() => {
+        'type': type,
+        'lid': lid,
+        if (meta.isNotEmpty) 'meta': meta,
+      };
+
+  Identifier toIdentifier(String id) => Identifier(type, id)..meta.addAll(meta);
+}
diff --git a/lib/src/document/new_relationship.dart b/lib/src/document/new_relationship.dart
new file mode 100644
index 00000000..f985ea06
--- /dev/null
+++ b/lib/src/document/new_relationship.dart
@@ -0,0 +1,19 @@
+import 'dart:collection';
+
+import 'package:json_api/src/document/link.dart';
+import 'package:json_api/src/document/new_identifier.dart';
+
+class NewRelationship with IterableMixin<NewIdentifier> {
+  final links = <String, Link>{};
+  final meta = <String, Object?>{};
+
+  Map<String, dynamic> toJson() => {
+        if (links.isNotEmpty) 'links': links,
+        if (meta.isNotEmpty) 'meta': meta,
+      };
+
+  // coverage:ignore-start
+  @override
+  Iterator<NewIdentifier> get iterator => <NewIdentifier>[].iterator;
+// coverage:ignore-end
+}
diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart
index b8ee227c..b111ab72 100644
--- a/lib/src/document/new_resource.dart
+++ b/lib/src/document/new_resource.dart
@@ -1,22 +1,85 @@
-import 'package:json_api/src/document/resource_properties.dart';
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/new_relationship.dart';
+import 'package:json_api/src/document/new_to_many.dart';
+import 'package:json_api/src/document/new_to_one.dart';
+import 'package:json_api/src/document/relationship.dart';
+import 'package:json_api/src/document/resource.dart';
+import 'package:json_api/src/document/to_many.dart';
+import 'package:json_api/src/document/to_one.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);
-  }
+class NewResource {
+  NewResource(this.type, {this.id, this.lid});
 
   /// Resource type
   final String type;
 
-  /// Nullable. Resource id.
+  /// Resource id.
   final String? id;
 
+  /// Local resource id.
+  final String? lid;
+
+  /// Resource meta data.
+  final meta = <String, Object?>{};
+
+  /// Resource attributes.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-attributes
+  final attributes = <String, Object?>{};
+
+  /// Resource relationships.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-relationships
+  final relationships = <String, NewRelationship>{};
+
   Map<String, Object> toJson() => {
         'type': type,
         if (id != null) 'id': id!,
+        if (lid != null) 'lid': lid!,
         if (attributes.isNotEmpty) 'attributes': attributes,
         if (relationships.isNotEmpty) 'relationships': relationships,
         if (meta.isNotEmpty) 'meta': meta,
       };
+
+  /// Converts this to a real [Resource] object, assigning the id if necessary.
+  Resource toResource(String Function() getId) {
+    final resource = Resource(type, id ?? getId());
+    resource.attributes.addAll(attributes);
+    resource.relationships.addAll(_toRelationships(resource.id));
+    resource.meta.addAll(meta);
+    return resource;
+  }
+
+  Map<String, Relationship> _toRelationships(String id) => relationships
+      .map((k, v) => MapEntry(k, _toRelationship(v, id)..meta.addAll(v.meta)));
+
+  Relationship _toRelationship(NewRelationship r, String id) {
+    if (r is NewToOne) {
+      return ToOne(_toIdentifierOrNull(r.identifier, id));
+    }
+    if (r is NewToMany) {
+      return ToMany(r.map((identifier) => _toIdentifier(identifier, id)));
+    }
+    // coverage:ignore-line
+    throw StateError('Unexpected relationship type: ${r.runtimeType}');
+  }
+
+  Identifier? _toIdentifierOrNull(NewIdentifier? identifier, String id) {
+    if (identifier == null) return null;
+    return _toIdentifier(identifier, id);
+  }
+
+  Identifier _toIdentifier(NewIdentifier identifier, String id) {
+    switch (identifier) {
+      case Identifier():
+        return identifier;
+      case LocalIdentifier():
+        if (identifier.type == type && identifier.lid == lid) {
+          return identifier.toIdentifier(id);
+        }
+        throw StateError(
+            'Unmatched local id: "${identifier.lid}". Expected "$lid".');
+    }
+  }
 }
diff --git a/lib/src/document/new_to_many.dart b/lib/src/document/new_to_many.dart
new file mode 100644
index 00000000..7d8da3d9
--- /dev/null
+++ b/lib/src/document/new_to_many.dart
@@ -0,0 +1,19 @@
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/new_relationship.dart';
+
+class NewToMany extends NewRelationship {
+  NewToMany(Iterable<NewIdentifier> identifiers) {
+    _ids.addAll(identifiers);
+  }
+
+  final _ids = <NewIdentifier>[];
+
+  @override
+  Map<String, Object> toJson() => {
+        'data': [..._ids],
+        ...super.toJson()
+      };
+
+  @override
+  Iterator<NewIdentifier> get iterator => _ids.iterator;
+}
diff --git a/lib/src/document/new_to_one.dart b/lib/src/document/new_to_one.dart
new file mode 100644
index 00000000..aefcd46c
--- /dev/null
+++ b/lib/src/document/new_to_one.dart
@@ -0,0 +1,17 @@
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/new_relationship.dart';
+
+class NewToOne extends NewRelationship {
+  NewToOne(this.identifier);
+
+  NewToOne.empty() : this(null);
+
+  @override
+  Map<String, dynamic> toJson() => {'data': identifier, ...super.toJson()};
+
+  final NewIdentifier? identifier;
+
+  @override
+  Iterator<NewIdentifier> get iterator =>
+      identifier == null ? super.iterator : [identifier!].iterator;
+}
diff --git a/lib/src/document/one.dart b/lib/src/document/one.dart
deleted file mode 100644
index 99680bd3..00000000
--- a/lib/src/document/one.dart
+++ /dev/null
@@ -1,23 +0,0 @@
-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<String, dynamic> toJson() => {'data': identifier, ...super.toJson()};
-
-  final Identifier? identifier;
-
-  @override
-  Iterator<Identifier> get iterator =>
-      identifier == null ? <Identifier>[].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
index a74d431c..78192f62 100644
--- a/lib/src/document/outbound_document.dart
+++ b/lib/src/document/outbound_document.dart
@@ -1,9 +1,9 @@
 import 'package:json_api/src/document/error_object.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/resource.dart';
+import 'package:json_api/src/document/to_many.dart';
+import 'package:json_api/src/document/to_one.dart';
 
 /// A sever-to-client document.
 class OutboundDocument {
diff --git a/lib/src/document/relationship.dart b/lib/src/document/relationship.dart
index 2eecb4a7..3526ffc0 100644
--- a/lib/src/document/relationship.dart
+++ b/lib/src/document/relationship.dart
@@ -1,7 +1,7 @@
 import 'dart:collection';
 
-import 'package:json_api/src/document/identifier.dart';
 import 'package:json_api/src/document/link.dart';
+import 'package:json_api/src/document/new_identifier.dart';
 
 class Relationship with IterableMixin<Identifier> {
   final links = <String, Link>{};
@@ -13,5 +13,5 @@ class Relationship with IterableMixin<Identifier> {
       };
 
   @override
-  Iterator<Identifier> get iterator => const <Identifier>[].iterator;
+  Iterator<Identifier> get iterator => <Identifier>[].iterator;
 }
diff --git a/lib/src/document/resource.dart b/lib/src/document/resource.dart
index 1b775dcb..b9ca5f71 100644
--- a/lib/src/document/resource.dart
+++ b/lib/src/document/resource.dart
@@ -1,18 +1,50 @@
-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';
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/relationship.dart';
+import 'package:json_api/src/document/to_many.dart';
+import 'package:json_api/src/document/to_one.dart';
 
-class Resource with ResourceProperties, Identity {
+class Resource {
   Resource(this.type, this.id);
 
-  @override
+  /// Resource type.
   final String type;
-  @override
+
+  /// Resource id.
   final String id;
 
   /// Resource links
   final links = <String, Link>{};
 
+  /// Resource meta data.
+  final meta = <String, Object?>{};
+
+  /// Resource attributes.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-attributes
+  final attributes = <String, Object?>{};
+
+  /// Resource relationships.
+  ///
+  /// See https://jsonapi.org/format/#document-resource-object-relationships
+  final relationships = <String, Relationship>{};
+
+  /// Creates a new [Identifier] for this resource.
+  Identifier toIdentifier() => Identifier(type, id);
+
+  /// Returns a to-one relationship by its [name].
+  ToOne? one(String name) => _rel<ToOne>(name);
+
+  /// Returns a to-many relationship by its [name].
+  ToMany? many(String name) => _rel<ToMany>(name);
+
+  /// Returns a typed relationship by its [name].
+  R? _rel<R extends Relationship>(String name) {
+    final r = relationships[name];
+    if (r is R) return r;
+    return null;
+  }
+
   Map<String, Object> toJson() => {
         'type': type,
         'id': id,
diff --git a/lib/src/document/resource_collection.dart b/lib/src/document/resource_collection.dart
deleted file mode 100644
index 95533d85..00000000
--- a/lib/src/document/resource_collection.dart
+++ /dev/null
@@ -1,21 +0,0 @@
-import 'dart:collection';
-
-import 'package:json_api/document.dart';
-
-/// A collection of resources indexed by key.
-class ResourceCollection with IterableMixin<Resource> {
-  final _map = <String, Resource>{};
-
-  Resource? operator [](Object? key) => _map[key];
-
-  void add(Resource resource) {
-    _map[resource.key] = resource;
-  }
-
-  void addAll(Iterable<Resource> resources) {
-    resources.forEach(add);
-  }
-
-  @override
-  Iterator<Resource> get iterator => _map.values.iterator;
-}
diff --git a/lib/src/document/resource_properties.dart b/lib/src/document/resource_properties.dart
deleted file mode 100644
index ef522fb0..00000000
--- a/lib/src/document/resource_properties.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-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 = <String, Object?>{};
-
-  /// Resource attributes.
-  ///
-  /// See https://jsonapi.org/format/#document-resource-object-attributes
-  final attributes = <String, Object?>{};
-
-  /// Resource relationships.
-  ///
-  /// See https://jsonapi.org/format/#document-resource-object-relationships
-  final relationships = <String, Relationship>{};
-
-  /// Returns a to-one relationship by its [name].
-  ToOne? one(String name) => _rel<ToOne>(name);
-
-  /// Returns a to-many relationship by its [name].
-  ToMany? many(String name) => _rel<ToMany>(name);
-
-  /// Returns a typed relationship by its [name].
-  R? _rel<R extends Relationship>(String name) {
-    final r = relationships[name];
-    if (r is R) return r;
-    return null;
-  }
-}
diff --git a/lib/src/document/to_many.dart b/lib/src/document/to_many.dart
new file mode 100644
index 00000000..902b1ff0
--- /dev/null
+++ b/lib/src/document/to_many.dart
@@ -0,0 +1,19 @@
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/relationship.dart';
+
+class ToMany extends Relationship {
+  ToMany(Iterable<Identifier> identifiers) {
+    _ids.addAll(identifiers);
+  }
+
+  final _ids = <Identifier>[];
+
+  @override
+  Map<String, Object> toJson() => {
+        'data': [..._ids],
+        ...super.toJson()
+      };
+
+  @override
+  Iterator<Identifier> get iterator => _ids.iterator;
+}
diff --git a/lib/src/document/to_one.dart b/lib/src/document/to_one.dart
new file mode 100644
index 00000000..ae61979f
--- /dev/null
+++ b/lib/src/document/to_one.dart
@@ -0,0 +1,17 @@
+import 'package:json_api/src/document/new_identifier.dart';
+import 'package:json_api/src/document/relationship.dart';
+
+class ToOne extends Relationship {
+  ToOne(this.identifier);
+
+  ToOne.empty() : this(null);
+
+  @override
+  Map<String, dynamic> toJson() => {'data': identifier, ...super.toJson()};
+
+  final Identifier? identifier;
+
+  @override
+  Iterator<Identifier> get iterator =>
+      identifier == null ? super.iterator : [identifier!].iterator;
+}
diff --git a/lib/src/http/http_response_ext.dart b/lib/src/http/http_response_ext.dart
deleted file mode 100644
index 62598108..00000000
--- a/lib/src/http/http_response_ext.dart
+++ /dev/null
@@ -1,15 +0,0 @@
-import 'package:http_interop/http_interop.dart' as interop;
-import 'package:json_api/src/http/media_type.dart';
-import 'package:json_api/src/http/status_code.dart';
-
-/// The response sent by the server and received by the client
-extension HttpResponseExt on interop.HttpResponse {
-  /// True if the body is not empty and the Content-Type
-  /// is `application/vnd.api+json`
-  bool get hasDocument =>
-      body.isNotEmpty &&
-      (headers['Content-Type'] ?? '').toLowerCase().startsWith(mediaType);
-
-  /// Returns true if the [statusCode] represents a failure
-  bool get isFailed => StatusCode(statusCode).isFailed;
-}
diff --git a/lib/src/http/status_code.dart b/lib/src/http/status_code.dart
deleted file mode 100644
index c59ca8df..00000000
--- a/lib/src/http/status_code.dart
+++ /dev/null
@@ -1,23 +0,0 @@
-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/http/media_type.dart b/lib/src/media_type.dart
similarity index 100%
rename from lib/src/http/media_type.dart
rename to lib/src/media_type.dart
diff --git a/lib/src/nullable.dart b/lib/src/nullable.dart
index f6dfb1e4..baea3203 100644
--- a/lib/src/nullable.dart
+++ b/lib/src/nullable.dart
@@ -1,2 +1,3 @@
+/// Nullable function application.
 U? Function(V? v) nullable<V, U>(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 05d99b17..0291e86f 100644
--- a/lib/src/query/fields.dart
+++ b/lib/src/query/fields.dart
@@ -1,8 +1,10 @@
 import 'dart:collection';
 
+import 'package:json_api/src/query/query_encodable.dart';
+
 /// Query parameters defining Sparse Fieldsets
 /// @see https://jsonapi.org/format/#fetching-sparse-fieldsets
-class Fields with MapMixin<String, Iterable<String>> {
+class Fields with MapMixin<String, Iterable<String>> implements QueryEncodable {
   /// The [fields] argument maps the resource type to a list of fields.
   ///
   /// Example:
@@ -25,8 +27,9 @@ class Fields with MapMixin<String, Iterable<String>> {
   final _map = <String, Iterable<String>>{};
 
   /// Converts to a map of query parameters
-  Map<String, String> get asQueryParameters =>
-      _map.map((k, v) => MapEntry('fields[$k]', v.join(',')));
+  @override
+  Map<String, List<String>> toQuery() =>
+      _map.map((k, v) => MapEntry('fields[$k]', [v.join(',')]));
 
   @override
   void operator []=(String key, Iterable<String> value) => _map[key] = value;
diff --git a/lib/src/query/filter.dart b/lib/src/query/filter.dart
deleted file mode 100644
index 43387400..00000000
--- a/lib/src/query/filter.dart
+++ /dev/null
@@ -1,42 +0,0 @@
-import 'dart:collection';
-
-class Filter with MapMixin<String, String> {
-  /// Example:
-  /// ```dart
-  /// Filter({'post': '1,2', 'author': '12'}).addTo(url);
-  /// ```
-  /// encodes into
-  /// ```
-  /// ?filter[post]=1,2&filter[author]=12
-  /// ```
-  Filter([Map<String, String> parameters = const {}]) {
-    addAll(parameters);
-  }
-
-  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 _ = <String, String>{};
-
-  /// Converts to a map of query parameters
-  Map<String, String> 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<String> 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 8b25bdb9..8d26b23b 100644
--- a/lib/src/query/include.dart
+++ b/lib/src/query/include.dart
@@ -1,8 +1,10 @@
 import 'dart:collection';
 
+import 'package:json_api/src/query/query_encodable.dart';
+
 /// Query parameter defining inclusion of related resources.
 /// @see https://jsonapi.org/format/#fetching-includes
-class Include with IterableMixin<String> {
+class Include with IterableMixin<String> implements QueryEncodable {
   /// Example:
   /// ```dart
   /// Include(['comments', 'comments.author']);
@@ -17,8 +19,10 @@ class Include with IterableMixin<String> {
   final _ = <String>[];
 
   /// Converts to a map of query parameters
-  Map<String, String> get asQueryParameters =>
-      {if (isNotEmpty) 'include': join(',')};
+  @override
+  Map<String, List<String>> toQuery() => {
+        if (isNotEmpty) 'include': [join(',')]
+      };
 
   @override
   Iterator<String> get iterator => _.iterator;
diff --git a/lib/src/query/page.dart b/lib/src/query/page.dart
index d4f441e5..e00ceea9 100644
--- a/lib/src/query/page.dart
+++ b/lib/src/query/page.dart
@@ -1,8 +1,10 @@
 import 'dart:collection';
 
+import 'package:json_api/src/query/query_encodable.dart';
+
 /// Query parameters defining the pagination data.
 /// @see https://jsonapi.org/format/#fetching-pagination
-class Page with MapMixin<String, String> {
+class Page with MapMixin<String, String> implements QueryEncodable {
   /// Example:
   /// ```dart
   /// Page({'limit': '10', 'offset': '20'}).addTo(url);
@@ -24,8 +26,9 @@ class Page with MapMixin<String, String> {
   final _ = <String, String>{};
 
   /// Converts to a map of query parameters
-  Map<String, String> get asQueryParameters =>
-      _.map((k, v) => MapEntry('page[$k]', v));
+  @override
+  Map<String, List<String>> toQuery() =>
+      _.map((k, v) => MapEntry('page[$k]', [v]));
 
   @override
   String? operator [](Object? key) => _[key];
diff --git a/lib/src/query/query.dart b/lib/src/query/query.dart
new file mode 100644
index 00000000..f4356e4b
--- /dev/null
+++ b/lib/src/query/query.dart
@@ -0,0 +1,42 @@
+import 'package:json_api/src/query/query_encodable.dart';
+
+/// Arbitrary query parameters.
+class Query implements QueryEncodable {
+  Query([Map<String, Iterable<String>> parameters = const {}]) {
+    mergeMap(parameters);
+  }
+
+  final _parameters = <String, List<String>>{};
+
+  /// Returns true if the collection is empty.
+  get isEmpty => _parameters.isEmpty;
+
+  /// Adds a new [value] for the [key].
+  void addValue(String key, String value) {
+    _parameters.putIfAbsent(key, () => []);
+    _parameters[key]!.add(value);
+  }
+
+  /// Merges the query [parameters] into this object.
+  void mergeMap(Map<String, Iterable<String>> parameters) {
+    parameters.forEach((key, values) {
+      for (final value in values) {
+        addValue(key, value);
+      }
+    });
+  }
+
+  /// Merges parameters from another [encodable] into this object.
+  void merge(QueryEncodable encodable) {
+    mergeMap(encodable.toQuery());
+  }
+
+  /// Merges parameters from other [encodables] into this object.
+  void mergeAll(Iterable<QueryEncodable> encodables) {
+    encodables.forEach(merge);
+  }
+
+  @override
+  Map<String, List<String>> toQuery() =>
+      _parameters.map((key, value) => MapEntry(key, value.toList()));
+}
diff --git a/lib/src/query/query_encodable.dart b/lib/src/query/query_encodable.dart
new file mode 100644
index 00000000..055ceade
--- /dev/null
+++ b/lib/src/query/query_encodable.dart
@@ -0,0 +1,7 @@
+/// An object which cab be represented as URI query parameters.
+abstract class QueryEncodable {
+  /// Returns the map representing query parameters.
+  /// Each key may have zero or more values.
+  /// `{'foo': ['bar', 'baz']}` represents `?foo=bar&foo=baz`
+  Map<String, List<String>> toQuery();
+}
diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart
index 85200cc5..e75f6313 100644
--- a/lib/src/query/sort.dart
+++ b/lib/src/query/sort.dart
@@ -1,8 +1,10 @@
 import 'dart:collection';
 
+import 'package:json_api/src/query/query_encodable.dart';
+
 /// Query parameters defining the sorting.
 /// @see https://jsonapi.org/format/#fetching-sorting
-class Sort with IterableMixin<SortField> {
+class Sort with IterableMixin<SortField> implements QueryEncodable {
   /// The [fields] arguments is the list of sorting criteria.
   ///
   /// Example:
@@ -19,8 +21,10 @@ class Sort with IterableMixin<SortField> {
   final _ = <SortField>[];
 
   /// Converts to a map of query parameters
-  Map<String, String> get asQueryParameters =>
-      {if (isNotEmpty) 'sort': join(',')};
+  @override
+  Map<String, List<String>> toQuery() => {
+        if (isNotEmpty) 'sort': [join(',')]
+      };
 
   @override
   int get length => _.length;
diff --git a/lib/src/server/controller.dart b/lib/src/server/controller.dart
index 9759d0b6..6936003d 100644
--- a/lib/src/server/controller.dart
+++ b/lib/src/server/controller.dart
@@ -1,41 +1,43 @@
-import 'package:json_api/http.dart';
+import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/routing.dart';
 
 /// JSON:API controller
 abstract class Controller {
   /// Fetch a primary resource collection
-  Future<HttpResponse> fetchCollection(HttpRequest request, Target target);
+  Future<http.Response> fetchCollection(http.Request request, Target target);
 
   /// Create resource
-  Future<HttpResponse> createResource(HttpRequest request, Target target);
+  Future<http.Response> createResource(http.Request request, Target target);
 
   /// Fetch a single primary resource
-  Future<HttpResponse> fetchResource(
-      HttpRequest request, ResourceTarget target);
+  Future<http.Response> fetchResource(
+      http.Request request, ResourceTarget target);
 
   /// Updates a primary resource
-  Future<HttpResponse> updateResource(
-      HttpRequest request, ResourceTarget target);
+  Future<http.Response> updateResource(
+      http.Request request, ResourceTarget target);
 
   /// Deletes the primary resource
-  Future<HttpResponse> deleteResource(
-      HttpRequest request, ResourceTarget target);
+  Future<http.Response> deleteResource(
+      http.Request request, ResourceTarget target);
 
   /// Fetches a relationship
-  Future<HttpResponse> fetchRelationship(
-      HttpRequest rq, RelationshipTarget target);
+  Future<http.Response> fetchRelationship(
+      http.Request rq, RelationshipTarget target);
 
   /// Add new entries to a to-many relationship
-  Future<HttpResponse> addMany(HttpRequest request, RelationshipTarget target);
+  Future<http.Response> addMany(
+      http.Request request, RelationshipTarget target);
 
   /// Updates the relationship
-  Future<HttpResponse> replaceRelationship(
-      HttpRequest request, RelationshipTarget target);
+  Future<http.Response> replaceRelationship(
+      http.Request request, RelationshipTarget target);
 
   /// Deletes the members from the to-many relationship
-  Future<HttpResponse> deleteMany(
-      HttpRequest request, RelationshipTarget target);
+  Future<http.Response> deleteMany(
+      http.Request request, RelationshipTarget target);
 
   /// Fetches related resource or collection
-  Future<HttpResponse> fetchRelated(HttpRequest request, RelatedTarget target);
+  Future<http.Response> fetchRelated(
+      http.Request request, RelatedTarget target);
 }
diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart
new file mode 100644
index 00000000..3f45e37e
--- /dev/null
+++ b/lib/src/server/controller_router.dart
@@ -0,0 +1,80 @@
+import 'package:http_interop/http_interop.dart';
+import 'package:http_parser/http_parser.dart';
+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/unacceptable.dart';
+import 'package:json_api/src/server/errors/unmatched_target.dart';
+import 'package:json_api/src/server/errors/unsupported_media_type.dart';
+
+class ControllerRouter implements Handler {
+  ControllerRouter(this._controller, this._matchTarget);
+
+  final Controller _controller;
+  final Target? Function(Uri uri) _matchTarget;
+
+  @override
+  Future<Response> handle(Request request) async {
+    _validate(request);
+    final target = _matchTarget(request.uri);
+    if (target is RelationshipTarget) {
+      if (request.method.equals('GET')) {
+        return await _controller.fetchRelationship(request, target);
+      }
+      if (request.method.equals('POST')) {
+        return await _controller.addMany(request, target);
+      }
+      if (request.method.equals('PATCH')) {
+        return await _controller.replaceRelationship(request, target);
+      }
+      if (request.method.equals('DELETE')) {
+        return await _controller.deleteMany(request, target);
+      }
+      throw MethodNotAllowed(request.method.value);
+    }
+    if (target is RelatedTarget) {
+      if (request.method.equals('GET')) {
+        return await _controller.fetchRelated(request, target);
+      }
+      throw MethodNotAllowed(request.method.value);
+    }
+    if (target is ResourceTarget) {
+      if (request.method.equals('GET')) {
+        return await _controller.fetchResource(request, target);
+      }
+      if (request.method.equals('PATCH')) {
+        return await _controller.updateResource(request, target);
+      }
+      if (request.method.equals('DELETE')) {
+        return await _controller.deleteResource(request, target);
+      }
+      throw MethodNotAllowed(request.method.value);
+    }
+    if (target is Target) {
+      if (request.method.equals('GET')) {
+        return await _controller.fetchCollection(request, target);
+      }
+      if (request.method.equals('POST')) {
+        return await _controller.createResource(request, target);
+      }
+      throw MethodNotAllowed(request.method.value);
+    }
+    throw UnmatchedTarget(request.uri);
+  }
+
+  void _validate(Request request) {
+    final contentType = request.headers.last('Content-Type');
+    if (contentType != null && !_isValid(MediaType.parse(contentType))) {
+      throw UnsupportedMediaType();
+    }
+    final accept = request.headers.last('Accept');
+    if (accept != null && !_isValid(MediaType.parse(accept))) {
+      throw Unacceptable();
+    }
+  }
+
+  bool _isValid(MediaType mediaType) {
+    return mediaType.parameters.isEmpty; // TODO: check for ext and profile
+  }
+}
diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart
new file mode 100644
index 00000000..b1d91262
--- /dev/null
+++ b/lib/src/server/error_converter.dart
@@ -0,0 +1,70 @@
+import 'package:http_interop/http_interop.dart' as http;
+import 'package:json_api/document.dart';
+import 'package:json_api/src/server/errors/collection_not_found.dart';
+import 'package:json_api/src/server/errors/method_not_allowed.dart';
+import 'package:json_api/src/server/errors/relationship_not_found.dart';
+import 'package:json_api/src/server/errors/resource_not_found.dart';
+import 'package:json_api/src/server/errors/unacceptable.dart';
+import 'package:json_api/src/server/errors/unmatched_target.dart';
+import 'package:json_api/src/server/errors/unsupported_media_type.dart';
+import 'package:json_api/src/server/response.dart';
+
+/// The error converter maps server exceptions to JSON:API responses.
+/// It is designed to be used with the TryCatchHandler from the `json_api:http`
+/// package and provides some meaningful defaults out of the box.
+class ErrorConverter {
+  ErrorConverter({
+    this.onMethodNotAllowed,
+    this.onUnmatchedTarget,
+    this.onCollectionNotFound,
+    this.onResourceNotFound,
+    this.onRelationshipNotFound,
+    this.onError,
+  });
+
+  final Future<http.Response> Function(MethodNotAllowed)? onMethodNotAllowed;
+  final Future<http.Response> Function(UnmatchedTarget)? onUnmatchedTarget;
+  final Future<http.Response> Function(CollectionNotFound)?
+      onCollectionNotFound;
+  final Future<http.Response> Function(ResourceNotFound)? onResourceNotFound;
+  final Future<http.Response> Function(RelationshipNotFound)?
+      onRelationshipNotFound;
+  final Future<http.Response> Function(dynamic, StackTrace)? onError;
+
+  Future<http.Response> call(Object? error, StackTrace trace) async =>
+      switch (error) {
+        MethodNotAllowed() =>
+          await onMethodNotAllowed?.call(error) ?? Response.methodNotAllowed(),
+        UnmatchedTarget() =>
+          await onUnmatchedTarget?.call(error) ?? Response.badRequest(),
+        CollectionNotFound() => await onCollectionNotFound?.call(error) ??
+            Response.notFound(OutboundErrorDocument([
+              ErrorObject(
+                title: 'Collection Not Found',
+                detail: 'Type: ${error.type}',
+              )
+            ])),
+        ResourceNotFound() => await onResourceNotFound?.call(error) ??
+            Response.notFound(OutboundErrorDocument([
+              ErrorObject(
+                title: 'Resource Not Found',
+                detail: 'Type: ${error.type}, id: ${error.id}',
+              )
+            ])),
+        RelationshipNotFound() => await onRelationshipNotFound?.call(error) ??
+            Response.notFound(OutboundErrorDocument([
+              ErrorObject(
+                title: 'Relationship Not Found',
+                detail: 'Type: ${error.type}'
+                    ', id: ${error.id}'
+                    ', relationship: ${error.relationship}',
+              )
+            ])),
+        UnsupportedMediaType() => Response.unsupportedMediaType(),
+        Unacceptable() => Response.unacceptable(),
+        _ => await onError?.call(error, trace) ??
+            Response(500,
+                document: OutboundErrorDocument(
+                    [ErrorObject(title: 'Internal Server Error')]))
+      };
+}
diff --git a/lib/src/server/errors/collection_not_found.dart b/lib/src/server/errors/collection_not_found.dart
new file mode 100644
index 00000000..c6110e17
--- /dev/null
+++ b/lib/src/server/errors/collection_not_found.dart
@@ -0,0 +1,7 @@
+/// A collection is not found on the server.
+class CollectionNotFound implements Exception {
+  CollectionNotFound(this.type);
+
+  /// Collection type.
+  final String type;
+}
diff --git a/lib/src/server/errors/relationship_not_found.dart b/lib/src/server/errors/relationship_not_found.dart
new file mode 100644
index 00000000..9a2ae1d2
--- /dev/null
+++ b/lib/src/server/errors/relationship_not_found.dart
@@ -0,0 +1,8 @@
+/// A relationship is not found on the server.
+class RelationshipNotFound implements Exception {
+  RelationshipNotFound(this.type, this.id, this.relationship);
+
+  final String type;
+  final String id;
+  final String relationship;
+}
diff --git a/lib/src/server/errors/resource_not_found.dart b/lib/src/server/errors/resource_not_found.dart
new file mode 100644
index 00000000..6848d9f5
--- /dev/null
+++ b/lib/src/server/errors/resource_not_found.dart
@@ -0,0 +1,7 @@
+/// A resource is not found on the server.
+class ResourceNotFound implements Exception {
+  ResourceNotFound(this.type, this.id);
+
+  final String type;
+  final String id;
+}
diff --git a/lib/src/server/errors/unacceptable.dart b/lib/src/server/errors/unacceptable.dart
new file mode 100644
index 00000000..ac6d6855
--- /dev/null
+++ b/lib/src/server/errors/unacceptable.dart
@@ -0,0 +1 @@
+class Unacceptable implements Exception {}
diff --git a/lib/src/server/errors/unsupported_media_type.dart b/lib/src/server/errors/unsupported_media_type.dart
new file mode 100644
index 00000000..8fde40a4
--- /dev/null
+++ b/lib/src/server/errors/unsupported_media_type.dart
@@ -0,0 +1 @@
+class UnsupportedMediaType implements Exception {}
diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart
index 90d092ef..b15084c6 100644
--- a/lib/src/server/response.dart
+++ b/lib/src/server/response.dart
@@ -1,22 +1,24 @@
 import 'dart:convert';
 
+import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/document.dart';
 import 'package:json_api/http.dart';
-import 'package:json_api/src/nullable.dart';
+import 'package:json_api/src/media_type.dart';
 
 /// JSON:API response
-class Response<D extends OutboundDocument> extends HttpResponse {
-  Response(int statusCode, {this.document}) : super(statusCode) {
+class Response<D extends OutboundDocument> extends http.Response {
+  Response(int statusCode, {D? document})
+      : super(
+            statusCode,
+            document != null
+                ? http.Body(jsonEncode(document), utf8)
+                : http.Body.empty(),
+            http.Headers({})) {
     if (document != null) {
-      headers['Content-Type'] = mediaType;
+      headers['Content-Type'] = [mediaType];
     }
   }
 
-  final D? document;
-
-  @override
-  String get body => nullable(jsonEncode)(document) ?? '';
-
   static Response ok(OutboundDocument document) =>
       Response(StatusCode.ok, document: document);
 
@@ -24,7 +26,7 @@ class Response<D extends OutboundDocument> extends HttpResponse {
 
   static Response created(OutboundDocument document, String location) =>
       Response(StatusCode.created, document: document)
-        ..headers['location'] = location;
+        ..headers['location'] = [location];
 
   static Response notFound([OutboundErrorDocument? document]) =>
       Response(StatusCode.notFound, document: document);
@@ -34,4 +36,10 @@ class Response<D extends OutboundDocument> extends HttpResponse {
 
   static Response badRequest([OutboundErrorDocument? document]) =>
       Response(StatusCode.badRequest, document: document);
+
+  static Response unsupportedMediaType([OutboundErrorDocument? document]) =>
+      Response(StatusCode.unsupportedMediaType, document: document);
+
+  static Response unacceptable([OutboundErrorDocument? document]) =>
+      Response(StatusCode.unacceptable, document: document);
 }
diff --git a/lib/src/server/router.dart b/lib/src/server/router.dart
deleted file mode 100644
index aeb29b7a..00000000
--- a/lib/src/server/router.dart
+++ /dev/null
@@ -1,60 +0,0 @@
-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<HttpResponse> 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/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart
new file mode 100644
index 00000000..312d1ea5
--- /dev/null
+++ b/lib/src/server/try_catch_handler.dart
@@ -0,0 +1,26 @@
+import 'package:http_interop/http_interop.dart';
+
+/// An [Handler] wrapper which calls the [wrapped] handler and does
+/// the following:
+/// - when an instance of [Response] is returned or thrown by the
+///   [wrapped] handler, the response is returned
+/// - when another error is thrown by the [wrapped] handler and
+///   the [onError] callback is set, the error will be converted to a response
+/// - otherwise the error will be rethrown.
+class TryCatchHandler implements Handler {
+  TryCatchHandler(this.wrapped, {this.onError});
+
+  final Handler wrapped;
+  final Future<Response> Function(dynamic, StackTrace)? onError;
+
+  @override
+  Future<Response> handle(Request request) async {
+    try {
+      return await wrapped.handle(request);
+    } on Response catch (response) {
+      return response;
+    } catch (error, stacktrace) {
+      return await onError?.call(error, stacktrace) ?? (throw error);
+    }
+  }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 18d21161..a3a3cde7 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,24 +1,25 @@
 name: json_api
-version: 5.4.0
+version: 6.0.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.19.0 <3.0.0'
+  sdk: '>=3.0.0 <4.0.0'
 
 dependencies:
-  http: ^0.13.4
   http_parser: ^4.0.0
-  http_interop: ^0.1.0
-  http_interop_http: ^0.1.1
+  http_interop: ^0.8.0
 
 dev_dependencies:
-  lints: ^1.0.1
+  lints: ^2.1.1
   test: ^1.21.1
   stream_channel: ^2.1.0
   uuid: ^3.0.0
   coverage: ^1.3.0
   check_coverage: ^0.0.4
-  http_interop_io: ^0.1.0
+  http: ^1.1.0
+  http_interop_http: ^0.6.0
+  http_interop_io: ^0.6.0
+
 
 cider:
   link_template:
diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart
index a2a2177c..4d3e32b5 100644
--- a/test/contract/crud_test.dart
+++ b/test/contract/crud_test.dart
@@ -1,16 +1,16 @@
 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:test/test.dart';
 
-import '../../example/server/demo_handler.dart';
+import '../test_handler.dart';
 
 void main() {
   late RoutingClient client;
 
   setUp(() async {
-    client = RoutingClient(StandardUriDesign.pathOnly,
-        client: Client(handler: DemoHandler()));
+    client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler()));
   });
 
   group('CRUD', () {
@@ -27,35 +27,37 @@ void main() {
           .resource;
       post = (await client.createNew('posts',
               attributes: {'title': 'Hello world'},
-              one: {'author': Identifier.of(alice)},
+              one: {'author': alice.toIdentifier()},
               many: {'comments': []}))
           .resource;
       comment = (await client.createNew('comments',
               attributes: {'text': 'Hi Alice'},
-              one: {'author': Identifier.of(bob)}))
+              one: {'author': bob.toIdentifier()}))
           .resource;
       secretComment = (await client.createNew('comments',
               attributes: {'text': 'Secret comment'},
-              one: {'author': Identifier.of(bob)}))
+              one: {'author': bob.toIdentifier()}))
           .resource;
       await client
-          .addMany(post.type, post.id, 'comments', [Identifier.of(comment)]);
+          .addMany(post.type, post.id, 'comments', [comment.toIdentifier()]);
     });
 
     test('Fetch a complex resource', () async {
-      final response = await client.fetchCollection('posts',
-          include: ['author', 'comments', 'comments.author']);
+      final response = await client.fetchCollection('posts', query: [
+        Include(['author', 'comments', 'comments.author'])
+      ]);
 
-      expect(response.http.statusCode, 200);
+      expect(response.httpResponse.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 fetchedAuthor = response.included
+          .where(fetchedPost.one('author')!.identifier!.identifies)
+          .single;
+      expect(fetchedAuthor.attributes['name'], 'Alice');
 
       final fetchedComment =
           fetchedPost.many('comments')!.findIn(response.included).single;
@@ -93,37 +95,43 @@ void main() {
 
     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);
+        expect(r.relationship.identifier!.identifies(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);
+        expect(r.relationship.single.identifies(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) {
+      await client.fetchResource(post.type, post.id, query: [
+        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'],
+          post.type, post.id, 'author', bob.toIdentifier());
+      await client.fetchResource(post.type, post.id, query: [
+        Include(['author'])
+      ]).then((r) {
+        expect(
+            r.included
+                .where(r.resource.one('author')!.identifier!.identifies)
+                .single
+                .attributes['name'],
             'Bob');
       });
     });
 
     test('Delete from a to-many relationship', () async {
       await client.deleteFromMany(
-          post.type, post.id, 'comments', [Identifier.of(comment)]);
+          post.type, post.id, 'comments', [comment.toIdentifier()]);
       await client.fetchResource(post.type, post.id).then((r) {
         expect(r.resource.many('comments'), isEmpty);
       });
@@ -131,9 +139,10 @@ void main() {
 
     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) {
+          post.type, post.id, 'comments', [secretComment.toIdentifier()]);
+      await client.fetchResource(post.type, post.id, query: [
+        Include(['comments'])
+      ]).then((r) {
         expect(
             r.resource
                 .many('comments')!
@@ -166,9 +175,23 @@ void main() {
           await action();
           fail('Exception expected');
         } on RequestFailure catch (e) {
-          expect(e.http.statusCode, 404);
+          expect(e.httpResponse.statusCode, 404);
         }
       }
     });
   });
 }
+
+extension _ToManyExt on ToMany {
+  /// 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<Resource> findIn(Iterable<Resource> collection) => collection.where(
+      (resource) => any((identifier) => identifier.identifies(resource)));
+}
+
+extension _IdentifierExt on Identifier {
+  /// True if this identifier identifies the [resource].
+  bool identifies(Resource resource) =>
+      type == resource.type && id == resource.id;
+}
diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart
index d399bcbd..db60cff3 100644
--- a/test/contract/errors_test.dart
+++ b/test/contract/errors_test.dart
@@ -1,14 +1,14 @@
+import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/client.dart';
-import 'package:json_api/http.dart';
 import 'package:test/test.dart';
 
-import '../../example/server/demo_handler.dart';
+import '../test_handler.dart';
 
 void main() {
   late Client client;
 
   setUp(() async {
-    client = Client(handler: DemoHandler());
+    client = Client(TestHandler());
   });
 
   group('Errors', () {
@@ -22,13 +22,35 @@ void main() {
       ];
       for (final action in actions) {
         final response = await action();
-        expect(response.http.statusCode, 405);
+        expect(response.httpResponse.statusCode, 405);
       }
     });
     test('Bad request when target can not be matched', () async {
-      final r = await DemoHandler()
-          .handle(HttpRequest('get', Uri.parse('/a/long/prefix/')));
+      final r = await TestHandler().handle(http.Request(http.Method('get'),
+          Uri.parse('/a/long/prefix/'), http.Body.empty(), http.Headers({})));
       expect(r.statusCode, 400);
     });
+    test('Unsupported extension', () async {
+      final r = await TestHandler().handle(http.Request(
+          http.Method('get'),
+          Uri.parse('/posts/1'),
+          http.Body.empty(),
+          http.Headers({
+            'Content-Type': ['application/vnd.api+json; ext=foobar'],
+            'Accept': ['application/vnd.api+json']
+          })));
+      expect(r.statusCode, 415);
+    });
+    test('Unacceptable', () async {
+      final r = await TestHandler().handle(http.Request(
+          http.Method('get'),
+          Uri.parse('/posts/1'),
+          http.Body.empty(),
+          http.Headers({
+            'Content-Type': ['application/vnd.api+json'],
+            'Accept': ['application/vnd.api+json; ext=foobar']
+          })));
+      expect(r.statusCode, 406);
+    });
   });
 }
diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart
index e1b09902..a48d2b9e 100644
--- a/test/contract/resource_creation_test.dart
+++ b/test/contract/resource_creation_test.dart
@@ -1,35 +1,50 @@
 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';
+import '../test_handler.dart';
 
 void main() {
   late RoutingClient client;
 
   setUp(() async {
-    client = RoutingClient(StandardUriDesign.pathOnly,
-        client: Client(handler: DemoHandler()));
+    client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler()));
   });
 
   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.httpResponse.statusCode, 201);
+        expect(r.httpResponse.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 server using local id', () async {
+      await client.createNew('posts',
+          lid: 'lid',
+          attributes: {'title': 'Hello world'},
+          one: {'self': LocalIdentifier('posts', 'lid')}).then((r) {
+        expect(r.httpResponse.statusCode, 201);
+        expect(r.httpResponse.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.httpResponse.statusCode, 204);
         expect(r.resource, isNull);
-        expect(r.http.headers['location'], isNull);
+        expect(r.httpResponse.headers['location'], isNull);
       });
     });
   });
diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart
index a33c45e5..21de2efd 100644
--- a/test/e2e/browser_test.dart
+++ b/test/e2e/browser_test.dart
@@ -1,3 +1,4 @@
+import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 import 'package:test/test.dart';
@@ -6,15 +7,16 @@ import 'e2e_test_set.dart';
 
 void main() {
   late RoutingClient client;
+  group('On Browser', () {
+    setUpAll(() async {
+      final channel = spawnHybridUri('hybrid_server.dart');
+      final serverUrl = await channel.stream.first;
 
-  setUp(() async {
-    final channel = spawnHybridUri('hybrid_server.dart');
-    final serverUrl = await channel.stream.first;
+      client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())),
+          Client(OneOffHandler()));
+    });
 
-    client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())));
-  });
-
-  test('On Browser', () async {
-    await e2eTests(client);
+    testLocationIsSet(() => client);
+    testAllHttpMethods(() => client);
   }, testOn: 'browser');
 }
diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart
index 04f63bef..4d7a3fd3 100644
--- a/test/e2e/e2e_test_set.dart
+++ b/test/e2e/e2e_test_set.dart
@@ -1,34 +1,36 @@
 import 'package:json_api/client.dart';
 import 'package:test/test.dart';
 
-Future<void> e2eTests(RoutingClient client) async {
-  await _testAllHttpMethods(client);
-  await _testLocationIsSet(client);
-}
-
-Future<void> _testAllHttpMethods(RoutingClient client) async {
+Future<void> testAllHttpMethods(RoutingClient Function() 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');
+  test('POST', () async {
+    await client().create('posts', id, attributes: {'title': 'Hello world'});
+  });
+  test('GET', () async {
+    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');
+  test('PATCH', () async {
+    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);
+  test('DELETE', () async {
+    await client().deleteResource('posts', id);
+    await client().fetchCollection('posts').then((r) {
+      expect(r.collection, isEmpty);
+    });
   });
 }
 
-Future<void> _testLocationIsSet(RoutingClient client) async {
-  await client
-      .createNew('posts', attributes: {'title': 'Location test'}).then((r) {
-    expect(r.http.headers['Location'], isNotEmpty);
+void testLocationIsSet(RoutingClient Function() client) {
+  test('Location is set', () async {
+    final r = await client()
+        .createNew('posts', attributes: {'title': 'Location test'});
+    expect(r.httpResponse.headers['Location'], isNotEmpty);
+    await client().deleteResource('posts', r.resource.id);
   });
 }
diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart
index 578430f8..ff363936 100644
--- a/test/e2e/hybrid_server.dart
+++ b/test/e2e/hybrid_server.dart
@@ -1,12 +1,12 @@
 import 'package:stream_channel/stream_channel.dart';
 
-import '../../example/server/demo_handler.dart';
 import '../../example/server/json_api_server.dart';
+import '../test_handler.dart';
 
 void hybridMain(StreamChannel channel, Object message) async {
   final host = 'localhost';
   final port = 8000;
-  final server = JsonApiServer(DemoHandler(), host: host, port: port);
+  final server = JsonApiServer(TestHandler(), 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
index 1c3d98f7..d644f359 100644
--- a/test/e2e/vm_test.dart
+++ b/test/e2e/vm_test.dart
@@ -1,27 +1,31 @@
+import 'package:http_interop_http/http_interop_http.dart';
 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 '../test_handler.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)));
-  });
+  group('On VM', () {
+    setUpAll(() async {
+      server = JsonApiServer(TestHandler(), port: 8001);
+      await server.start();
+      client = RoutingClient(
+          StandardUriDesign(
+              Uri(scheme: 'http', host: server.host, port: server.port)),
+          Client(OneOffHandler()));
+    });
 
-  tearDown(() async {
-    await server.stop();
-  });
+    tearDownAll(() async {
+      await server.stop();
+    });
 
-  test('On VM', () async {
-    await e2eTests(client);
+    testLocationIsSet(() => client);
+    testAllHttpMethods(() => client);
   }, testOn: 'vm');
 }
diff --git a/test/test_handler.dart b/test/test_handler.dart
new file mode 100644
index 00000000..d26214ea
--- /dev/null
+++ b/test/test_handler.dart
@@ -0,0 +1,31 @@
+import 'package:http_interop/http_interop.dart' as http;
+import 'package:json_api/http.dart';
+import 'package:json_api/routing.dart';
+import 'package:json_api/server.dart';
+
+import '../example/server/in_memory_repo.dart';
+import '../example/server/repository_controller.dart';
+
+class TestHandler extends LoggingHandler {
+  TestHandler(
+      {Iterable<String> types = const ['users', 'posts', 'comments'],
+      Function(http.Request request)? onRequest,
+      Function(http.Response response)? onResponse,
+      Future<http.Response> Function(dynamic, StackTrace)? onError})
+      : super(
+            TryCatchHandler(
+                ControllerRouter(RepositoryController(InMemoryRepo(types), _id),
+                    StandardUriDesign.matchTarget),
+                onError: ErrorConverter(
+                    onError: onError ??
+                        (err, trace) {
+                          print(trace);
+                          throw err;
+                        })),
+            onRequest: onRequest,
+            onResponse: onResponse);
+}
+
+int _counter = 0;
+
+String _id() => (_counter++).toString();
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index c01b5180..6fe5120b 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -1,7 +1,9 @@
 import 'dart:convert';
 
+import 'package:http_interop/extensions.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:test/test.dart';
 
@@ -10,65 +12,65 @@ import 'response.dart' as mock;
 
 void main() {
   final http = MockHandler();
-  final client =
-      RoutingClient(StandardUriDesign.pathOnly, client: Client(handler: http));
+  final client = RoutingClient(StandardUriDesign.pathOnly, Client(http));
 
   group('Failure', () {
     test('RequestFailure', () async {
-      http.response = mock.error422;
+      http.response = mock.error422();
       try {
         await client.fetchCollection('articles');
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.http.statusCode, 422);
+        expect(e.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
         expect(e.errors.first.title, 'Invalid Attribute');
       }
     });
     test('ServerError', () async {
-      http.response = mock.error500;
+      http.response = mock.error500();
       try {
         await client.fetchCollection('articles');
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.http.statusCode, 500);
+        expect(e.httpResponse.statusCode, 500);
       }
     });
   });
 
   group('Fetch Collection', () {
     test('Min', () async {
-      http.response = mock.collectionMin;
+      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.method.value, 'get');
       expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json']
+      });
     });
   });
 
   test('Full', () async {
-    http.response = mock.collectionFull;
+    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'
+      'foo': ['bar']
+    }, query: [
+      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.method.value, 'get');
     expect(http.request.uri.path, '/articles');
     expect(http.request.uri.queryParameters, {
       'include': 'author',
@@ -77,44 +79,47 @@ void main() {
       'page[limit]': '10',
       'foo': 'bar'
     });
-    expect(http.request.headers,
-        {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+    expect(http.request.headers, {
+      'Accept': ['application/vnd.api+json'],
+      'foo': ['bar']
+    });
 
     expect(response.meta, {'hello': 'world'});
   });
 
   group('Fetch Related Collection', () {
     test('Min', () async {
-      http.response = mock.collectionFull;
+      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.method.equals('get'), true);
       expect(http.request.uri.path, '/people/1/articles');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json']
+      });
     });
 
     test('Full', () async {
-      http.response = mock.collectionFull;
+      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'
+        'foo': ['bar']
+      }, query: [
+        Query({
+          'foo': ['bar']
+        }),
+        Include(['author']),
+        Page({'limit': '10'}),
+        Fields({
+          'author': ['name']
+        }),
+        Sort(['title', '-date'])
       ]);
 
       expect(response.collection.length, 1);
       expect(response.included.length, 3);
-      expect(http.request.method, 'get');
+      expect(http.request.method.equals('get'), true);
       expect(http.request.uri.path, '/people/1/articles');
       expect(http.request.uri.queryParameters, {
         'include': 'author',
@@ -123,8 +128,10 @@ void main() {
         'page[limit]': '10',
         'foo': 'bar'
       });
-      expect(http.request.headers,
-          {'Accept': 'application/vnd.api+json', 'foo': 'bar'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json'],
+        'foo': ['bar']
+      });
 
       expect(response.meta, {'hello': 'world'});
     });
@@ -132,33 +139,39 @@ void main() {
 
   group('Fetch Primary Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.fetchResource('articles', '1');
       expect(response.resource.type, 'articles');
-      expect(http.request.method, 'get');
+      expect(http.request.method.equals('get'), true);
       expect(http.request.uri.toString(), '/articles/1');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json']
+      });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.fetchResource('articles', '1', headers: {
-        'foo': 'bar'
-      }, query: {
-        'foo': 'bar'
-      }, include: [
-        'author'
-      ], fields: {
-        'author': ['name']
-      });
+        'foo': ['bar']
+      }, query: [
+        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.method.equals('get'), true);
       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'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json'],
+        'foo': ['bar']
+      });
 
       expect(response.meta, {'hello': 'world'});
     });
@@ -166,96 +179,114 @@ void main() {
 
   group('Fetch Related Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource;
+      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.method.equals('get'), true);
       expect(http.request.uri.toString(), '/articles/1/author');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json']
+      });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client
           .fetchRelatedResource('articles', '1', 'author', headers: {
-        'foo': 'bar'
-      }, query: {
-        'foo': 'bar'
-      }, include: [
-        'author'
-      ], fields: {
-        'author': ['name']
-      });
+        'foo': ['bar']
+      }, query: [
+        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.method.equals('get'), true);
       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'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json'],
+        'foo': ['bar']
+      });
 
       expect(response.meta, {'hello': 'world'});
     });
 
     test('Missing resource', () async {
-      http.response = mock.relatedResourceNull;
+      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.method.equals('get'), true);
       expect(http.request.uri.toString(), '/articles/1/author');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json']
+      });
     });
   });
 
   group('Fetch Relationship', () {
     test('Min', () async {
-      http.response = mock.one;
+      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.method.equals('get'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {'Accept': 'application/vnd.api+json'});
+      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'});
+      http.response = mock.one();
+      final response =
+          await client.fetchToOne('articles', '1', 'author', headers: {
+        'foo': ['bar']
+      }, query: [
+        Query({
+          'foo': ['bar']
+        })
+      ]);
       expect(response.included.length, 3);
-      expect(http.request.method, 'get');
+      expect(http.request.method.equals('get'), true);
       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'});
+      expect(http.request.headers, {
+        'Accept': ['application/vnd.api+json'],
+        'foo': ['bar']
+      });
     });
   });
 
   group('Create New Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource;
+      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.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'articles'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.createNew('articles', attributes: {
         'cool': true
       }, one: {
@@ -267,20 +298,20 @@ void main() {
       }, documentMeta: {
         'hello': 'world'
       }, headers: {
-        'foo': 'bar'
+        '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.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json',
-        'foo': 'bar'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'attributes': {'cool': true},
@@ -310,37 +341,37 @@ void main() {
 
   group('Create Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.create('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, 'post');
+      expect(http.request.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Min with 204 No Content', () async {
-      http.response = mock.noContent;
+      http.response = mock.noContent();
       final response = await client.create('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method, 'post');
+      expect(http.request.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.create('articles', '1', attributes: {
         'cool': true
       }, one: {
@@ -352,17 +383,17 @@ void main() {
       }, documentMeta: {
         'hello': 'world'
       }, headers: {
-        'foo': 'bar'
+        'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, 'post');
+      expect(http.request.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json',
-        'foo': 'bar'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'id': '1',
@@ -393,37 +424,37 @@ void main() {
 
   group('Update Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response = await client.updateResource('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Min with 204 No Content', () async {
-      http.response = mock.noContent;
+      http.response = mock.noContent();
       final response = await client.updateResource('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource;
+      http.response = mock.primaryResource();
       final response =
           await client.updateResource('articles', '1', attributes: {
         'cool': true
@@ -436,17 +467,17 @@ void main() {
       }, documentMeta: {
         'hello': 'world'
       }, headers: {
-        'foo': 'bar'
+        'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json',
-        'foo': 'bar'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'id': '1',
@@ -477,54 +508,59 @@ void main() {
 
   group('Replace One', () {
     test('Min', () async {
-      http.response = mock.one;
+      http.response = mock.one();
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'));
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'people', 'id': '42'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.one;
+      http.response = mock.one();
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'),
-          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
+          meta: {
+            'hello': 'world'
+          },
+          headers: {
+            'foo': ['bar']
+          });
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       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'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': {'type': 'people', 'id': '42'},
         'meta': {'hello': 'world'}
       });
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422;
+      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.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.many;
+      http.response = mock.many();
       expect(
           () => client.replaceToOne(
               'articles', '1', 'author', Identifier('people', '42')),
@@ -534,48 +570,50 @@ void main() {
 
   group('Delete One', () {
     test('Min', () async {
-      http.response = mock.oneEmpty;
+      http.response = mock.oneEmpty();
       final response = await client.deleteToOne('articles', '1', 'author');
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {'data': null});
+      expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null});
     });
 
     test('Full', () async {
-      http.response = mock.oneEmpty;
-      final response = await client
-          .deleteToOne('articles', '1', 'author', headers: {'foo': 'bar'});
+      http.response = mock.oneEmpty();
+      final response =
+          await client.deleteToOne('articles', '1', 'author', headers: {
+        'foo': ['bar']
+      });
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       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'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {'data': null});
+      expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null});
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422;
+      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.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.many;
+      http.response = mock.many();
       expect(() => client.deleteToOne('articles', '1', 'author'),
           throwsFormatException);
     });
@@ -583,17 +621,17 @@ void main() {
 
   group('Delete Many', () {
     test('Min', () async {
-      http.response = mock.many;
+      http.response = mock.many();
       final response = await client
           .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'delete');
+      expect(http.request.method.equals('delete'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -601,19 +639,23 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many;
-      final response = await client.deleteFromMany(
-          'articles', '1', 'tags', [Identifier('tags', '1')],
-          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
+      http.response = mock.many();
+      final response = await client.deleteFromMany('articles', '1', 'tags', [
+        Identifier('tags', '1')
+      ], meta: {
+        'hello': 'world'
+      }, headers: {
+        'foo': ['bar']
+      });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'delete');
+      expect(http.request.method.equals('delete'), true);
       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'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -622,19 +664,19 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422;
+      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.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one;
+      http.response = mock.one();
       expect(
           () => client.deleteFromMany(
               'articles', '1', 'tags', [Identifier('tags', '1')]),
@@ -644,17 +686,17 @@ void main() {
 
   group('Replace Many', () {
     test('Min', () async {
-      http.response = mock.many;
+      http.response = mock.many();
       final response = await client
           .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -662,19 +704,23 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many;
-      final response = await client.replaceToMany(
-          'articles', '1', 'tags', [Identifier('tags', '1')],
-          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
+      http.response = mock.many();
+      final response = await client.replaceToMany('articles', '1', 'tags', [
+        Identifier('tags', '1')
+      ], meta: {
+        'hello': 'world'
+      }, headers: {
+        'foo': ['bar']
+      });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'patch');
+      expect(http.request.method.equals('patch'), true);
       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'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -683,19 +729,19 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422;
+      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.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one;
+      http.response = mock.one();
       expect(
           () => client.replaceToMany(
               'articles', '1', 'tags', [Identifier('tags', '1')]),
@@ -705,17 +751,17 @@ void main() {
 
   group('Add Many', () {
     test('Min', () async {
-      http.response = mock.many;
+      http.response = mock.many();
       final response = await client
           .addMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'post');
+      expect(http.request.method.equals('post'), true);
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
-        'Accept': 'application/vnd.api+json',
-        'Content-Type': 'application/vnd.api+json'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -723,19 +769,23 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many;
-      final response = await client.addMany(
-          'articles', '1', 'tags', [Identifier('tags', '1')],
-          meta: {'hello': 'world'}, headers: {'foo': 'bar'});
+      http.response = mock.many();
+      final response = await client.addMany('articles', '1', 'tags', [
+        Identifier('tags', '1')
+      ], meta: {
+        'hello': 'world'
+      }, headers: {
+        'foo': ['bar']
+      });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, 'post');
+      expect(http.request.method.equals('post'), true);
       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'
+        'Accept': ['application/vnd.api+json'],
+        'Content-Type': ['application/vnd.api+json'],
+        'foo': ['bar']
       });
-      expect(jsonDecode(http.request.body), {
+      expect(jsonDecode(await http.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -744,20 +794,20 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422;
+      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.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
         expect(e.toString(), contains('422'));
       }
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one;
+      http.response = mock.one();
       expect(
           () => client
               .addMany('articles', '1', 'tags', [Identifier('tags', '1')]),
diff --git a/test/unit/client/encoding_test.dart b/test/unit/client/encoding_test.dart
deleted file mode 100644
index 67605d1f..00000000
--- a/test/unit/client/encoding_test.dart
+++ /dev/null
@@ -1,53 +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);
-
-    buildResponse(
-      List<int> bytesBody,
-      Encoding encoding,
-    ) async {
-      final dartHttp = PersistentHandler(
-        MockClient(
-          (request) async {
-            return http.Response.bytes(bytesBody, 200);
-          },
-        ),
-        // ignore: deprecated_member_use_from_same_package
-        defaultEncoding: encoding,
-      );
-
-      return dartHttp.handle(HttpRequest('get', 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/message_converter_test.dart b/test/unit/client/message_converter_test.dart
deleted file mode 100644
index be0b862d..00000000
--- a/test/unit/client/message_converter_test.dart
+++ /dev/null
@@ -1,24 +0,0 @@
-import 'package:json_api/client.dart';
-import 'package:json_api/http.dart';
-import 'package:test/expect.dart';
-import 'package:test/scaffolding.dart';
-
-void main() {
-  final converter = MessageConverter();
-  final uri = Uri.parse('https://example.com');
-
-  test('No headers are set for GET requests', () {
-    final r = converter.request(HttpRequest('GET', uri));
-    expect(r.headers, isEmpty);
-  });
-
-  test('No headers are set for OPTIONS requests', () {
-    final r = converter.request(HttpRequest('OPTIONS', uri));
-    expect(r.headers, isEmpty);
-  });
-
-  test('No headers are set for DELETE requests', () {
-    final r = converter.request(HttpRequest('DELETE', uri));
-    expect(r.headers, isEmpty);
-  });
-}
diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart
index 9dfe2ff9..d25b8a75 100644
--- a/test/unit/client/mock_handler.dart
+++ b/test/unit/client/mock_handler.dart
@@ -1,11 +1,11 @@
-import 'package:json_api/http.dart';
+import 'package:http_interop/http_interop.dart';
 
-class MockHandler implements HttpHandler {
-  late HttpRequest request;
-  late HttpResponse response;
+class MockHandler implements Handler {
+  late Request request;
+  late Response response;
 
   @override
-  Future<HttpResponse> handle(HttpRequest request) async {
+  Future<Response> handle(Request request) async {
     this.request = request;
     return response;
   }
diff --git a/test/unit/http/payload_codec_test.dart b/test/unit/client/payload_codec_test.dart
similarity index 77%
rename from test/unit/http/payload_codec_test.dart
rename to test/unit/client/payload_codec_test.dart
index ef1057ad..a4dde408 100644
--- a/test/unit/http/payload_codec_test.dart
+++ b/test/unit/client/payload_codec_test.dart
@@ -1,4 +1,4 @@
-import 'package:json_api/http.dart';
+import 'package:json_api/src/client/payload_codec.dart';
 import 'package:test/test.dart';
 
 void main() {
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index a89e5f0b..ce1c81eb 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -1,17 +1,23 @@
-import 'dart:convert';
-
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/http.dart';
+import 'package:json_api/src/media_type.dart';
+
+final headers = Headers({
+  'Content-Type': [mediaType]
+});
 
-final collectionMin = HttpResponse(200,
-    body: jsonEncode({
+collectionMin() => Response(
+    200,
+    Json({
       'data': [
         {'type': 'articles', 'id': '1'}
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final collectionFull = HttpResponse(200,
-    body: jsonEncode({
+collectionFull() => Response(
+    200,
+    Json({
       'links': {
         'self': 'http://example.com/articles',
         'next': 'http://example.com/articles?page[offset]=2',
@@ -79,11 +85,12 @@ final collectionFull = HttpResponse(200,
           'links': {'self': 'http://example.com/comments/12'}
         }
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final primaryResource = HttpResponse(200,
-    body: jsonEncode({
+primaryResource() => Response(
+    200,
+    Json({
       'links': {'self': 'http://example.com/articles/1'},
       'meta': {'hello': 'world'},
       'data': {
@@ -130,17 +137,21 @@ final primaryResource = HttpResponse(200,
           'links': {'self': 'http://example.com/comments/12'}
         }
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
-final relatedResourceNull = HttpResponse(200,
-    body: jsonEncode({
+    }),
+    headers);
+
+relatedResourceNull() => Response(
+    200,
+    Json({
       'links': {'self': 'http://example.com/articles/1/author'},
       'meta': {'hello': 'world'},
       'data': null
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
-final one = HttpResponse(200,
-    body: jsonEncode({
+    }),
+    headers);
+
+one() => Response(
+    200,
+    Json({
       'links': {
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
@@ -181,11 +192,12 @@ final one = HttpResponse(200,
           'links': {'self': 'http://example.com/comments/12'}
         }
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final oneEmpty = HttpResponse(200,
-    body: jsonEncode({
+oneEmpty() => Response(
+    200,
+    Json({
       'links': {
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
@@ -226,11 +238,12 @@ final oneEmpty = HttpResponse(200,
           'links': {'self': 'http://example.com/comments/12'}
         }
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final many = HttpResponse(200,
-    body: jsonEncode({
+many() => Response(
+    200,
+    Json({
       'links': {
         'self': '/articles/1/relationships/tags',
         'related': '/articles/1/tags'
@@ -239,13 +252,14 @@ final many = HttpResponse(200,
       'data': [
         {'type': 'tags', 'id': '12'}
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final noContent = HttpResponse(204);
+noContent() => Response(204, Body.empty(), Headers({}));
 
-final error422 = HttpResponse(422,
-    body: jsonEncode({
+error422() => Response(
+    422,
+    Json({
       'meta': {'hello': 'world'},
       'errors': [
         {
@@ -255,7 +269,7 @@ final error422 = HttpResponse(422,
           'detail': 'First name must contain at least three characters.'
         }
       ]
-    }))
-  ..headers.addAll({'Content-Type': mediaType});
+    }),
+    headers);
 
-final error500 = HttpResponse(500);
+error500() => Response(500, Body.empty(), Headers({}));
diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart
index f3d826aa..1e0cd9da 100644
--- a/test/unit/document/new_resource_test.dart
+++ b/test/unit/document/new_resource_test.dart
@@ -10,17 +10,24 @@ void main() {
           jsonEncode({'type': 'test_type'}));
 
       expect(
-          jsonEncode(NewResource('test_type')
+          jsonEncode(NewResource('test_type', id: 'test_id', lid: 'test_lid')
             ..meta['foo'] = [42]
             ..attributes['color'] = 'green'
             ..relationships['one'] =
-                (ToOne(Identifier('rel', '1')..meta['rel'] = 1)
+                (NewToOne(Identifier('rel', '1')..meta['rel'] = 1)
                   ..meta['one'] = 1)
-            ..relationships['many'] =
-                (ToMany([Identifier('rel', '1')..meta['rel'] = 1])
-                  ..meta['many'] = 1)),
+            ..relationships['self'] = (NewToOne(
+                LocalIdentifier('test_type', 'test_lid')..meta['rel'] = 1)
+              ..meta['one'] = 1)
+            ..relationships['many'] = (NewToMany([
+              Identifier('rel', '1')..meta['rel'] = 1,
+              LocalIdentifier('test_type', 'test_lid')..meta['rel'] = 1,
+            ])
+              ..meta['many'] = 1)),
           jsonEncode({
             'type': 'test_type',
+            'id': 'test_id',
+            'lid': 'test_lid',
             'attributes': {'color': 'green'},
             'relationships': {
               'one': {
@@ -31,6 +38,14 @@ void main() {
                 },
                 'meta': {'one': 1}
               },
+              'self': {
+                'data': {
+                  'type': 'test_type',
+                  'lid': 'test_lid',
+                  'meta': {'rel': 1}
+                },
+                'meta': {'one': 1}
+              },
               'many': {
                 'data': [
                   {
@@ -38,6 +53,11 @@ void main() {
                     'id': '1',
                     'meta': {'rel': 1}
                   },
+                  {
+                    'type': 'test_type',
+                    'lid': 'test_lid',
+                    'meta': {'rel': 1}
+                  },
                 ],
                 'meta': {'many': 1}
               }
diff --git a/test/unit/document/outbound_document_test.dart b/test/unit/document/outbound_document_test.dart
index 5631f3a2..7886a665 100644
--- a/test/unit/document/outbound_document_test.dart
+++ b/test/unit/document/outbound_document_test.dart
@@ -90,7 +90,7 @@ void main() {
       });
       test('full', () {
         expect(
-            toObject(OutboundDataDocument.one(ToOne(Identifier.of(book))
+            toObject(OutboundDataDocument.one(ToOne(book.toIdentifier())
               ..meta['foo'] = 42
               ..links['self'] = Link(Uri.parse('/books/1')))
               ..included.add(author)),
@@ -111,7 +111,7 @@ void main() {
       });
       test('full', () {
         expect(
-            toObject(OutboundDataDocument.many(ToMany([Identifier.of(book)])
+            toObject(OutboundDataDocument.many(ToMany([book.toIdentifier()])
               ..meta['foo'] = 42
               ..links['self'] = Link(Uri.parse('/books/1')))
               ..included.add(author)),
diff --git a/test/unit/http/request_test.dart b/test/unit/http/request_test.dart
deleted file mode 100644
index 96eaaffb..00000000
--- a/test/unit/http/request_test.dart
+++ /dev/null
@@ -1,30 +0,0 @@
-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 2708c983..965c6313 100644
--- a/test/unit/query/fields_test.dart
+++ b/test/unit/query/fields_test.dart
@@ -52,8 +52,11 @@ void main() {
           Fields({
             'articles': ['title', 'body'],
             'people': ['name']
-          }).asQueryParameters,
-          {'fields[articles]': 'title,body', 'fields[people]': 'name'});
+          }).toQuery(),
+          {
+            'fields[articles]': ['title,body'],
+            'fields[people]': ['name']
+          });
     });
   });
 }
diff --git a/test/unit/query/filter_test.dart b/test/unit/query/filter_test.dart
deleted file mode 100644
index 5c8f0286..00000000
--- a/test/unit/query/filter_test.dart
+++ /dev/null
@@ -1,37 +0,0 @@
-import 'package:json_api/query.dart';
-import 'package:test/test.dart';
-
-void main() {
-  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('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);
-    });
-
-    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 ef91bcc9..7f4eef5b 100644
--- a/test/unit/query/include_test.dart
+++ b/test/unit/query/include_test.dart
@@ -25,7 +25,8 @@ void main() {
   });
 
   test('Can convert to query parameters', () {
-    expect(Include(['author', 'comments.author']).asQueryParameters,
-        {'include': 'author,comments.author'});
+    expect(Include(['author', 'comments.author']).toQuery(), {
+      'include': ['author,comments.author']
+    });
   });
 }
diff --git a/test/unit/query/page_test.dart b/test/unit/query/page_test.dart
index 5b878e82..facc6d78 100644
--- a/test/unit/query/page_test.dart
+++ b/test/unit/query/page_test.dart
@@ -30,8 +30,10 @@ void main() {
     });
 
     test('can convert to query parameters', () {
-      expect(Page({'limit': '10', 'offset': '20'}).asQueryParameters,
-          {'page[limit]': '10', 'page[offset]': '20'});
+      expect(Page({'limit': '10', 'offset': '20'}).toQuery(), {
+        'page[limit]': ['10'],
+        'page[offset]': ['20']
+      });
     });
   });
 }
diff --git a/test/unit/query/sort_test.dart b/test/unit/query/sort_test.dart
index effa6e72..25910136 100644
--- a/test/unit/query/sort_test.dart
+++ b/test/unit/query/sort_test.dart
@@ -26,7 +26,8 @@ void main() {
   });
 
   test('Can convert to query parameters', () {
-    expect(Sort(['-created', 'title']).asQueryParameters,
-        {'sort': '-created,title'});
+    expect(Sort(['-created', 'title']).toQuery(), {
+      'sort': ['-created,title']
+    });
   });
 }
diff --git a/test/unit/server/error_converter_test.dart b/test/unit/server/error_converter_test.dart
new file mode 100644
index 00000000..b2fbc4bc
--- /dev/null
+++ b/test/unit/server/error_converter_test.dart
@@ -0,0 +1,14 @@
+import 'dart:convert';
+
+import 'package:http_interop/extensions.dart';
+import 'package:json_api/server.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('500', () async {
+    final r = await ErrorConverter().call('Foo', StackTrace.current);
+    expect(r.statusCode, equals(500));
+    expect(await r.body.decode(utf8),
+        equals('{"errors":[{"title":"Internal Server Error"}]}'));
+  });
+}

From e5d1aedc888cc9aa943fa1d2076df11779c2b785 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Mon, 11 Sep 2023 19:17:46 -0700
Subject: [PATCH 20/25] Release 6.0.1 (#136)

---
 CHANGELOG.md      | 5 +++++
 lib/document.dart | 1 +
 pubspec.yaml      | 2 +-
 3 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f882fd3a..0707da20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [6.0.1] - 2023-09-11
+### Fixed
+- `NewRelationship` was not exported
+
 ## [6.0.0] - 2023-09-07
 ### Added
 - Partial support for JSON:API v1.1
@@ -238,6 +242,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[6.0.1]: https://github.com/f3ath/json-api-dart/compare/6.0.0...6.0.1
 [6.0.0]: https://github.com/f3ath/json-api-dart/compare/5.4.0...6.0.0
 [5.4.0]: https://github.com/f3ath/json-api-dart/compare/5.3.0...5.4.0
 [5.3.0]: https://github.com/f3ath/json-api-dart/compare/5.2.0...5.3.0
diff --git a/lib/document.dart b/lib/document.dart
index 2618a546..04efafb7 100644
--- a/lib/document.dart
+++ b/lib/document.dart
@@ -5,6 +5,7 @@ export 'package:json_api/src/document/error_object.dart';
 export 'package:json_api/src/document/inbound_document.dart';
 export 'package:json_api/src/document/link.dart';
 export 'package:json_api/src/document/new_identifier.dart';
+export 'package:json_api/src/document/new_relationship.dart';
 export 'package:json_api/src/document/new_resource.dart';
 export 'package:json_api/src/document/new_to_many.dart';
 export 'package:json_api/src/document/new_to_one.dart';
diff --git a/pubspec.yaml b/pubspec.yaml
index a3a3cde7..f93f095f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 6.0.0
+version: 6.0.1
 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:

From faae2e5e419910634b7d3fcfc82aa6e9543e4134 Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Sun, 12 Nov 2023 20:25:37 -0800
Subject: [PATCH 21/25] v7 (#138)

---
 .github/workflows/dart.yml                    |  2 +-
 .github/workflows/publish.yml                 | 14 ++++
 CHANGELOG.md                                  |  5 ++
 example/server.dart                           | 10 +--
 example/server/cors_handler.dart              |  6 +-
 lib/http.dart                                 |  4 +-
 lib/src/client/client.dart                    |  8 +--
 lib/src/client/request.dart                   |  2 +-
 lib/src/document/inbound_document.dart        | 44 +++++-------
 lib/src/document/new_relationship.dart        |  2 -
 lib/src/document/new_resource.dart            | 21 +++---
 lib/src/routing/standard_uri_design.dart      | 24 +++----
 lib/src/server/controller_router.dart         | 68 +++++++------------
 lib/src/server/response.dart                  |  8 +--
 pubspec.yaml                                  | 14 ++--
 test/contract/errors_test.dart                | 16 ++---
 test/test_handler.dart                        | 11 +--
 test/unit/client/client_test.dart             | 58 ++++++++--------
 test/unit/client/response.dart                |  6 +-
 test/unit/document/inbound_document_test.dart | 14 ++++
 test/unit/document/new_relationship_test.dart | 13 ++++
 test/unit/document/new_resource_test.dart     | 24 +++++++
 test/unit/document/new_to_one_test.dart       | 15 ++++
 test/unit/document/payload.dart               | 24 +++++++
 24 files changed, 238 insertions(+), 175 deletions(-)
 create mode 100644 .github/workflows/publish.yml
 create mode 100644 test/unit/document/new_relationship_test.dart
 create mode 100644 test/unit/document/new_to_one_test.dart

diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index f8c2d358..0297fc11 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -25,4 +25,4 @@ jobs:
       - name: Tests
         run: dart test --coverage=.coverage -j1
       - name: Coverage
-        run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage 98
+        run: dart run coverage:format_coverage -l -c -i .coverage --report-on=lib | dart run check_coverage:check_coverage
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..d79d8c8d
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,14 @@
+name: Publish to pub.dev
+
+on:
+  push:
+    tags:
+      - '[0-9]+.[0-9]+.[0-9]+*'
+
+jobs:
+  publish:
+    permissions:
+      id-token: write # Required for authentication using OIDC
+    uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
+    # with:
+    #   working-directory: path/to/package/within/repository
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0707da20..679d663c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [7.0.0] - 2023-11-12
+### Changed
+- Migrated to `http_interop` v1.
+
 ## [6.0.1] - 2023-09-11
 ### Fixed
 - `NewRelationship` was not exported
@@ -242,6 +246,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[7.0.0]: https://github.com/f3ath/json-api-dart/compare/6.0.1...7.0.0
 [6.0.1]: https://github.com/f3ath/json-api-dart/compare/6.0.0...6.0.1
 [6.0.0]: https://github.com/f3ath/json-api-dart/compare/5.4.0...6.0.0
 [5.4.0]: https://github.com/f3ath/json-api-dart/compare/5.3.0...5.4.0
diff --git a/example/server.dart b/example/server.dart
index 57dccb93..bea19e00 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -22,11 +22,11 @@ Future<void> main() async {
       ControllerRouter(controller, StandardUriDesign.matchTarget);
   handler = TryCatchHandler(handler,
       onError: ErrorConverter(onError: (e, stack) async {
-    stderr.writeln(e);
-    return Response(500,
-        document: OutboundErrorDocument(
-            [ErrorObject(title: 'Internal Server Error')]));
-  }));
+        stderr.writeln(e);
+        return Response(500,
+            document: OutboundErrorDocument(
+                [ErrorObject(title: 'Internal Server Error')]));
+      }).call);
   handler = LoggingHandler(handler,
       onRequest: (r) => print('${r.method} ${r.uri}'),
       onResponse: (r) => print('${r.statusCode}'));
diff --git a/example/server/cors_handler.dart b/example/server/cors_handler.dart
index c419a0a9..073ee0de 100644
--- a/example/server/cors_handler.dart
+++ b/example/server/cors_handler.dart
@@ -13,12 +13,12 @@ class CorsHandler implements Handler {
       'Access-Control-Expose-Headers': ['Location'],
     };
 
-    if (request.method.equals('OPTIONS')) {
+    if (request.method == 'options') {
       const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'];
       return Response(
           204,
-          Body.empty(),
-          Headers({
+          Body(),
+          Headers.from({
             ...headers,
             'Access-Control-Allow-Methods':
                 request.headers['Access-Control-Request-Method'] ?? methods,
diff --git a/lib/http.dart b/lib/http.dart
index 0cd26e6f..5a190c6d 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -1,5 +1,3 @@
-import 'dart:convert';
-
 import 'package:http_interop/http_interop.dart';
 
 class StatusCode {
@@ -29,7 +27,7 @@ class StatusCode {
 }
 
 class Json extends Body {
-  Json(Map<String, Object?> json) : super(jsonEncode(json), utf8);
+  Json(Map<String, Object?> super.object) : super.json();
 }
 
 class LoggingHandler implements Handler {
diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index 083f05dd..405267e2 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -23,8 +23,8 @@ class Client {
   /// Sends the [request] to the given [uri].
   Future<Response> send(Uri uri, Request request) async {
     final json = await _encode(request.document);
-    final body = http.Body(json, utf8);
-    final headers = http.Headers({
+    final body = http.Body.text(json, utf8);
+    final headers = http.Headers.from({
       'Accept': [mediaType],
       if (json.isNotEmpty) 'Content-Type': [mediaType],
       ...request.headers
@@ -32,8 +32,8 @@ class Client {
     final url = request.query.isEmpty
         ? uri
         : uri.replace(queryParameters: request.query.toQuery());
-    final response = await _handler
-        .handle(http.Request(http.Method(request.method), url, body, headers));
+    final response =
+        await _handler.handle(http.Request(request.method, url, body, headers));
 
     final document = await _decode(response);
     return Response(response, document);
diff --git a/lib/src/client/request.dart b/lib/src/client/request.dart
index d2f30bfb..00467270 100644
--- a/lib/src/client/request.dart
+++ b/lib/src/client/request.dart
@@ -31,5 +31,5 @@ class Request {
   final query = Query();
 
   /// HTTP headers.
-  final headers = Headers({});
+  final headers = Headers();
 }
diff --git a/lib/src/document/inbound_document.dart b/lib/src/document/inbound_document.dart
index 962052aa..4571c697 100644
--- a/lib/src/document/inbound_document.dart
+++ b/lib/src/document/inbound_document.dart
@@ -147,13 +147,11 @@ class _Parser {
 
   /// 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');
-  }
+  Link _link(Object json) => switch (json) {
+        String() => Link(Uri.parse(json)),
+        Map() => Link(Uri.parse(json['href']))..meta.addAll(meta(json)),
+        _ => throw FormatException('Invalid JSON')
+      };
 
   Map<String, Object?> _getAttributes(Map json) =>
       json.get<Map<String, Object?>>('attributes', orGet: () => {});
@@ -166,25 +164,19 @@ class _Parser {
       .get<Map>('relationships', orGet: () => {})
       .map((key, value) => MapEntry(key, newRelationship(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>().map(identifier));
-    throw FormatException('Invalid relationship object');
-  }
-
-  NewRelationship _newRel(data) {
-    if (data == null) {
-      return NewToOne.empty();
-    }
-    if (data is Map) {
-      return NewToOne(newIdentifier(data));
-    }
-    if (data is List) {
-      return NewToMany(data.whereType<Map>().map(newIdentifier));
-    }
-    throw FormatException('Invalid relationship object');
-  }
+  Relationship _rel(data) => switch (data) {
+        null => ToOne.empty(),
+        Map() => ToOne(identifier(data)),
+        List() => ToMany(data.whereType<Map>().map(identifier)),
+        _ => throw FormatException('Invalid relationship object')
+      };
+
+  NewRelationship _newRel(data) => switch (data) {
+        null => NewToOne.empty(),
+        Map() => NewToOne(newIdentifier(data)),
+        List() => NewToMany(data.whereType<Map>().map(newIdentifier)),
+        _ => throw FormatException('Invalid relationship object')
+      };
 }
 
 extension _TypedGeter on Map {
diff --git a/lib/src/document/new_relationship.dart b/lib/src/document/new_relationship.dart
index f985ea06..65de471f 100644
--- a/lib/src/document/new_relationship.dart
+++ b/lib/src/document/new_relationship.dart
@@ -12,8 +12,6 @@ class NewRelationship with IterableMixin<NewIdentifier> {
         if (meta.isNotEmpty) 'meta': meta,
       };
 
-  // coverage:ignore-start
   @override
   Iterator<NewIdentifier> get iterator => <NewIdentifier>[].iterator;
-// coverage:ignore-end
 }
diff --git a/lib/src/document/new_resource.dart b/lib/src/document/new_resource.dart
index b111ab72..3cf4be73 100644
--- a/lib/src/document/new_resource.dart
+++ b/lib/src/document/new_resource.dart
@@ -61,7 +61,6 @@ class NewResource {
     if (r is NewToMany) {
       return ToMany(r.map((identifier) => _toIdentifier(identifier, id)));
     }
-    // coverage:ignore-line
     throw StateError('Unexpected relationship type: ${r.runtimeType}');
   }
 
@@ -70,16 +69,12 @@ class NewResource {
     return _toIdentifier(identifier, id);
   }
 
-  Identifier _toIdentifier(NewIdentifier identifier, String id) {
-    switch (identifier) {
-      case Identifier():
-        return identifier;
-      case LocalIdentifier():
-        if (identifier.type == type && identifier.lid == lid) {
-          return identifier.toIdentifier(id);
-        }
-        throw StateError(
-            'Unmatched local id: "${identifier.lid}". Expected "$lid".');
-    }
-  }
+  Identifier _toIdentifier(NewIdentifier identifier, String id) =>
+      switch (identifier) {
+        Identifier() => identifier,
+        LocalIdentifier() => (identifier.type == type && identifier.lid == lid)
+            ? identifier.toIdentifier(id)
+            : throw StateError(
+                'Unmatched local id: "${identifier.lid}". Expected "$lid".')
+      };
 }
diff --git a/lib/src/routing/standard_uri_design.dart b/lib/src/routing/standard_uri_design.dart
index 27d13829..2b474f65 100644
--- a/lib/src/routing/standard_uri_design.dart
+++ b/lib/src/routing/standard_uri_design.dart
@@ -11,22 +11,14 @@ class StandardUriDesign implements UriDesign {
   /// `/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;
-  }
+  static Target? matchTarget(Uri uri) => switch ((uri.pathSegments)) {
+        [var type] => Target(type),
+        [var type, var id] => ResourceTarget(type, id),
+        [var type, var id, var rel] => RelatedTarget(type, id, rel),
+        [var type, var id, 'relationships', var rel] =>
+          RelationshipTarget(type, id, rel),
+        _ => null
+      };
 
   final Uri base;
 
diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart
index 3f45e37e..08f25a6c 100644
--- a/lib/src/server/controller_router.dart
+++ b/lib/src/server/controller_router.dart
@@ -18,49 +18,31 @@ class ControllerRouter implements Handler {
   Future<Response> handle(Request request) async {
     _validate(request);
     final target = _matchTarget(request.uri);
-    if (target is RelationshipTarget) {
-      if (request.method.equals('GET')) {
-        return await _controller.fetchRelationship(request, target);
-      }
-      if (request.method.equals('POST')) {
-        return await _controller.addMany(request, target);
-      }
-      if (request.method.equals('PATCH')) {
-        return await _controller.replaceRelationship(request, target);
-      }
-      if (request.method.equals('DELETE')) {
-        return await _controller.deleteMany(request, target);
-      }
-      throw MethodNotAllowed(request.method.value);
-    }
-    if (target is RelatedTarget) {
-      if (request.method.equals('GET')) {
-        return await _controller.fetchRelated(request, target);
-      }
-      throw MethodNotAllowed(request.method.value);
-    }
-    if (target is ResourceTarget) {
-      if (request.method.equals('GET')) {
-        return await _controller.fetchResource(request, target);
-      }
-      if (request.method.equals('PATCH')) {
-        return await _controller.updateResource(request, target);
-      }
-      if (request.method.equals('DELETE')) {
-        return await _controller.deleteResource(request, target);
-      }
-      throw MethodNotAllowed(request.method.value);
-    }
-    if (target is Target) {
-      if (request.method.equals('GET')) {
-        return await _controller.fetchCollection(request, target);
-      }
-      if (request.method.equals('POST')) {
-        return await _controller.createResource(request, target);
-      }
-      throw MethodNotAllowed(request.method.value);
-    }
-    throw UnmatchedTarget(request.uri);
+    return await switch (target) {
+      RelationshipTarget() => switch (request.method) {
+          'get' => _controller.fetchRelationship(request, target),
+          'post' => _controller.addMany(request, target),
+          'patch' => _controller.replaceRelationship(request, target),
+          'delete' => _controller.deleteMany(request, target),
+          _ => throw MethodNotAllowed(request.method)
+        },
+      RelatedTarget() => switch (request.method) {
+          'get' => _controller.fetchRelated(request, target),
+          _ => throw MethodNotAllowed(request.method)
+        },
+      ResourceTarget() => switch (request.method) {
+          'get' => _controller.fetchResource(request, target),
+          'patch' => _controller.updateResource(request, target),
+          'delete' => _controller.deleteResource(request, target),
+          _ => throw MethodNotAllowed(request.method)
+        },
+      Target() => switch (request.method) {
+          'get' => _controller.fetchCollection(request, target),
+          'post' => _controller.createResource(request, target),
+          _ => throw MethodNotAllowed(request.method)
+        },
+      _ => throw UnmatchedTarget(request.uri)
+    };
   }
 
   void _validate(Request request) {
diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart
index b15084c6..d8cabaaa 100644
--- a/lib/src/server/response.dart
+++ b/lib/src/server/response.dart
@@ -1,5 +1,3 @@
-import 'dart:convert';
-
 import 'package:http_interop/http_interop.dart' as http;
 import 'package:json_api/document.dart';
 import 'package:json_api/http.dart';
@@ -10,10 +8,8 @@ class Response<D extends OutboundDocument> extends http.Response {
   Response(int statusCode, {D? document})
       : super(
             statusCode,
-            document != null
-                ? http.Body(jsonEncode(document), utf8)
-                : http.Body.empty(),
-            http.Headers({})) {
+            document != null ? http.Body.json(document) : http.Body(),
+            http.Headers()) {
     if (document != null) {
       headers['Content-Type'] = [mediaType];
     }
diff --git a/pubspec.yaml b/pubspec.yaml
index f93f095f..d47cfb3f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,24 +1,24 @@
 name: json_api
-version: 6.0.1
+version: 7.0.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: '>=3.0.0 <4.0.0'
+  sdk: '>=3.1.0 <4.0.0'
 
 dependencies:
   http_parser: ^4.0.0
-  http_interop: ^0.8.0
+  http_interop: ^1.0.0
 
 dev_dependencies:
-  lints: ^2.1.1
+  lints: ^3.0.0
   test: ^1.21.1
   stream_channel: ^2.1.0
-  uuid: ^3.0.0
+  uuid: ^4.2.0
   coverage: ^1.3.0
   check_coverage: ^0.0.4
   http: ^1.1.0
-  http_interop_http: ^0.6.0
-  http_interop_io: ^0.6.0
+  http_interop_http: ^0.7.0
+  http_interop_io: ^0.7.0
 
 
 cider:
diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart
index db60cff3..edccf8b7 100644
--- a/test/contract/errors_test.dart
+++ b/test/contract/errors_test.dart
@@ -26,16 +26,16 @@ void main() {
       }
     });
     test('Bad request when target can not be matched', () async {
-      final r = await TestHandler().handle(http.Request(http.Method('get'),
-          Uri.parse('/a/long/prefix/'), http.Body.empty(), http.Headers({})));
+      final r = await TestHandler().handle(http.Request(
+          'get', Uri.parse('/a/long/prefix/'), http.Body(), http.Headers()));
       expect(r.statusCode, 400);
     });
     test('Unsupported extension', () async {
       final r = await TestHandler().handle(http.Request(
-          http.Method('get'),
+          'get',
           Uri.parse('/posts/1'),
-          http.Body.empty(),
-          http.Headers({
+          http.Body(),
+          http.Headers.from({
             'Content-Type': ['application/vnd.api+json; ext=foobar'],
             'Accept': ['application/vnd.api+json']
           })));
@@ -43,10 +43,10 @@ void main() {
     });
     test('Unacceptable', () async {
       final r = await TestHandler().handle(http.Request(
-          http.Method('get'),
+          'get',
           Uri.parse('/posts/1'),
-          http.Body.empty(),
-          http.Headers({
+          http.Body(),
+          http.Headers.from({
             'Content-Type': ['application/vnd.api+json'],
             'Accept': ['application/vnd.api+json; ext=foobar']
           })));
diff --git a/test/test_handler.dart b/test/test_handler.dart
index d26214ea..3b29817a 100644
--- a/test/test_handler.dart
+++ b/test/test_handler.dart
@@ -17,11 +17,12 @@ class TestHandler extends LoggingHandler {
                 ControllerRouter(RepositoryController(InMemoryRepo(types), _id),
                     StandardUriDesign.matchTarget),
                 onError: ErrorConverter(
-                    onError: onError ??
-                        (err, trace) {
-                          print(trace);
-                          throw err;
-                        })),
+                        onError: onError ??
+                            (err, trace) {
+                              print(trace);
+                              throw err;
+                            })
+                    .call),
             onRequest: onRequest,
             onResponse: onResponse);
 }
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index 6fe5120b..c617682f 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -44,7 +44,7 @@ void main() {
       expect(response.collection.single.type, 'articles');
       expect(response.collection.single.id, '1');
       expect(response.included, isEmpty);
-      expect(http.request.method.value, 'get');
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -70,7 +70,7 @@ void main() {
 
     expect(response.collection.length, 1);
     expect(response.included.length, 3);
-    expect(http.request.method.value, 'get');
+    expect(http.request.method, equals('get'));
     expect(http.request.uri.path, '/articles');
     expect(http.request.uri.queryParameters, {
       'include': 'author',
@@ -93,7 +93,7 @@ void main() {
       final response =
           await client.fetchRelatedCollection('people', '1', 'articles');
       expect(response.collection.length, 1);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.path, '/people/1/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -119,7 +119,7 @@ void main() {
 
       expect(response.collection.length, 1);
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.path, '/people/1/articles');
       expect(http.request.uri.queryParameters, {
         'include': 'author',
@@ -142,7 +142,7 @@ void main() {
       http.response = mock.primaryResource();
       final response = await client.fetchResource('articles', '1');
       expect(response.resource.type, 'articles');
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -164,7 +164,7 @@ void main() {
       ]);
       expect(response.resource.type, 'articles');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.path, '/articles/1');
       expect(http.request.uri.queryParameters,
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
@@ -184,7 +184,7 @@ void main() {
           await client.fetchRelatedResource('articles', '1', 'author');
       expect(response.resource?.type, 'articles');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.toString(), '/articles/1/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -207,7 +207,7 @@ void main() {
       ]);
       expect(response.resource?.type, 'articles');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.path, '/articles/1/author');
       expect(http.request.uri.queryParameters,
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
@@ -225,7 +225,7 @@ void main() {
           await client.fetchRelatedResource('articles', '1', 'author');
       expect(response.resource, isNull);
       expect(response.included, isEmpty);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.toString(), '/articles/1/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -238,7 +238,7 @@ void main() {
       http.response = mock.one();
       final response = await client.fetchToOne('articles', '1', 'author');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json']
@@ -256,7 +256,7 @@ void main() {
         })
       ]);
       expect(response.included.length, 3);
-      expect(http.request.method.equals('get'), true);
+      expect(http.request.method, equals('get'));
       expect(http.request.uri.path, '/articles/1/relationships/author');
       expect(http.request.uri.queryParameters, {'foo': 'bar'});
       expect(http.request.headers, {
@@ -274,7 +274,7 @@ void main() {
       expect(
           response.links['self'].toString(), 'http://example.com/articles/1');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -304,7 +304,7 @@ void main() {
       expect(
           response.links['self'].toString(), 'http://example.com/articles/1');
       expect(response.included.length, 3);
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -344,7 +344,7 @@ void main() {
       http.response = mock.primaryResource();
       final response = await client.create('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -359,7 +359,7 @@ void main() {
       http.response = mock.noContent();
       final response = await client.create('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -386,7 +386,7 @@ void main() {
         'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -427,7 +427,7 @@ void main() {
       http.response = mock.primaryResource();
       final response = await client.updateResource('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -442,7 +442,7 @@ void main() {
       http.response = mock.noContent();
       final response = await client.updateResource('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -470,7 +470,7 @@ void main() {
         'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -512,7 +512,7 @@ void main() {
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'));
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -534,7 +534,7 @@ void main() {
             'foo': ['bar']
           });
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -574,7 +574,7 @@ void main() {
       final response = await client.deleteToOne('articles', '1', 'author');
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -591,7 +591,7 @@ void main() {
       });
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/author');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -625,7 +625,7 @@ void main() {
       final response = await client
           .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('delete'), true);
+      expect(http.request.method, equals('delete'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -648,7 +648,7 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('delete'), true);
+      expect(http.request.method, equals('delete'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -690,7 +690,7 @@ void main() {
       final response = await client
           .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -713,7 +713,7 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('patch'), true);
+      expect(http.request.method, equals('patch'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -755,7 +755,7 @@ void main() {
       final response = await client
           .addMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
@@ -778,7 +778,7 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method.equals('post'), true);
+      expect(http.request.method, equals('post'));
       expect(http.request.uri.toString(), '/articles/1/relationships/tags');
       expect(http.request.headers, {
         'Accept': ['application/vnd.api+json'],
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index ce1c81eb..1a646036 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -2,7 +2,7 @@ import 'package:http_interop/http_interop.dart';
 import 'package:json_api/http.dart';
 import 'package:json_api/src/media_type.dart';
 
-final headers = Headers({
+final headers = Headers.from({
   'Content-Type': [mediaType]
 });
 
@@ -255,7 +255,7 @@ many() => Response(
     }),
     headers);
 
-noContent() => Response(204, Body.empty(), Headers({}));
+noContent() => Response(204, Body(), Headers());
 
 error422() => Response(
     422,
@@ -272,4 +272,4 @@ error422() => Response(
     }),
     headers);
 
-error500() => Response(500, Body.empty(), Headers({}));
+error500() => Response(500, Body(), Headers());
diff --git a/test/unit/document/inbound_document_test.dart b/test/unit/document/inbound_document_test.dart
index 0123e389..d5b4c22a 100644
--- a/test/unit/document/inbound_document_test.dart
+++ b/test/unit/document/inbound_document_test.dart
@@ -188,6 +188,20 @@ void main() {
         expect(() => InboundDocument(payload.many).asToOne(),
             throwsFormatException);
       });
+
+      test('throws on invalid new relationship', () {
+        expect(
+            () => InboundDocument(payload.newResourceInvalidRelationship)
+                .dataAsNewResource(),
+            throwsFormatException);
+      });
+
+      test('throws on incomplete new relationship', () {
+        expect(
+            () => InboundDocument(payload.newResourceIncompleteRelationship)
+                .dataAsNewResource(),
+            throwsFormatException);
+      });
     });
   });
 }
diff --git a/test/unit/document/new_relationship_test.dart b/test/unit/document/new_relationship_test.dart
new file mode 100644
index 00000000..504d442c
--- /dev/null
+++ b/test/unit/document/new_relationship_test.dart
@@ -0,0 +1,13 @@
+import 'package:json_api/document.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+void main() {
+  group('NewRelationship', () {
+    test('iterator', () {
+      final list = [];
+      list.addAll(NewRelationship());
+      expect(list, isEmpty);
+    });
+  });
+}
diff --git a/test/unit/document/new_resource_test.dart b/test/unit/document/new_resource_test.dart
index 1e0cd9da..eba069e4 100644
--- a/test/unit/document/new_resource_test.dart
+++ b/test/unit/document/new_resource_test.dart
@@ -5,6 +5,30 @@ import 'package:test/test.dart';
 
 void main() {
   group('NewResource', () {
+    test('toResource throws on unmatched local id in "many"', () {
+      final resource = NewResource('test_type', id: 'test_id', lid: 'test_lid')
+        ..relationships['many'] = NewToMany([
+          LocalIdentifier('test_type', 'test_lid2'),
+        ]);
+
+      expect(() => resource.toResource(() => 'my-test-id'), throwsStateError);
+    });
+
+    test('toResource throws on unmatched local id in "one"', () {
+      final resource = NewResource('test_type', id: 'test_id', lid: 'test_lid')
+        ..relationships['one'] =
+            NewToOne(LocalIdentifier('test_type', 'test_lid2'));
+
+      expect(() => resource.toResource(() => 'my-test-id'), throwsStateError);
+    });
+
+    test('toResource throws on invalid relationship', () {
+      final resource = NewResource('test_type', id: 'test_id')
+        ..relationships['many'] = NewRelationship();
+
+      expect(() => resource.toResource(() => 'my-test-id'), throwsStateError);
+    });
+
     test('json encoding', () {
       expect(jsonEncode(NewResource('test_type')),
           jsonEncode({'type': 'test_type'}));
diff --git a/test/unit/document/new_to_one_test.dart b/test/unit/document/new_to_one_test.dart
new file mode 100644
index 00000000..6853d3af
--- /dev/null
+++ b/test/unit/document/new_to_one_test.dart
@@ -0,0 +1,15 @@
+import 'package:json_api/document.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+void main() {
+  group('NewToOne', () {
+    test('can be iterated', () {
+      final id = Identifier('books', '123');
+      final r = NewToOne(id);
+      final list = <NewIdentifier>[];
+      list.addAll(r);
+      expect(list.single, equals(id));
+    });
+  });
+}
diff --git a/test/unit/document/payload.dart b/test/unit/document/payload.dart
index c7d6cb06..9653ac33 100644
--- a/test/unit/document/payload.dart
+++ b/test/unit/document/payload.dart
@@ -147,3 +147,27 @@ final resource = {
     }
   }
 };
+
+final newResourceInvalidRelationship = {
+  'data': {
+    'type': 'articles',
+    'attributes': {'title': 'JSON:API paints my bikeshed!'},
+    'relationships': {
+      'author': {
+        'data': true,
+      },
+    }
+  }
+};
+
+final newResourceIncompleteRelationship = {
+  'data': {
+    'type': 'articles',
+    'attributes': {'title': 'JSON:API paints my bikeshed!'},
+    'relationships': {
+      'author': {
+        'data': {'type': 'person'},
+      },
+    }
+  }
+};

From 356319bbb8aac5859939bdf376bf542cf856bfcb Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Mon, 17 Jun 2024 19:00:12 -0700
Subject: [PATCH 22/25] Fix (#139)

---
 CHANGELOG.md                          |  5 +++++
 example/server.dart                   |  1 +
 lib/src/query/fields.dart             |  2 +-
 lib/src/query/include.dart            |  2 +-
 lib/src/server/controller_router.dart | 16 ++++++++++------
 pubspec.yaml                          |  2 +-
 6 files changed, 19 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 679d663c..4dac7f21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [7.0.1] - 2024-06-17
+### Fixed
+- "Accept" header with multiple values was being mishandled
+
 ## [7.0.0] - 2023-11-12
 ### Changed
 - Migrated to `http_interop` v1.
@@ -246,6 +250,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1
 [7.0.0]: https://github.com/f3ath/json-api-dart/compare/6.0.1...7.0.0
 [6.0.1]: https://github.com/f3ath/json-api-dart/compare/6.0.0...6.0.1
 [6.0.0]: https://github.com/f3ath/json-api-dart/compare/5.4.0...6.0.0
diff --git a/example/server.dart b/example/server.dart
index bea19e00..9235e38c 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -23,6 +23,7 @@ Future<void> main() async {
   handler = TryCatchHandler(handler,
       onError: ErrorConverter(onError: (e, stack) async {
         stderr.writeln(e);
+        stderr.writeln(stack);
         return Response(500,
             document: OutboundErrorDocument(
                 [ErrorObject(title: 'Internal Server Error')]));
diff --git a/lib/src/query/fields.dart b/lib/src/query/fields.dart
index 0291e86f..a09bab7c 100644
--- a/lib/src/query/fields.dart
+++ b/lib/src/query/fields.dart
@@ -19,7 +19,7 @@ class Fields with MapMixin<String, Iterable<String>> implements QueryEncodable {
   static Fields fromUri(Uri uri) =>
       Fields(uri.queryParametersAll.map((k, v) => MapEntry(
           _regex.firstMatch(k)?.group(1) ?? '',
-          v.expand((_) => _.split(',')).toList()))
+          v.expand((it) => it.split(',')).toList()))
         ..removeWhere((k, v) => k.isEmpty));
 
   static final _regex = RegExp(r'^fields\[(.+)\]$');
diff --git a/lib/src/query/include.dart b/lib/src/query/include.dart
index 8d26b23b..838cfc35 100644
--- a/lib/src/query/include.dart
+++ b/lib/src/query/include.dart
@@ -14,7 +14,7 @@ class Include with IterableMixin<String> implements QueryEncodable {
   }
 
   static Include fromUri(Uri uri) => Include(
-      uri.queryParametersAll['include']?.expand((_) => _.split(',')) ?? []);
+      uri.queryParametersAll['include']?.expand((it) => it.split(',')) ?? []);
 
   final _ = <String>[];
 
diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart
index 08f25a6c..e10d5195 100644
--- a/lib/src/server/controller_router.dart
+++ b/lib/src/server/controller_router.dart
@@ -2,6 +2,7 @@ import 'package:http_interop/http_interop.dart';
 import 'package:http_parser/http_parser.dart';
 import 'package:json_api/http.dart';
 import 'package:json_api/routing.dart';
+import 'package:json_api/src/media_type.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/unacceptable.dart';
@@ -47,16 +48,19 @@ class ControllerRouter implements Handler {
 
   void _validate(Request request) {
     final contentType = request.headers.last('Content-Type');
-    if (contentType != null && !_isValid(MediaType.parse(contentType))) {
+    if (contentType != null && _isInvalid(MediaType.parse(contentType))) {
       throw UnsupportedMediaType();
     }
-    final accept = request.headers.last('Accept');
-    if (accept != null && !_isValid(MediaType.parse(accept))) {
+    if ((request.headers['Accept'] ?? [])
+        .expand((it) => it.split(','))
+        .map((it) => it.trim())
+        .map(MediaType.parse)
+        .any(_isInvalid)) {
       throw Unacceptable();
     }
   }
 
-  bool _isValid(MediaType mediaType) {
-    return mediaType.parameters.isEmpty; // TODO: check for ext and profile
-  }
+  bool _isInvalid(MediaType mt) =>
+      mt.mimeType == mediaType &&
+      mt.parameters.isNotEmpty; // TODO: check for ext and profile
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index d47cfb3f..eab220f5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 7.0.0
+version: 7.0.1
 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:

From a1b2f096ca737b9eea2b120187666f75ad6365bb Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Mon, 1 Jul 2024 18:20:40 -0700
Subject: [PATCH 23/25] v8 (#141)

---
 CHANGELOG.md                                |   8 +
 README.md                                   |  44 ++-
 example/client.dart                         |   4 +-
 example/server.dart                         |  10 +-
 example/server/cors_handler.dart            |  31 --
 example/server/json_api_server.dart         |   2 +-
 example/server/repository_controller.dart   |  61 ++--
 lib/http.dart                               |  54 ++--
 lib/server.dart                             |   2 +-
 lib/src/client/client.dart                  |  12 +-
 lib/src/query/sort.dart                     |  12 +-
 lib/src/server/controller_router.dart       |   3 +-
 lib/src/server/error_converter.dart         |  16 +-
 lib/src/server/response.dart                |  51 ++--
 lib/src/server/try_catch_handler.dart       |  26 --
 lib/src/server/try_catch_middleware.dart    |  21 ++
 pubspec.yaml                                |  14 +-
 test/contract/crud_test.dart                |   2 +-
 test/contract/errors_test.dart              |   8 +-
 test/contract/resource_creation_test.dart   |   2 +-
 test/e2e/browser_test.dart                  |   5 +-
 test/e2e/hybrid_server.dart                 |   9 +-
 test/e2e/one_off_handler.dart               |  10 +
 test/e2e/vm_test.dart                       |   6 +-
 test/test_handler.dart                      |  45 ++-
 test/unit/client/client_test.dart           | 320 ++++++++++----------
 test/unit/client/mock_handler.dart          |   3 +-
 test/unit/client/response.dart              |  17 +-
 test/unit/http/cors_middleware_test.dart    |  65 ++++
 test/unit/http/logging_middleware_test.dart |  26 ++
 30 files changed, 502 insertions(+), 387 deletions(-)
 delete mode 100644 example/server/cors_handler.dart
 delete mode 100644 lib/src/server/try_catch_handler.dart
 create mode 100644 lib/src/server/try_catch_middleware.dart
 create mode 100644 test/e2e/one_off_handler.dart
 create mode 100644 test/unit/http/cors_middleware_test.dart
 create mode 100644 test/unit/http/logging_middleware_test.dart

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4dac7f21..42e78d19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [8.0.0] - 2024-07-01
+### Added
+- CORS middware
+
+### Changed
+- Bump http\_interop to v2.0
+
 ## [7.0.1] - 2024-06-17
 ### Fixed
 - "Accept" header with multiple values was being mishandled
@@ -250,6 +257,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[8.0.0]: https://github.com/f3ath/json-api-dart/compare/7.0.1...8.0.0
 [7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1
 [7.0.0]: https://github.com/f3ath/json-api-dart/compare/6.0.1...7.0.0
 [6.0.1]: https://github.com/f3ath/json-api-dart/compare/6.0.0...6.0.1
diff --git a/README.md b/README.md
index 90948700..75a866a0 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
 
 TL;DR:
 ```dart
+import 'package:http/http.dart' as http;
 import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
@@ -13,29 +14,52 @@ void main() async {
   /// Use the standard recommended URL structure or implement your own
   final uriDesign = StandardUriDesign(Uri.parse(baseUri));
 
+  /// This is the Dart's standard HTTP client.
+  /// Do not forget to close it in the end.
+  final httpClient = http.Client();
+
+  /// This is the interface which decouples this JSON:API implementation
+  /// from the HTTP client.
+  /// Learn more: https://pub.dev/packages/http_interop
+  final httpHandler = httpClient.handleInterop;
+
+  /// This is the basic JSON:API client. It is flexible but not very convenient
+  /// to use, because you would need to remember a lot of JSON:API protocol details.
+  /// We will use another wrapper on top of it.
+  final jsonApiClient = Client(httpHandler);
+
   /// The [RoutingClient] is most likely the right choice.
-  /// It has methods covering many standard use cases.
-  final client = RoutingClient(uriDesign, Client(OneOffHandler()));
+  /// It is called routing because it routes the calls to the correct
+  /// URLs depending on the use case. Take a look at its methods, they cover
+  /// all the standard scenarios specified by the JSON:API standard.
+  final client = RoutingClient(uriDesign, jsonApiClient);
 
   try {
     /// Fetch the collection.
     /// See other methods to query and manipulate resources.
     final response = await client.fetchCollection('colors');
 
-    final resources = response.collection;
-    resources.map((resource) => resource.attributes).forEach((attr) {
-      final name = attr['name'];
-      final red = attr['red'];
-      final green = attr['green'];
-      final blue = attr['blue'];
+    /// The fetched collection allows us to iterate over the resources
+    /// and to look into their attributes
+    for (final resource in response.collection) {
+      final {
+      'name': name,
+      'red': red,
+      'green': green,
+      'blue': blue,
+      } = resource.attributes;
+      print('${resource.type}:${resource.id}');
       print('$name - $red:$green:$blue');
-    });
+    }
   } on RequestFailure catch (e) {
     /// Catch error response
-    for (var error in e.errors) {
+    for (final error in e.errors) {
       print(error.title);
     }
   }
+
+  /// Free up the resources before exit.
+  httpClient.close();
 }
 ```
 This is a work-in-progress. You can help it by submitting a PR with a feature or documentation improvements.
diff --git a/example/client.dart b/example/client.dart
index a9b2387d..e2e6f672 100644
--- a/example/client.dart
+++ b/example/client.dart
@@ -14,10 +14,10 @@ void main() async {
   /// Do not forget to close it in the end.
   final httpClient = http.Client();
 
-  /// This is the adapter which decouples this JSON:API implementation
+  /// This is the interface which decouples this JSON:API implementation
   /// from the HTTP client.
   /// Learn more: https://pub.dev/packages/http_interop
-  final httpHandler = ClientWrapper(httpClient);
+  final httpHandler = httpClient.handleInterop;
 
   /// This is the basic JSON:API client. It is flexible but not very convenient
   /// to use, because you would need to remember a lot of JSON:API protocol details.
diff --git a/example/server.dart b/example/server.dart
index 9235e38c..a8173b4e 100644
--- a/example/server.dart
+++ b/example/server.dart
@@ -19,16 +19,18 @@ Future<void> main() async {
   await initRepo(repo);
   final controller = RepositoryController(repo, Uuid().v4);
   interop.Handler handler =
-      ControllerRouter(controller, StandardUriDesign.matchTarget);
-  handler = TryCatchHandler(handler,
+      ControllerRouter(controller, StandardUriDesign.matchTarget).handle;
+
+  handler = tryCatchMiddleware(handler,
       onError: ErrorConverter(onError: (e, stack) async {
         stderr.writeln(e);
         stderr.writeln(stack);
-        return Response(500,
+        return response(500,
             document: OutboundErrorDocument(
                 [ErrorObject(title: 'Internal Server Error')]));
       }).call);
-  handler = LoggingHandler(handler,
+
+  handler = loggingMiddleware(handler,
       onRequest: (r) => print('${r.method} ${r.uri}'),
       onResponse: (r) => print('${r.statusCode}'));
   final server = JsonApiServer(handler, host: host, port: port);
diff --git a/example/server/cors_handler.dart b/example/server/cors_handler.dart
deleted file mode 100644
index 073ee0de..00000000
--- a/example/server/cors_handler.dart
+++ /dev/null
@@ -1,31 +0,0 @@
-import 'package:http_interop/http_interop.dart';
-import 'package:json_api/http.dart';
-
-class CorsHandler implements Handler {
-  CorsHandler(this._inner);
-
-  final Handler _inner;
-
-  @override
-  Future<Response> handle(Request request) async {
-    final headers = {
-      'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'],
-      'Access-Control-Expose-Headers': ['Location'],
-    };
-
-    if (request.method == 'options') {
-      const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'];
-      return Response(
-          204,
-          Body(),
-          Headers.from({
-            ...headers,
-            'Access-Control-Allow-Methods':
-                request.headers['Access-Control-Request-Method'] ?? methods,
-            'Access-Control-Allow-Headers':
-                request.headers['Access-Control-Request-Headers'] ?? ['*'],
-          }));
-    }
-    return await _inner.handle(request..headers.addAll(headers));
-  }
-}
diff --git a/example/server/json_api_server.dart b/example/server/json_api_server.dart
index 1d8510d6..9430821e 100644
--- a/example/server/json_api_server.dart
+++ b/example/server/json_api_server.dart
@@ -41,7 +41,7 @@ class JsonApiServer {
 
   Future<HttpServer> _createServer() async {
     final server = await HttpServer.bind(host, port);
-    server.listen(listener(_handler));
+    server.listenInterop(_handler);
     return server;
   }
 }
diff --git a/example/server/repository_controller.dart b/example/server/repository_controller.dart
index c1f9dd95..cd40607d 100644
--- a/example/server/repository_controller.dart
+++ b/example/server/repository_controller.dart
@@ -1,7 +1,7 @@
 import 'dart:convert';
 
 import 'package:http_interop/extensions.dart';
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
 import 'package:json_api/query.dart';
 import 'package:json_api/routing.dart';
@@ -22,7 +22,7 @@ class RepositoryController implements Controller {
   final design = StandardUriDesign.pathOnly;
 
   @override
-  Future<Response> fetchCollection(http.Request request, Target target) async {
+  Future<Response> fetchCollection(Request request, Target target) async {
     final resources = await _fetchAll(target.type).toList();
     final doc = OutboundDataDocument.collection(resources)
       ..links['self'] = Link(design.collection(target.type));
@@ -32,12 +32,11 @@ class RepositoryController implements Controller {
         doc.included.add(r);
       }
     }
-    return Response.ok(doc);
+    return ok(doc);
   }
 
   @override
-  Future<Response> fetchResource(
-      http.Request request, ResourceTarget target) async {
+  Future<Response> fetchResource(Request 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));
@@ -45,115 +44,113 @@ class RepositoryController implements Controller {
     await for (final r in _getAllRelated(resource, forest)) {
       doc.included.add(r);
     }
-    return Response.ok(doc);
+    return ok(doc);
   }
 
   @override
-  Future<Response> createResource(http.Request request, Target target) async {
+  Future<Response> createResource(Request request, Target target) async {
     final document = await _decode(request);
     final newResource = document.dataAsNewResource();
     final res = newResource.toResource(getId);
     await repo.persist(
         res.type, Model(res.id)..setFrom(ModelProps.fromResource(res)));
     if (newResource.id != null) {
-      return Response.noContent();
+      return noContent();
     }
     final ref = Reference.of(res.toIdentifier());
     final self = Link(design.resource(ref.type, ref.id));
     final resource = (await _fetchResource(ref.type, ref.id))
       ..links['self'] = self;
-    return Response.created(
+    return created(
         OutboundDataDocument.resource(resource)..links['self'] = self,
         self.uri.toString());
   }
 
   @override
-  Future<Response> addMany(
-      http.Request request, RelationshipTarget target) async {
+  Future<Response> addMany(Request request, RelationshipTarget target) async {
     final many = (await _decode(request)).asRelationship<ToMany>();
     final refs = await repo
         .addMany(target.type, target.id, target.relationship, many)
         .toList();
-    return Response.ok(OutboundDataDocument.many(ToMany(refs)));
+    return ok(OutboundDataDocument.many(ToMany(refs)));
   }
 
   @override
   Future<Response> deleteResource(
-      http.Request request, ResourceTarget target) async {
+      Request request, ResourceTarget target) async {
     await repo.delete(target.type, target.id);
-    return Response.noContent();
+    return noContent();
   }
 
   @override
   Future<Response> updateResource(
-      http.Request request, ResourceTarget target) async {
+      Request request, ResourceTarget target) async {
     await repo.update(target.type, target.id,
         ModelProps.fromResource((await _decode(request)).dataAsResource()));
-    return Response.noContent();
+    return noContent();
   }
 
   @override
   Future<Response> replaceRelationship(
-      http.Request request, RelationshipTarget target) async {
+      Request 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(
+      return ok(
           OutboundDataDocument.one(ref == null ? ToOne.empty() : ToOne(ref)));
     }
     if (rel is ToMany) {
       final ids = await repo
           .replaceMany(target.type, target.id, target.relationship, rel)
           .toList();
-      return Response.ok(OutboundDataDocument.many(ToMany(ids)));
+      return ok(OutboundDataDocument.many(ToMany(ids)));
     }
     throw FormatException('Incomplete relationship');
   }
 
   @override
   Future<Response> deleteMany(
-      http.Request request, RelationshipTarget target) async {
+      Request request, RelationshipTarget target) async {
     final rel = (await _decode(request)).asToMany();
     final ids = await repo
         .deleteMany(target.type, target.id, target.relationship, rel)
         .toList();
-    return Response.ok(OutboundDataDocument.many(ToMany(ids)));
+    return ok(OutboundDataDocument.many(ToMany(ids)));
   }
 
   @override
   Future<Response> fetchRelationship(
-      http.Request request, RelationshipTarget target) async {
+      Request request, RelationshipTarget target) async {
     final model = (await repo.fetch(target.type, target.id));
 
     if (model.one.containsKey(target.relationship)) {
-      return Response.ok(OutboundDataDocument.one(
+      return ok(OutboundDataDocument.one(
           ToOne(model.one[target.relationship]?.toIdentifier())));
     }
     final many =
         model.many[target.relationship]?.map((it) => it.toIdentifier());
     if (many != null) {
       final doc = OutboundDataDocument.many(ToMany(many));
-      return Response.ok(doc);
+      return ok(doc);
     }
     throw RelationshipNotFound(target.type, target.id, target.relationship);
   }
 
   @override
-  Future<Response> fetchRelated(
-      http.Request request, RelatedTarget target) async {
+  Future<Response> fetchRelated(Request 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);
+      return 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);
+      return ok(doc);
     }
     throw RelationshipNotFound(target.type, target.id, target.relationship);
   }
@@ -171,10 +168,10 @@ class RepositoryController implements Controller {
 
   /// Returns a stream of related resources
   Stream<Resource> _getRelated(Resource resource, String relationship) async* {
-    for (final _ in resource.relationships[relationship] ??
+    for (final rel in resource.relationships[relationship] ??
         (throw RelationshipNotFound(
             resource.type, resource.id, relationship))) {
-      yield await _fetchLinkedResource(_.type, _.id);
+      yield await _fetchLinkedResource(rel.type, rel.id);
     }
   }
 
@@ -185,7 +182,7 @@ class RepositoryController implements Controller {
   }
 
   Stream<Resource> _fetchAll(String type) =>
-      repo.fetchCollection(type).map((_) => _.toResource(type));
+      repo.fetchCollection(type).map((model) => model.toResource(type));
 
   /// Fetches and builds a resource object
   Future<Resource> _fetchResource(String type, String id) async {
@@ -203,7 +200,7 @@ class RepositoryController implements Controller {
     }
   }
 
-  Future<InboundDocument> _decode(http.Request r) => r.body
+  Future<InboundDocument> _decode(Request r) => r.body
       .decode(utf8)
       .then(const PayloadCodec().decode)
       .then(InboundDocument.new);
diff --git a/lib/http.dart b/lib/http.dart
index 5a190c6d..9a3ced90 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -26,32 +26,44 @@ class StatusCode {
   bool get isFailed => !isSuccessful && !isPending;
 }
 
-class Json extends Body {
-  Json(Map<String, Object?> super.object) : super.json();
-}
-
-class LoggingHandler implements Handler {
-  LoggingHandler(this.handler, {this.onRequest, this.onResponse});
+Handler loggingMiddleware(Handler handler,
+        {Function(Request request)? onRequest,
+        Function(Response response)? onResponse}) =>
+    (Request request) async {
+      onRequest?.call(request);
+      final response = await handler(request);
+      onResponse?.call(response);
+      return response;
+    };
 
-  final Handler handler;
-  final Function(Request request)? onRequest;
-  final Function(Response response)? onResponse;
+/// CORS middleware
+Handler corsMiddleware(Handler handler) => (Request request) async {
+      final corsHeaders = {
+        'Access-Control-Allow-Origin': [request.headers.last('origin') ?? '*'],
+        'Access-Control-Expose-Headers': ['Location'],
+      };
 
-  @override
-  Future<Response> handle(Request request) async {
-    onRequest?.call(request);
-    final response = await handler.handle(request);
-    onResponse?.call(response);
-    return response;
-  }
-}
+      if (request.method == 'options') {
+        const methods = ['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS'];
+        return Response(
+            204,
+            Body(),
+            Headers.from({
+              ...corsHeaders,
+              'Access-Control-Allow-Methods':
+                  request.headers['Access-Control-Request-Method'] ?? methods,
+              'Access-Control-Allow-Headers':
+                  request.headers['Access-Control-Request-Headers'] ?? ['*'],
+            }));
+      }
+      final response = await handler(request);
+      response.headers.addAll(corsHeaders);
+      return response;
+    };
 
 extension HeaderExt on Headers {
   String? last(String key) {
     final v = this[key];
-    if (v != null && v.isNotEmpty) {
-      return v.last;
-    }
-    return null;
+    return (v != null && v.isNotEmpty) ? v.last : null;
   }
 }
diff --git a/lib/server.dart b/lib/server.dart
index 08f79a38..1cf7a97e 100644
--- a/lib/server.dart
+++ b/lib/server.dart
@@ -10,4 +10,4 @@ export 'package:json_api/src/server/errors/relationship_not_found.dart';
 export 'package:json_api/src/server/errors/resource_not_found.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/try_catch_handler.dart';
+export 'package:json_api/src/server/try_catch_middleware.dart';
diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart
index 405267e2..c5b1f0f3 100644
--- a/lib/src/client/client.dart
+++ b/lib/src/client/client.dart
@@ -1,7 +1,7 @@
 import 'dart:convert';
 
 import 'package:http_interop/extensions.dart';
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/http.dart';
 import 'package:json_api/src/client/payload_codec.dart';
 import 'package:json_api/src/client/request.dart';
@@ -17,14 +17,14 @@ class Client {
   const Client(this._handler, {PayloadCodec codec = const PayloadCodec()})
       : _codec = codec;
 
-  final http.Handler _handler;
+  final i.Handler _handler;
   final PayloadCodec _codec;
 
   /// Sends the [request] to the given [uri].
   Future<Response> send(Uri uri, Request request) async {
     final json = await _encode(request.document);
-    final body = http.Body.text(json, utf8);
-    final headers = http.Headers.from({
+    final body = i.Body.text(json, utf8);
+    final headers = i.Headers.from({
       'Accept': [mediaType],
       if (json.isNotEmpty) 'Content-Type': [mediaType],
       ...request.headers
@@ -33,7 +33,7 @@ class Client {
         ? uri
         : uri.replace(queryParameters: request.query.toQuery());
     final response =
-        await _handler.handle(http.Request(request.method, url, body, headers));
+        await _handler(i.Request(request.method, url, body, headers));
 
     final document = await _decode(response);
     return Response(response, document);
@@ -42,7 +42,7 @@ class Client {
   Future<String> _encode(Object? doc) async =>
       doc == null ? '' : await _codec.encode(doc);
 
-  Future<Map?> _decode(http.Response response) async {
+  Future<Map?> _decode(i.Response response) async {
     final json = await response.body.decode(utf8);
     if (json.isNotEmpty &&
         response.headers
diff --git a/lib/src/query/sort.dart b/lib/src/query/sort.dart
index e75f6313..47fbad66 100644
--- a/lib/src/query/sort.dart
+++ b/lib/src/query/sort.dart
@@ -12,13 +12,13 @@ class Sort with IterableMixin<SortField> implements QueryEncodable {
   /// Sort(['-created', 'title']);
   /// ```
   Sort([Iterable<String> fields = const []]) {
-    _.addAll(fields.map((SortField.parse)));
+    _fields.addAll(fields.map((SortField.parse)));
   }
 
-  static Sort fromUri(Uri uri) =>
-      Sort((uri.queryParametersAll['sort']?.expand((_) => _.split(',')) ?? []));
+  static Sort fromUri(Uri uri) => Sort(
+      (uri.queryParametersAll['sort']?.expand((it) => it.split(',')) ?? []));
 
-  final _ = <SortField>[];
+  final _fields = <SortField>[];
 
   /// Converts to a map of query parameters
   @override
@@ -27,10 +27,10 @@ class Sort with IterableMixin<SortField> implements QueryEncodable {
       };
 
   @override
-  int get length => _.length;
+  int get length => _fields.length;
 
   @override
-  Iterator<SortField> get iterator => _.iterator;
+  Iterator<SortField> get iterator => _fields.iterator;
 }
 
 abstract class SortField {
diff --git a/lib/src/server/controller_router.dart b/lib/src/server/controller_router.dart
index e10d5195..4171d1f5 100644
--- a/lib/src/server/controller_router.dart
+++ b/lib/src/server/controller_router.dart
@@ -9,13 +9,12 @@ import 'package:json_api/src/server/errors/unacceptable.dart';
 import 'package:json_api/src/server/errors/unmatched_target.dart';
 import 'package:json_api/src/server/errors/unsupported_media_type.dart';
 
-class ControllerRouter implements Handler {
+class ControllerRouter {
   ControllerRouter(this._controller, this._matchTarget);
 
   final Controller _controller;
   final Target? Function(Uri uri) _matchTarget;
 
-  @override
   Future<Response> handle(Request request) async {
     _validate(request);
     final target = _matchTarget(request.uri);
diff --git a/lib/src/server/error_converter.dart b/lib/src/server/error_converter.dart
index b1d91262..63004082 100644
--- a/lib/src/server/error_converter.dart
+++ b/lib/src/server/error_converter.dart
@@ -34,25 +34,25 @@ class ErrorConverter {
   Future<http.Response> call(Object? error, StackTrace trace) async =>
       switch (error) {
         MethodNotAllowed() =>
-          await onMethodNotAllowed?.call(error) ?? Response.methodNotAllowed(),
+          await onMethodNotAllowed?.call(error) ?? methodNotAllowed(),
         UnmatchedTarget() =>
-          await onUnmatchedTarget?.call(error) ?? Response.badRequest(),
+          await onUnmatchedTarget?.call(error) ?? badRequest(),
         CollectionNotFound() => await onCollectionNotFound?.call(error) ??
-            Response.notFound(OutboundErrorDocument([
+            notFound(OutboundErrorDocument([
               ErrorObject(
                 title: 'Collection Not Found',
                 detail: 'Type: ${error.type}',
               )
             ])),
         ResourceNotFound() => await onResourceNotFound?.call(error) ??
-            Response.notFound(OutboundErrorDocument([
+            notFound(OutboundErrorDocument([
               ErrorObject(
                 title: 'Resource Not Found',
                 detail: 'Type: ${error.type}, id: ${error.id}',
               )
             ])),
         RelationshipNotFound() => await onRelationshipNotFound?.call(error) ??
-            Response.notFound(OutboundErrorDocument([
+            notFound(OutboundErrorDocument([
               ErrorObject(
                 title: 'Relationship Not Found',
                 detail: 'Type: ${error.type}'
@@ -60,10 +60,10 @@ class ErrorConverter {
                     ', relationship: ${error.relationship}',
               )
             ])),
-        UnsupportedMediaType() => Response.unsupportedMediaType(),
-        Unacceptable() => Response.unacceptable(),
+        UnsupportedMediaType() => unsupportedMediaType(),
+        Unacceptable() => unacceptable(),
         _ => await onError?.call(error, trace) ??
-            Response(500,
+            response(500,
                 document: OutboundErrorDocument(
                     [ErrorObject(title: 'Internal Server Error')]))
       };
diff --git a/lib/src/server/response.dart b/lib/src/server/response.dart
index d8cabaaa..16109f9c 100644
--- a/lib/src/server/response.dart
+++ b/lib/src/server/response.dart
@@ -1,41 +1,38 @@
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/document.dart';
 import 'package:json_api/http.dart';
 import 'package:json_api/src/media_type.dart';
 
 /// JSON:API response
-class Response<D extends OutboundDocument> extends http.Response {
-  Response(int statusCode, {D? document})
-      : super(
-            statusCode,
-            document != null ? http.Body.json(document) : http.Body(),
-            http.Headers()) {
-    if (document != null) {
-      headers['Content-Type'] = [mediaType];
-    }
+Response response(int statusCode, {OutboundDocument? document}) {
+  final r = Response(
+      statusCode, document != null ? Body.json(document) : Body(), Headers());
+  if (document != null) {
+    r.headers['Content-Type'] = [mediaType];
   }
+  return r;
+}
 
-  static Response ok(OutboundDocument document) =>
-      Response(StatusCode.ok, document: document);
+Response ok(OutboundDocument document) =>
+    response(StatusCode.ok, document: document);
 
-  static Response noContent() => Response(StatusCode.noContent);
+Response noContent() => response(StatusCode.noContent);
 
-  static Response created(OutboundDocument document, String location) =>
-      Response(StatusCode.created, document: document)
-        ..headers['location'] = [location];
+Response created(OutboundDocument document, String location) =>
+    response(StatusCode.created, document: document)
+      ..headers['location'] = [location];
 
-  static Response notFound([OutboundErrorDocument? document]) =>
-      Response(StatusCode.notFound, document: document);
+Response notFound([OutboundErrorDocument? document]) =>
+    response(StatusCode.notFound, document: document);
 
-  static Response methodNotAllowed([OutboundErrorDocument? document]) =>
-      Response(StatusCode.methodNotAllowed, document: document);
+Response methodNotAllowed([OutboundErrorDocument? document]) =>
+    response(StatusCode.methodNotAllowed, document: document);
 
-  static Response badRequest([OutboundErrorDocument? document]) =>
-      Response(StatusCode.badRequest, document: document);
+Response badRequest([OutboundErrorDocument? document]) =>
+    response(StatusCode.badRequest, document: document);
 
-  static Response unsupportedMediaType([OutboundErrorDocument? document]) =>
-      Response(StatusCode.unsupportedMediaType, document: document);
+Response unsupportedMediaType([OutboundErrorDocument? document]) =>
+    response(StatusCode.unsupportedMediaType, document: document);
 
-  static Response unacceptable([OutboundErrorDocument? document]) =>
-      Response(StatusCode.unacceptable, document: document);
-}
+Response unacceptable([OutboundErrorDocument? document]) =>
+    response(StatusCode.unacceptable, document: document);
diff --git a/lib/src/server/try_catch_handler.dart b/lib/src/server/try_catch_handler.dart
deleted file mode 100644
index 312d1ea5..00000000
--- a/lib/src/server/try_catch_handler.dart
+++ /dev/null
@@ -1,26 +0,0 @@
-import 'package:http_interop/http_interop.dart';
-
-/// An [Handler] wrapper which calls the [wrapped] handler and does
-/// the following:
-/// - when an instance of [Response] is returned or thrown by the
-///   [wrapped] handler, the response is returned
-/// - when another error is thrown by the [wrapped] handler and
-///   the [onError] callback is set, the error will be converted to a response
-/// - otherwise the error will be rethrown.
-class TryCatchHandler implements Handler {
-  TryCatchHandler(this.wrapped, {this.onError});
-
-  final Handler wrapped;
-  final Future<Response> Function(dynamic, StackTrace)? onError;
-
-  @override
-  Future<Response> handle(Request request) async {
-    try {
-      return await wrapped.handle(request);
-    } on Response catch (response) {
-      return response;
-    } catch (error, stacktrace) {
-      return await onError?.call(error, stacktrace) ?? (throw error);
-    }
-  }
-}
diff --git a/lib/src/server/try_catch_middleware.dart b/lib/src/server/try_catch_middleware.dart
new file mode 100644
index 00000000..b9cba2b4
--- /dev/null
+++ b/lib/src/server/try_catch_middleware.dart
@@ -0,0 +1,21 @@
+import 'package:http_interop/http_interop.dart';
+
+/// An [Handler] wrapper which calls the wrapped [handler] and does
+/// the following:
+/// - when an instance of [Response] is returned or thrown by the
+///   wrapped handler, the response is returned
+/// - when another error is thrown by the wrapped handler and
+///   the [onError] callback is set, the error will be converted to a response
+/// - otherwise the error will be rethrown.
+
+Handler tryCatchMiddleware(Handler handler,
+        {Future<Response> Function(dynamic, StackTrace)? onError}) =>
+    (Request request) async {
+      try {
+        return await handler(request);
+      } on Response catch (response) {
+        return response;
+      } catch (error, stacktrace) {
+        return await onError?.call(error, stacktrace) ?? (throw error);
+      }
+    };
diff --git a/pubspec.yaml b/pubspec.yaml
index eab220f5..d378e2d2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,24 +1,24 @@
 name: json_api
-version: 7.0.1
+version: 8.0.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: '>=3.1.0 <4.0.0'
+  sdk: '>=3.4.0 <4.0.0'
 
 dependencies:
   http_parser: ^4.0.0
-  http_interop: ^1.0.0
+  http_interop: ^2.0.0
 
 dev_dependencies:
-  lints: ^3.0.0
+  lints: ^4.0.0
   test: ^1.21.1
   stream_channel: ^2.1.0
   uuid: ^4.2.0
   coverage: ^1.3.0
   check_coverage: ^0.0.4
-  http: ^1.1.0
-  http_interop_http: ^0.7.0
-  http_interop_io: ^0.7.0
+  http: ^1.2.1
+  http_interop_http: ^0.9.0
+  http_interop_io: ^0.9.0
 
 
 cider:
diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart
index 4d3e32b5..41520f55 100644
--- a/test/contract/crud_test.dart
+++ b/test/contract/crud_test.dart
@@ -10,7 +10,7 @@ void main() {
   late RoutingClient client;
 
   setUp(() async {
-    client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler()));
+    client = RoutingClient(StandardUriDesign.pathOnly, Client(testHandler()));
   });
 
   group('CRUD', () {
diff --git a/test/contract/errors_test.dart b/test/contract/errors_test.dart
index edccf8b7..96d10f02 100644
--- a/test/contract/errors_test.dart
+++ b/test/contract/errors_test.dart
@@ -8,7 +8,7 @@ void main() {
   late Client client;
 
   setUp(() async {
-    client = Client(TestHandler());
+    client = Client(testHandler());
   });
 
   group('Errors', () {
@@ -26,12 +26,12 @@ void main() {
       }
     });
     test('Bad request when target can not be matched', () async {
-      final r = await TestHandler().handle(http.Request(
+      final r = await testHandler()(http.Request(
           'get', Uri.parse('/a/long/prefix/'), http.Body(), http.Headers()));
       expect(r.statusCode, 400);
     });
     test('Unsupported extension', () async {
-      final r = await TestHandler().handle(http.Request(
+      final r = await testHandler()(http.Request(
           'get',
           Uri.parse('/posts/1'),
           http.Body(),
@@ -42,7 +42,7 @@ void main() {
       expect(r.statusCode, 415);
     });
     test('Unacceptable', () async {
-      final r = await TestHandler().handle(http.Request(
+      final r = await testHandler()(http.Request(
           'get',
           Uri.parse('/posts/1'),
           http.Body(),
diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart
index a48d2b9e..06b215b7 100644
--- a/test/contract/resource_creation_test.dart
+++ b/test/contract/resource_creation_test.dart
@@ -9,7 +9,7 @@ void main() {
   late RoutingClient client;
 
   setUp(() async {
-    client = RoutingClient(StandardUriDesign.pathOnly, Client(TestHandler()));
+    client = RoutingClient(StandardUriDesign.pathOnly, Client(testHandler()));
   });
 
   group('Resource creation', () {
diff --git a/test/e2e/browser_test.dart b/test/e2e/browser_test.dart
index 21de2efd..decb6977 100644
--- a/test/e2e/browser_test.dart
+++ b/test/e2e/browser_test.dart
@@ -1,9 +1,9 @@
-import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 import 'package:test/test.dart';
 
 import 'e2e_test_set.dart';
+import 'one_off_handler.dart';
 
 void main() {
   late RoutingClient client;
@@ -11,9 +11,8 @@ void main() {
     setUpAll(() async {
       final channel = spawnHybridUri('hybrid_server.dart');
       final serverUrl = await channel.stream.first;
-
       client = RoutingClient(StandardUriDesign(Uri.parse(serverUrl.toString())),
-          Client(OneOffHandler()));
+          Client(oneOffHandler));
     });
 
     testLocationIsSet(() => client);
diff --git a/test/e2e/hybrid_server.dart b/test/e2e/hybrid_server.dart
index ff363936..71121a38 100644
--- a/test/e2e/hybrid_server.dart
+++ b/test/e2e/hybrid_server.dart
@@ -3,10 +3,11 @@ import 'package:stream_channel/stream_channel.dart';
 import '../../example/server/json_api_server.dart';
 import '../test_handler.dart';
 
-void hybridMain(StreamChannel channel, Object message) async {
-  final host = 'localhost';
-  final port = 8000;
-  final server = JsonApiServer(TestHandler(), host: host, port: port);
+void hybridMain(StreamChannel channel) async {
+  const host = 'localhost';
+  const port = 8888;
+  final server = JsonApiServer(testHandler(onRequest: print, onResponse: print),
+      host: host, port: port);
   await server.start();
   channel.sink.add('http://$host:$port');
 }
diff --git a/test/e2e/one_off_handler.dart b/test/e2e/one_off_handler.dart
new file mode 100644
index 00000000..fe86e6d5
--- /dev/null
+++ b/test/e2e/one_off_handler.dart
@@ -0,0 +1,10 @@
+import 'package:http/http.dart' as h;
+import 'package:http_interop/http_interop.dart';
+import 'package:http_interop_http/http_interop_http.dart';
+
+Future<Response> oneOffHandler(Request request) async {
+  final client = h.Client();
+  final response = await client.handleInterop(request);
+  client.close();
+  return response;
+}
diff --git a/test/e2e/vm_test.dart b/test/e2e/vm_test.dart
index d644f359..50d1e128 100644
--- a/test/e2e/vm_test.dart
+++ b/test/e2e/vm_test.dart
@@ -1,4 +1,3 @@
-import 'package:http_interop_http/http_interop_http.dart';
 import 'package:json_api/client.dart';
 import 'package:json_api/routing.dart';
 import 'package:test/test.dart';
@@ -6,6 +5,7 @@ import 'package:test/test.dart';
 import '../../example/server/json_api_server.dart';
 import '../test_handler.dart';
 import 'e2e_test_set.dart';
+import 'one_off_handler.dart';
 
 void main() {
   late RoutingClient client;
@@ -13,12 +13,12 @@ void main() {
 
   group('On VM', () {
     setUpAll(() async {
-      server = JsonApiServer(TestHandler(), port: 8001);
+      server = JsonApiServer(testHandler(), port: 8001);
       await server.start();
       client = RoutingClient(
           StandardUriDesign(
               Uri(scheme: 'http', host: server.host, port: server.port)),
-          Client(OneOffHandler()));
+          Client(oneOffHandler));
     });
 
     tearDownAll(() async {
diff --git a/test/test_handler.dart b/test/test_handler.dart
index 3b29817a..e9177b12 100644
--- a/test/test_handler.dart
+++ b/test/test_handler.dart
@@ -1,4 +1,4 @@
-import 'package:http_interop/http_interop.dart' as http;
+import 'package:http_interop/http_interop.dart';
 import 'package:json_api/http.dart';
 import 'package:json_api/routing.dart';
 import 'package:json_api/server.dart';
@@ -6,27 +6,26 @@ import 'package:json_api/server.dart';
 import '../example/server/in_memory_repo.dart';
 import '../example/server/repository_controller.dart';
 
-class TestHandler extends LoggingHandler {
-  TestHandler(
-      {Iterable<String> types = const ['users', 'posts', 'comments'],
-      Function(http.Request request)? onRequest,
-      Function(http.Response response)? onResponse,
-      Future<http.Response> Function(dynamic, StackTrace)? onError})
-      : super(
-            TryCatchHandler(
-                ControllerRouter(RepositoryController(InMemoryRepo(types), _id),
-                    StandardUriDesign.matchTarget),
-                onError: ErrorConverter(
-                        onError: onError ??
-                            (err, trace) {
-                              print(trace);
-                              throw err;
-                            })
-                    .call),
-            onRequest: onRequest,
-            onResponse: onResponse);
-}
+Handler testHandler(
+        {Iterable<String> types = const ['users', 'posts', 'comments'],
+        Function(Request request)? onRequest,
+        Function(Response response)? onResponse,
+        Future<Response> Function(dynamic, StackTrace)? onError}) =>
+    loggingMiddleware(
+        corsMiddleware(tryCatchMiddleware(
+            ControllerRouter(
+                    RepositoryController(
+                        InMemoryRepo(types), () => (_counter++).toString()),
+                    StandardUriDesign.matchTarget)
+                .handle,
+            onError: ErrorConverter(
+                    onError: onError ??
+                        (err, trace) {
+                          print(trace);
+                          throw err;
+                        })
+                .call)),
+        onRequest: onRequest,
+        onResponse: onResponse);
 
 int _counter = 0;
-
-String _id() => (_counter++).toString();
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index c617682f..eaf38e06 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -11,12 +11,13 @@ import 'mock_handler.dart';
 import 'response.dart' as mock;
 
 void main() {
-  final http = MockHandler();
-  final client = RoutingClient(StandardUriDesign.pathOnly, Client(http));
+  final mockHandler = MockHandler();
+  final client =
+      RoutingClient(StandardUriDesign.pathOnly, Client(mockHandler.handle));
 
   group('Failure', () {
     test('RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client.fetchCollection('articles');
         fail('Exception expected');
@@ -27,7 +28,7 @@ void main() {
       }
     });
     test('ServerError', () async {
-      http.response = mock.error500();
+      mockHandler.response = mock.error500();
       try {
         await client.fetchCollection('articles');
         fail('Exception expected');
@@ -39,21 +40,21 @@ void main() {
 
   group('Fetch Collection', () {
     test('Min', () async {
-      http.response = mock.collectionMin();
+      mockHandler.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, equals('get'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
   });
 
   test('Full', () async {
-    http.response = mock.collectionFull();
+    mockHandler.response = mock.collectionFull();
     final response = await client.fetchCollection('articles', headers: {
       'foo': ['bar']
     }, query: [
@@ -70,16 +71,16 @@ void main() {
 
     expect(response.collection.length, 1);
     expect(response.included.length, 3);
-    expect(http.request.method, equals('get'));
-    expect(http.request.uri.path, '/articles');
-    expect(http.request.uri.queryParameters, {
+    expect(mockHandler.request.method, equals('get'));
+    expect(mockHandler.request.uri.path, '/articles');
+    expect(mockHandler.request.uri.queryParameters, {
       'include': 'author',
       'fields[author]': 'name',
       'sort': 'title,-date',
       'page[limit]': '10',
       'foo': 'bar'
     });
-    expect(http.request.headers, {
+    expect(mockHandler.request.headers, {
       'Accept': ['application/vnd.api+json'],
       'foo': ['bar']
     });
@@ -89,19 +90,19 @@ void main() {
 
   group('Fetch Related Collection', () {
     test('Min', () async {
-      http.response = mock.collectionFull();
+      mockHandler.response = mock.collectionFull();
       final response =
           await client.fetchRelatedCollection('people', '1', 'articles');
       expect(response.collection.length, 1);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.path, '/people/1/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.path, '/people/1/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
 
     test('Full', () async {
-      http.response = mock.collectionFull();
+      mockHandler.response = mock.collectionFull();
       final response = await client
           .fetchRelatedCollection('people', '1', 'articles', headers: {
         'foo': ['bar']
@@ -119,16 +120,16 @@ void main() {
 
       expect(response.collection.length, 1);
       expect(response.included.length, 3);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.path, '/people/1/articles');
-      expect(http.request.uri.queryParameters, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.path, '/people/1/articles');
+      expect(mockHandler.request.uri.queryParameters, {
         'include': 'author',
         'fields[author]': 'name',
         'sort': 'title,-date',
         'page[limit]': '10',
         'foo': 'bar'
       });
-      expect(http.request.headers, {
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'foo': ['bar']
       });
@@ -139,18 +140,18 @@ void main() {
 
   group('Fetch Primary Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.fetchResource('articles', '1');
       expect(response.resource.type, 'articles');
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.toString(), '/articles/1');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.toString(), '/articles/1');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.fetchResource('articles', '1', headers: {
         'foo': ['bar']
       }, query: [
@@ -164,11 +165,11 @@ void main() {
       ]);
       expect(response.resource.type, 'articles');
       expect(response.included.length, 3);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.path, '/articles/1');
-      expect(http.request.uri.queryParameters,
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.path, '/articles/1');
+      expect(mockHandler.request.uri.queryParameters,
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
-      expect(http.request.headers, {
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'foo': ['bar']
       });
@@ -179,20 +180,20 @@ void main() {
 
   group('Fetch Related Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource();
+      mockHandler.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, equals('get'));
-      expect(http.request.uri.toString(), '/articles/1/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.toString(), '/articles/1/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client
           .fetchRelatedResource('articles', '1', 'author', headers: {
         'foo': ['bar']
@@ -207,11 +208,11 @@ void main() {
       ]);
       expect(response.resource?.type, 'articles');
       expect(response.included.length, 3);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.path, '/articles/1/author');
-      expect(http.request.uri.queryParameters,
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.path, '/articles/1/author');
+      expect(mockHandler.request.uri.queryParameters,
           {'include': 'author', 'fields[author]': 'name', 'foo': 'bar'});
-      expect(http.request.headers, {
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'foo': ['bar']
       });
@@ -220,14 +221,14 @@ void main() {
     });
 
     test('Missing resource', () async {
-      http.response = mock.relatedResourceNull();
+      mockHandler.response = mock.relatedResourceNull();
       final response =
           await client.fetchRelatedResource('articles', '1', 'author');
       expect(response.resource, isNull);
       expect(response.included, isEmpty);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.toString(), '/articles/1/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.toString(), '/articles/1/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
@@ -235,18 +236,19 @@ void main() {
 
   group('Fetch Relationship', () {
     test('Min', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       final response = await client.fetchToOne('articles', '1', 'author');
       expect(response.included.length, 3);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.toString(),
+          '/articles/1/relationships/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json']
       });
     });
 
     test('Full', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       final response =
           await client.fetchToOne('articles', '1', 'author', headers: {
         'foo': ['bar']
@@ -256,10 +258,10 @@ void main() {
         })
       ]);
       expect(response.included.length, 3);
-      expect(http.request.method, equals('get'));
-      expect(http.request.uri.path, '/articles/1/relationships/author');
-      expect(http.request.uri.queryParameters, {'foo': 'bar'});
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('get'));
+      expect(mockHandler.request.uri.path, '/articles/1/relationships/author');
+      expect(mockHandler.request.uri.queryParameters, {'foo': 'bar'});
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'foo': ['bar']
       });
@@ -268,25 +270,25 @@ void main() {
 
   group('Create New Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource();
+      mockHandler.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, equals('post'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'articles'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.createNew('articles', attributes: {
         'cool': true
       }, one: {
@@ -304,14 +306,14 @@ void main() {
       expect(
           response.links['self'].toString(), 'http://example.com/articles/1');
       expect(response.included.length, 3);
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'attributes': {'cool': true},
@@ -341,37 +343,37 @@ void main() {
 
   group('Create Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.create('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Min with 204 No Content', () async {
-      http.response = mock.noContent();
+      mockHandler.response = mock.noContent();
       final response = await client.create('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.create('articles', '1', attributes: {
         'cool': true
       }, one: {
@@ -386,14 +388,14 @@ void main() {
         'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(mockHandler.request.uri.toString(), '/articles');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'id': '1',
@@ -424,37 +426,37 @@ void main() {
 
   group('Update Resource', () {
     test('Min', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response = await client.updateResource('articles', '1');
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(), '/articles/1');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Min with 204 No Content', () async {
-      http.response = mock.noContent();
+      mockHandler.response = mock.noContent();
       final response = await client.updateResource('articles', '1');
       expect(response.resource, isNull);
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(), '/articles/1');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'articles', 'id': '1'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.primaryResource();
+      mockHandler.response = mock.primaryResource();
       final response =
           await client.updateResource('articles', '1', attributes: {
         'cool': true
@@ -470,14 +472,14 @@ void main() {
         'foo': ['bar']
       });
       expect(response.resource?.type, 'articles');
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(), '/articles/1');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {
           'type': 'articles',
           'id': '1',
@@ -508,23 +510,24 @@ void main() {
 
   group('Replace One', () {
     test('Min', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'));
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(),
+          '/articles/1/relationships/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'people', 'id': '42'}
       });
     });
 
     test('Full', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       final response = await client.replaceToOne(
           'articles', '1', 'author', Identifier('people', '42'),
           meta: {
@@ -534,21 +537,22 @@ void main() {
             'foo': ['bar']
           });
       expect(response.relationship, isA<ToOne>());
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(),
+          '/articles/1/relationships/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': {'type': 'people', 'id': '42'},
         'meta': {'hello': 'world'}
       });
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client.replaceToOne(
             'articles', '1', 'author', Identifier('people', '42'));
@@ -560,7 +564,7 @@ void main() {
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       expect(
           () => client.replaceToOne(
               'articles', '1', 'author', Identifier('people', '42')),
@@ -570,39 +574,43 @@ void main() {
 
   group('Delete One', () {
     test('Min', () async {
-      http.response = mock.oneEmpty();
+      mockHandler.response = mock.oneEmpty();
       final response = await client.deleteToOne('articles', '1', 'author');
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(),
+          '/articles/1/relationships/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null});
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)),
+          {'data': null});
     });
 
     test('Full', () async {
-      http.response = mock.oneEmpty();
+      mockHandler.response = mock.oneEmpty();
       final response =
           await client.deleteToOne('articles', '1', 'author', headers: {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToOne>());
       expect(response.relationship!.identifier, isNull);
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/author');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(mockHandler.request.uri.toString(),
+          '/articles/1/relationships/author');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {'data': null});
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)),
+          {'data': null});
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client.deleteToOne('articles', '1', 'author');
         fail('Exception expected');
@@ -613,7 +621,7 @@ void main() {
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       expect(() => client.deleteToOne('articles', '1', 'author'),
           throwsFormatException);
     });
@@ -621,17 +629,18 @@ void main() {
 
   group('Delete Many', () {
     test('Min', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client
           .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('delete'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('delete'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -639,7 +648,7 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client.deleteFromMany('articles', '1', 'tags', [
         Identifier('tags', '1')
       ], meta: {
@@ -648,14 +657,15 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('delete'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('delete'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -664,7 +674,7 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client
             .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]);
@@ -676,7 +686,7 @@ void main() {
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       expect(
           () => client.deleteFromMany(
               'articles', '1', 'tags', [Identifier('tags', '1')]),
@@ -686,17 +696,18 @@ void main() {
 
   group('Replace Many', () {
     test('Min', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client
           .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -704,7 +715,7 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client.replaceToMany('articles', '1', 'tags', [
         Identifier('tags', '1')
       ], meta: {
@@ -713,14 +724,15 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('patch'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('patch'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -729,7 +741,7 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client
             .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]);
@@ -741,7 +753,7 @@ void main() {
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       expect(
           () => client.replaceToMany(
               'articles', '1', 'tags', [Identifier('tags', '1')]),
@@ -751,17 +763,18 @@ void main() {
 
   group('Add Many', () {
     test('Min', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client
           .addMany('articles', '1', 'tags', [Identifier('tags', '1')]);
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ]
@@ -769,7 +782,7 @@ void main() {
     });
 
     test('Full', () async {
-      http.response = mock.many();
+      mockHandler.response = mock.many();
       final response = await client.addMany('articles', '1', 'tags', [
         Identifier('tags', '1')
       ], meta: {
@@ -778,14 +791,15 @@ void main() {
         'foo': ['bar']
       });
       expect(response.relationship, isA<ToMany>());
-      expect(http.request.method, equals('post'));
-      expect(http.request.uri.toString(), '/articles/1/relationships/tags');
-      expect(http.request.headers, {
+      expect(mockHandler.request.method, equals('post'));
+      expect(
+          mockHandler.request.uri.toString(), '/articles/1/relationships/tags');
+      expect(mockHandler.request.headers, {
         'Accept': ['application/vnd.api+json'],
         'Content-Type': ['application/vnd.api+json'],
         'foo': ['bar']
       });
-      expect(jsonDecode(await http.request.body.decode(utf8)), {
+      expect(jsonDecode(await mockHandler.request.body.decode(utf8)), {
         'data': [
           {'type': 'tags', 'id': '1'}
         ],
@@ -794,7 +808,7 @@ void main() {
     });
 
     test('Throws RequestFailure', () async {
-      http.response = mock.error422();
+      mockHandler.response = mock.error422();
       try {
         await client
             .addMany('articles', '1', 'tags', [Identifier('tags', '1')]);
@@ -807,7 +821,7 @@ void main() {
     });
 
     test('Throws FormatException', () async {
-      http.response = mock.one();
+      mockHandler.response = mock.one();
       expect(
           () => client
               .addMany('articles', '1', 'tags', [Identifier('tags', '1')]),
diff --git a/test/unit/client/mock_handler.dart b/test/unit/client/mock_handler.dart
index d25b8a75..a0ad79e8 100644
--- a/test/unit/client/mock_handler.dart
+++ b/test/unit/client/mock_handler.dart
@@ -1,10 +1,9 @@
 import 'package:http_interop/http_interop.dart';
 
-class MockHandler implements Handler {
+class MockHandler {
   late Request request;
   late Response response;
 
-  @override
   Future<Response> handle(Request request) async {
     this.request = request;
     return response;
diff --git a/test/unit/client/response.dart b/test/unit/client/response.dart
index 1a646036..a5569c8c 100644
--- a/test/unit/client/response.dart
+++ b/test/unit/client/response.dart
@@ -1,5 +1,4 @@
 import 'package:http_interop/http_interop.dart';
-import 'package:json_api/http.dart';
 import 'package:json_api/src/media_type.dart';
 
 final headers = Headers.from({
@@ -8,7 +7,7 @@ final headers = Headers.from({
 
 collectionMin() => Response(
     200,
-    Json({
+    Body.json({
       'data': [
         {'type': 'articles', 'id': '1'}
       ]
@@ -17,7 +16,7 @@ collectionMin() => Response(
 
 collectionFull() => Response(
     200,
-    Json({
+    Body.json({
       'links': {
         'self': 'http://example.com/articles',
         'next': 'http://example.com/articles?page[offset]=2',
@@ -90,7 +89,7 @@ collectionFull() => Response(
 
 primaryResource() => Response(
     200,
-    Json({
+    Body.json({
       'links': {'self': 'http://example.com/articles/1'},
       'meta': {'hello': 'world'},
       'data': {
@@ -142,7 +141,7 @@ primaryResource() => Response(
 
 relatedResourceNull() => Response(
     200,
-    Json({
+    Body.json({
       'links': {'self': 'http://example.com/articles/1/author'},
       'meta': {'hello': 'world'},
       'data': null
@@ -151,7 +150,7 @@ relatedResourceNull() => Response(
 
 one() => Response(
     200,
-    Json({
+    Body.json({
       'links': {
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
@@ -197,7 +196,7 @@ one() => Response(
 
 oneEmpty() => Response(
     200,
-    Json({
+    Body.json({
       'links': {
         'self': '/articles/1/relationships/author',
         'related': '/articles/1/author'
@@ -243,7 +242,7 @@ oneEmpty() => Response(
 
 many() => Response(
     200,
-    Json({
+    Body.json({
       'links': {
         'self': '/articles/1/relationships/tags',
         'related': '/articles/1/tags'
@@ -259,7 +258,7 @@ noContent() => Response(204, Body(), Headers());
 
 error422() => Response(
     422,
-    Json({
+    Body.json({
       'meta': {'hello': 'world'},
       'errors': [
         {
diff --git a/test/unit/http/cors_middleware_test.dart b/test/unit/http/cors_middleware_test.dart
new file mode 100644
index 00000000..6dc2939e
--- /dev/null
+++ b/test/unit/http/cors_middleware_test.dart
@@ -0,0 +1,65 @@
+import 'dart:convert';
+
+import 'package:http_interop/http_interop.dart';
+import 'package:json_api/http.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+main() {
+  Response response = Response(200, Body.text('hello', utf8), Headers());
+
+  Future<Response> handler(Request rq) async => response;
+
+  group('CORS Middleware', () {
+    test('Sets the headers', () async {
+      final rq = Request(
+          'get',
+          Uri(host: 'localhost'),
+          Body(),
+          Headers.from({
+            'origin': ['foo']
+          }));
+      final rs = await corsMiddleware(handler)(rq);
+      expect(rs.statusCode, equals(200));
+      expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo']));
+      expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location']));
+    });
+
+    test('Responds to OPTIONS', () async {
+      final rq = Request(
+          'options',
+          Uri(host: 'localhost'),
+          Body(),
+          Headers.from({
+            'origin': ['foo']
+          }));
+      final rs = await corsMiddleware(handler)(rq);
+      expect(rs.statusCode, equals(204));
+      expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo']));
+      expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location']));
+      expect(rs.headers['Access-Control-Allow-Methods'],
+          equals(['POST', 'GET', 'DELETE', 'PATCH', 'OPTIONS']));
+      expect(rs.headers['Access-Control-Allow-Headers'], equals(['*']));
+    });
+
+    test('Responds to OPTIONS with custom headers', () async {
+      final rq = Request(
+          'options',
+          Uri(host: 'localhost'),
+          Body(),
+          Headers.from({
+            'origin': ['foo'],
+            'Access-Control-Request-Method': ['PUT', 'POST'],
+            'Access-Control-Request-Headers': ['foo', 'bar'],
+          }));
+      final rs = await corsMiddleware(handler)(rq);
+      expect(rs.statusCode, equals(204));
+      expect(rs.headers['Access-Control-Allow-Origin'], equals(['foo']));
+      expect(rs.headers['Access-Control-Expose-Headers'], equals(['Location']));
+      expect(
+          rs.headers['Access-Control-Allow-Methods'], equals(['PUT', 'POST']));
+      expect(
+          rs.headers['Access-Control-Allow-Headers'], equals(['foo', 'bar']));
+    });
+  });
+}
diff --git a/test/unit/http/logging_middleware_test.dart b/test/unit/http/logging_middleware_test.dart
new file mode 100644
index 00000000..ca1faaf5
--- /dev/null
+++ b/test/unit/http/logging_middleware_test.dart
@@ -0,0 +1,26 @@
+import 'dart:convert';
+
+import 'package:http_interop/http_interop.dart';
+import 'package:json_api/http.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+main() {
+  Response response = Response(200, Body.text('hello', utf8), Headers());
+
+  Future<Response> handler(Request rq) async => response;
+
+  group('Logging Middleware', () {
+    test('Can log', () async {
+      Request? loggedRq;
+      Response? loggedRs;
+
+      final request = Request('get', Uri(host: 'localhost'), Body(), Headers());
+      final response = await loggingMiddleware(handler,
+          onRequest: (r) => loggedRq = r,
+          onResponse: (r) => loggedRs = r)(request);
+      expect(loggedRq, same(request));
+      expect(loggedRs, same(response));
+    });
+  });
+}

From 3aeecb628e34e079f305f44156cd3699f9a1923a Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Thu, 29 Aug 2024 20:21:56 -0700
Subject: [PATCH 24/25] Add rawResponse (#144)

---
 .../client/response/collection_fetched.dart   |  17 +-
 .../response/related_resource_fetched.dart    |  21 +-
 .../client/response/relationship_fetched.dart |  33 ++-
 .../client/response/relationship_updated.dart |  30 +-
 lib/src/client/response/request_failure.dart  |  25 +-
 lib/src/client/response/resource_created.dart |  26 +-
 lib/src/client/response/resource_fetched.dart |  27 +-
 lib/src/client/response/resource_updated.dart |  28 +-
 lib/src/client/routing_client.dart            | 258 ++++++++----------
 test/contract/crud_test.dart                  |   4 +-
 test/contract/resource_creation_test.dart     |  14 +-
 test/e2e/e2e_test_set.dart                    |   2 +-
 test/unit/client/client_test.dart             |  14 +-
 test/unit/client/typed_responses_test.dart    |  45 +++
 14 files changed, 317 insertions(+), 227 deletions(-)
 create mode 100644 test/unit/client/typed_responses_test.dart

diff --git a/lib/src/client/response/collection_fetched.dart b/lib/src/client/response/collection_fetched.dart
index d643258b..f77b7093 100644
--- a/lib/src/client/response/collection_fetched.dart
+++ b/lib/src/client/response/collection_fetched.dart
@@ -1,16 +1,25 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 class CollectionFetched {
-  CollectionFetched(this.httpResponse, Map json) {
-    final document = InboundDocument(json);
+  CollectionFetched(this.rawResponse) {
+    final document = InboundDocument(rawResponse.document ??
+        (throw FormatException('The document must not be empty')));
     collection.addAll(document.dataAsCollection());
     included.addAll(document.included());
     meta.addAll(document.meta());
     links.addAll(document.links());
   }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// The resource collection fetched from the server
   final collection = <Resource>[];
diff --git a/lib/src/client/response/related_resource_fetched.dart b/lib/src/client/response/related_resource_fetched.dart
index 39a71a73..ab3cd609 100644
--- a/lib/src/client/response/related_resource_fetched.dart
+++ b/lib/src/client/response/related_resource_fetched.dart
@@ -1,22 +1,31 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 /// A related resource response.
 ///
 /// https://jsonapi.org/format/#fetching-resources-responses
 class RelatedResourceFetched {
-  RelatedResourceFetched(this.httpResponse, Map json)
-      : resource = InboundDocument(json).dataAsResourceOrNull() {
-    final document = InboundDocument(json);
+  RelatedResourceFetched(this.rawResponse) {
+    final document = InboundDocument(rawResponse.document ??
+        (throw FormatException('The document must not be empty')));
+    resource = document.dataAsResourceOrNull();
     included.addAll(document.included());
     meta.addAll(document.meta());
     links.addAll(document.links());
   }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// Related resource. May be null
-  final Resource? resource;
+  late final Resource? resource;
 
   /// Included resources
   final included = <Resource>[];
diff --git a/lib/src/client/response/relationship_fetched.dart b/lib/src/client/response/relationship_fetched.dart
index a99c78eb..889070f6 100644
--- a/lib/src/client/response/relationship_fetched.dart
+++ b/lib/src/client/response/relationship_fetched.dart
@@ -1,19 +1,34 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 /// A response to a relationship fetch request.
 class RelationshipFetched<R extends Relationship> {
-  RelationshipFetched(this.httpResponse, this.relationship);
+  RelationshipFetched(this.rawResponse, this.relationship);
 
-  static RelationshipFetched<ToMany> many(Response httpResponse, Map json) =>
-      RelationshipFetched(httpResponse, InboundDocument(json).asToMany())
-        ..included.addAll(InboundDocument(json).included());
+  static RelationshipFetched<ToMany> many(Response response) {
+    final document = InboundDocument(response.document ??
+        (throw FormatException('The document must not be empty')));
+    return RelationshipFetched(response, document.asToMany())
+      ..included.addAll(document.included());
+  }
 
-  static RelationshipFetched<ToOne> one(Response httpResponse, Map json) =>
-      RelationshipFetched(httpResponse, InboundDocument(json).asToOne())
-        ..included.addAll(InboundDocument(json).included());
+  static RelationshipFetched<ToOne> one(Response response) {
+    final document = InboundDocument(response.document ??
+        (throw FormatException('The document must not be empty')));
+    return RelationshipFetched(response, document.asToOne())
+      ..included.addAll(document.included());
+  }
+
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
-  final Response httpResponse;
   final R relationship;
   final included = <Resource>[];
 }
diff --git a/lib/src/client/response/relationship_updated.dart b/lib/src/client/response/relationship_updated.dart
index 2adb7421..9a885d8c 100644
--- a/lib/src/client/response/relationship_updated.dart
+++ b/lib/src/client/response/relationship_updated.dart
@@ -1,19 +1,31 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 /// A response to a relationship request.
 class RelationshipUpdated<R extends Relationship> {
-  RelationshipUpdated(this.httpResponse, this.relationship);
+  RelationshipUpdated(this.rawResponse, this.relationship);
 
-  static RelationshipUpdated<ToMany> many(Response httpResponse, Map? json) =>
-      RelationshipUpdated(
-          httpResponse, json == null ? null : InboundDocument(json).asToMany());
+  static RelationshipUpdated<ToMany> many(Response response) {
+    final json = response.document;
+    return RelationshipUpdated(
+        response, json == null ? null : InboundDocument(json).asToMany());
+  }
 
-  static RelationshipUpdated<ToOne> one(Response httpResponse, Map? json) =>
-      RelationshipUpdated(
-          httpResponse, json == null ? null : InboundDocument(json).asToOne());
+  static RelationshipUpdated<ToOne> one(Response response) {
+    final json = response.document;
+    return RelationshipUpdated(
+        response, json == null ? null : InboundDocument(json).asToOne());
+  }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// 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
index 096a84ca..1f69695d 100644
--- a/lib/src/client/response/request_failure.dart
+++ b/lib/src/client/response/request_failure.dart
@@ -1,16 +1,25 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 /// Thrown when the server returns a non-successful response.
 class RequestFailure implements Exception {
-  RequestFailure(this.httpResponse, Map? document) {
-    if (document != null) {
-      errors.addAll(InboundDocument(document).errors());
-      meta.addAll(InboundDocument(document).meta());
-    }
+  RequestFailure(this.rawResponse) {
+    final json = rawResponse.document;
+    if (json == null) return;
+    final document = InboundDocument(json);
+    errors.addAll(document.errors());
+    meta.addAll(document.meta());
   }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// Error objects returned by the server
   final errors = <ErrorObject>[];
@@ -20,5 +29,5 @@ class RequestFailure implements Exception {
 
   @override
   String toString() =>
-      'JSON:API request failed with HTTP status ${httpResponse.statusCode}.';
+      'JSON:API request failed with HTTP status ${rawResponse.httpResponse.statusCode}.';
 }
diff --git a/lib/src/client/response/resource_created.dart b/lib/src/client/response/resource_created.dart
index 3b157810..d67a8ec3 100644
--- a/lib/src/client/response/resource_created.dart
+++ b/lib/src/client/response/resource_created.dart
@@ -1,22 +1,32 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.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.httpResponse, Map json)
-      : resource = InboundDocument(json).dataAsResource() {
-    meta.addAll(InboundDocument(json).meta());
-    links.addAll(InboundDocument(json).links());
-    included.addAll(InboundDocument(json).included());
+  ResourceCreated(this.rawResponse) {
+    final document = InboundDocument(rawResponse.document ??
+        (throw FormatException('The document must not be empty')));
+    resource = document.dataAsResource();
+    included.addAll(document.included());
+    meta.addAll(document.meta());
+    links.addAll(document.links());
   }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// Created resource.
-  final Resource resource;
+  late final Resource resource;
 
   /// Top-level meta data
   final meta = <String, Object?>{};
diff --git a/lib/src/client/response/resource_fetched.dart b/lib/src/client/response/resource_fetched.dart
index 3e628298..2b694466 100644
--- a/lib/src/client/response/resource_fetched.dart
+++ b/lib/src/client/response/resource_fetched.dart
@@ -1,17 +1,28 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 /// A response to fetch a primary resource request
 class ResourceFetched {
-  ResourceFetched(this.httpResponse, Map json)
-      : resource = InboundDocument(json).dataAsResource() {
-    included.addAll(InboundDocument(json).included());
-    meta.addAll(InboundDocument(json).meta());
-    links.addAll(InboundDocument(json).links());
+  ResourceFetched(this.rawResponse) {
+    final document = InboundDocument(rawResponse.document ??
+        (throw FormatException('The document must not be empty')));
+    resource = document.dataAsResource();
+    included.addAll(document.included());
+    meta.addAll(document.meta());
+    links.addAll(document.links());
   }
 
-  final Response httpResponse;
-  final Resource resource;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
+
+  late final Resource resource;
 
   /// Top-level meta data
   final meta = <String, Object?>{};
diff --git a/lib/src/client/response/resource_updated.dart b/lib/src/client/response/resource_updated.dart
index ca9b9198..381ebe90 100644
--- a/lib/src/client/response/resource_updated.dart
+++ b/lib/src/client/response/resource_updated.dart
@@ -1,26 +1,34 @@
-import 'package:http_interop/http_interop.dart';
+import 'package:http_interop/http_interop.dart' as i;
 import 'package:json_api/document.dart';
+import 'package:json_api/src/client/response.dart';
 
 class ResourceUpdated {
-  ResourceUpdated(this.httpResponse, Map? json) : resource = _resource(json) {
-    if (json != null) {
-      included.addAll(InboundDocument(json).included());
-      meta.addAll(InboundDocument(json).meta());
-      links.addAll(InboundDocument(json).links());
+  ResourceUpdated(this.rawResponse)
+      : resource = _resource(rawResponse.document) {
+    final document = rawResponse.document;
+    if (document != null) {
+      included.addAll(InboundDocument(document).included());
+      meta.addAll(InboundDocument(document).meta());
+      links.addAll(InboundDocument(document).links());
     }
   }
 
   static Resource? _resource(Map? json) {
     if (json != null) {
       final doc = InboundDocument(json);
-      if (doc.hasData) {
-        return doc.dataAsResource();
-      }
+      if (doc.hasData) return doc.dataAsResource();
     }
     return null;
   }
 
-  final Response httpResponse;
+  // coverage:ignore-start
+  /// The raw HTTP response
+  @Deprecated('Use rawResponse.httpResponse instead')
+  i.Response get httpResponse => rawResponse.httpResponse;
+  // coverage:ignore-end
+
+  /// The raw JSON:API response
+  final Response rawResponse;
 
   /// 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 9a250d07..7a11e244 100644
--- a/lib/src/client/routing_client.dart
+++ b/lib/src/client/routing_client.dart
@@ -32,14 +32,12 @@ class RoutingClient {
     List<Identifier> identifiers, {
     Map<String, Object?> meta = const {},
     Map<String, List<String>> headers = const {},
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.post(
-            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
-          ..headers.addAll(headers));
-    return RelationshipUpdated.many(response.httpResponse, response.document);
-  }
+  }) async =>
+      RelationshipUpdated.many(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.post(
+              OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
+            ..headers.addAll(headers)));
 
   /// Creates a new resource with the given [type] and [id] on the server.
   ///
@@ -61,21 +59,19 @@ class RoutingClient {
     Map<String, Object?> documentMeta = const {},
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.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))
-          ..meta.addAll(documentMeta))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return ResourceUpdated(response.httpResponse, response.document);
-  }
+  }) async =>
+      ResourceUpdated(await send(
+          _baseUri.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))
+            ..meta.addAll(documentMeta))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Creates a new resource in the collection of type [type].
   /// The server is responsible for assigning the resource id.
@@ -99,24 +95,20 @@ class RoutingClient {
     Map<String, Object?> documentMeta = const {},
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.collection(type),
-        Request.post(
-            OutboundDataDocument.newResource(NewResource(type, lid: lid)
-              ..attributes.addAll(attributes)
-              ..relationships.addAll({
-                ...one.map((key, value) => MapEntry(key, NewToOne(value))),
-                ...many.map((key, value) => MapEntry(key, NewToMany(value))),
-              })
-              ..meta.addAll(meta))
-              ..meta.addAll(documentMeta))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-
-    return ResourceCreated(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      ResourceCreated(await send(
+          _baseUri.collection(type),
+          Request.post(
+              OutboundDataDocument.newResource(NewResource(type, lid: lid)
+                ..attributes.addAll(attributes)
+                ..relationships.addAll({
+                  ...one.map((key, value) => MapEntry(key, NewToOne(value))),
+                  ...many.map((key, value) => MapEntry(key, NewToMany(value))),
+                })
+                ..meta.addAll(meta))
+                ..meta.addAll(documentMeta))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Deletes the [identifiers] from the to-many relationship
   /// identified by [type], [id], [relationship].
@@ -131,15 +123,12 @@ class RoutingClient {
     List<Identifier> identifiers, {
     Map<String, Object?> meta = const {},
     Map<String, List<String>> headers = const {},
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.delete(
-            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
-          ..headers.addAll(headers));
-
-    return RelationshipUpdated.many(response.httpResponse, response.document);
-  }
+  }) async =>
+      RelationshipUpdated.many(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.delete(
+              OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
+            ..headers.addAll(headers)));
 
   /// Fetches the primary collection of type [type].
   ///
@@ -150,15 +139,12 @@ class RoutingClient {
     String type, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.collection(type),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return CollectionFetched(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      CollectionFetched(await send(
+          _baseUri.collection(type),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Fetches the related resource collection
   /// identified by [type], [id], [relationship].
@@ -172,15 +158,12 @@ class RoutingClient {
     String relationship, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.related(type, id, relationship),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return CollectionFetched(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      CollectionFetched(await send(
+          _baseUri.related(type, id, relationship),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Fetches the to-one relationship
   /// identified by [type], [id], [relationship].
@@ -194,15 +177,12 @@ class RoutingClient {
     String relationship, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelationshipFetched.one(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      RelationshipFetched.one(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Fetches the to-many relationship
   /// identified by [type], [id], [relationship].
@@ -216,15 +196,12 @@ class RoutingClient {
     String relationship, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelationshipFetched.many(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      RelationshipFetched.many(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Fetches the related resource
   /// identified by [type], [id], [relationship].
@@ -238,15 +215,12 @@ class RoutingClient {
     String relationship, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.related(type, id, relationship),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelatedResourceFetched(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      RelatedResourceFetched(await send(
+          _baseUri.related(type, id, relationship),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Fetches the resource identified by [type] and [id].
   ///
@@ -258,16 +232,12 @@ class RoutingClient {
     String id, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.resource(type, id),
-        Request.get()
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-
-    return ResourceFetched(
-        response.httpResponse, response.document ?? (throw FormatException()));
-  }
+  }) async =>
+      ResourceFetched(await send(
+          _baseUri.resource(type, id),
+          Request.get()
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Updates the resource identified by [type] and [id].
   ///
@@ -289,21 +259,19 @@ class RoutingClient {
     Map<String, Object?> documentMeta = const {},
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.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))
-          ..meta.addAll(documentMeta))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return ResourceUpdated(response.httpResponse, response.document);
-  }
+  }) async =>
+      ResourceUpdated(await send(
+          _baseUri.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))
+            ..meta.addAll(documentMeta))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Replaces the to-one relationship
   /// identified by [type], [id], and [relationship] by setting
@@ -321,15 +289,13 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.patch(
-            OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta)))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelationshipUpdated.one(response.httpResponse, response.document);
-  }
+  }) async =>
+      RelationshipUpdated.one(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.patch(
+              OutboundDataDocument.one(ToOne(identifier)..meta.addAll(meta)))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Replaces the to-many relationship
   /// identified by [type], [id], and [relationship] by setting
@@ -347,15 +313,13 @@ class RoutingClient {
     Map<String, Object?> meta = const {},
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.patch(
-            OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelationshipUpdated.many(response.httpResponse, response.document);
-  }
+  }) async =>
+      RelationshipUpdated.many(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.patch(
+              OutboundDataDocument.many(ToMany(identifiers)..meta.addAll(meta)))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Removes the to-one relationship
   /// identified by [type], [id], and [relationship]..
@@ -369,14 +333,12 @@ class RoutingClient {
     String relationship, {
     Map<String, List<String>> headers = const {},
     Iterable<QueryEncodable> query = const [],
-  }) async {
-    final response = await send(
-        _baseUri.relationship(type, id, relationship),
-        Request.patch(OutboundDataDocument.one(ToOne.empty()))
-          ..headers.addAll(headers)
-          ..query.mergeAll(query));
-    return RelationshipUpdated.one(response.httpResponse, response.document);
-  }
+  }) async =>
+      RelationshipUpdated.one(await send(
+          _baseUri.relationship(type, id, relationship),
+          Request.patch(OutboundDataDocument.one(ToOne.empty()))
+            ..headers.addAll(headers)
+            ..query.mergeAll(query)));
 
   /// Deletes the resource identified by [type] and [id].
   ///
@@ -399,9 +361,7 @@ class RoutingClient {
   /// This method can be used to send any non-standard requests.
   Future<Response> send(Uri uri, Request request) async {
     final response = await _client.send(uri, request);
-    if (response.isFailed) {
-      throw RequestFailure(response.httpResponse, response.document);
-    }
+    if (response.isFailed) throw RequestFailure(response);
     return response;
   }
 }
diff --git a/test/contract/crud_test.dart b/test/contract/crud_test.dart
index 41520f55..e2af2547 100644
--- a/test/contract/crud_test.dart
+++ b/test/contract/crud_test.dart
@@ -47,7 +47,7 @@ void main() {
         Include(['author', 'comments', 'comments.author'])
       ]);
 
-      expect(response.httpResponse.statusCode, 200);
+      expect(response.rawResponse.httpResponse.statusCode, 200);
       expect(response.collection.length, 1);
       expect(response.included.length, 3);
 
@@ -175,7 +175,7 @@ void main() {
           await action();
           fail('Exception expected');
         } on RequestFailure catch (e) {
-          expect(e.httpResponse.statusCode, 404);
+          expect(e.rawResponse.httpResponse.statusCode, 404);
         }
       }
     });
diff --git a/test/contract/resource_creation_test.dart b/test/contract/resource_creation_test.dart
index 06b215b7..c29991a4 100644
--- a/test/contract/resource_creation_test.dart
+++ b/test/contract/resource_creation_test.dart
@@ -16,8 +16,9 @@ void main() {
     test('Resource id assigned on the server', () async {
       await client
           .createNew('posts', attributes: {'title': 'Hello world'}).then((r) {
-        expect(r.httpResponse.statusCode, 201);
-        expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']);
+        expect(r.rawResponse.httpResponse.statusCode, 201);
+        expect(r.rawResponse.httpResponse.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');
@@ -30,8 +31,9 @@ void main() {
           lid: 'lid',
           attributes: {'title': 'Hello world'},
           one: {'self': LocalIdentifier('posts', 'lid')}).then((r) {
-        expect(r.httpResponse.statusCode, 201);
-        expect(r.httpResponse.headers['location'], ['/posts/${r.resource.id}']);
+        expect(r.rawResponse.httpResponse.statusCode, 201);
+        expect(r.rawResponse.httpResponse.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');
@@ -42,9 +44,9 @@ void main() {
     test('Resource id assigned on the client', () async {
       await client.create('posts', '12345',
           attributes: {'title': 'Hello world'}).then((r) {
-        expect(r.httpResponse.statusCode, 204);
+        expect(r.rawResponse.httpResponse.statusCode, 204);
         expect(r.resource, isNull);
-        expect(r.httpResponse.headers['location'], isNull);
+        expect(r.rawResponse.httpResponse.headers['location'], isNull);
       });
     });
   });
diff --git a/test/e2e/e2e_test_set.dart b/test/e2e/e2e_test_set.dart
index 4d7a3fd3..6dc67ec4 100644
--- a/test/e2e/e2e_test_set.dart
+++ b/test/e2e/e2e_test_set.dart
@@ -30,7 +30,7 @@ void testLocationIsSet(RoutingClient Function() client) {
   test('Location is set', () async {
     final r = await client()
         .createNew('posts', attributes: {'title': 'Location test'});
-    expect(r.httpResponse.headers['Location'], isNotEmpty);
+    expect(r.rawResponse.httpResponse.headers['Location'], isNotEmpty);
     await client().deleteResource('posts', r.resource.id);
   });
 }
diff --git a/test/unit/client/client_test.dart b/test/unit/client/client_test.dart
index eaf38e06..1ce542a5 100644
--- a/test/unit/client/client_test.dart
+++ b/test/unit/client/client_test.dart
@@ -22,7 +22,7 @@ void main() {
         await client.fetchCollection('articles');
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
         expect(e.errors.first.title, 'Invalid Attribute');
       }
@@ -33,7 +33,7 @@ void main() {
         await client.fetchCollection('articles');
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 500);
+        expect(e.rawResponse.httpResponse.statusCode, 500);
       }
     });
   });
@@ -558,7 +558,7 @@ void main() {
             'articles', '1', 'author', Identifier('people', '42'));
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
@@ -615,7 +615,7 @@ void main() {
         await client.deleteToOne('articles', '1', 'author');
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
@@ -680,7 +680,7 @@ void main() {
             .deleteFromMany('articles', '1', 'tags', [Identifier('tags', '1')]);
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
@@ -747,7 +747,7 @@ void main() {
             .replaceToMany('articles', '1', 'tags', [Identifier('tags', '1')]);
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
       }
     });
@@ -814,7 +814,7 @@ void main() {
             .addMany('articles', '1', 'tags', [Identifier('tags', '1')]);
         fail('Exception expected');
       } on RequestFailure catch (e) {
-        expect(e.httpResponse.statusCode, 422);
+        expect(e.rawResponse.httpResponse.statusCode, 422);
         expect(e.errors.first.status, '422');
         expect(e.toString(), contains('422'));
       }
diff --git a/test/unit/client/typed_responses_test.dart b/test/unit/client/typed_responses_test.dart
new file mode 100644
index 00000000..85525cd3
--- /dev/null
+++ b/test/unit/client/typed_responses_test.dart
@@ -0,0 +1,45 @@
+import 'package:http_interop/http_interop.dart' as i;
+import 'package:json_api/client.dart';
+import 'package:json_api/src/client/response.dart';
+import 'package:test/expect.dart';
+import 'package:test/scaffolding.dart';
+
+void main() {
+  final emptyResponse = Response(i.Response(200, i.Body(), i.Headers()), null);
+  group('CollectionFetched', () {
+    test('throws on empty body', () {
+      expect(() => CollectionFetched(emptyResponse), throwsFormatException);
+    });
+  });
+
+  group('RelatedResourceFetched', () {
+    test('throws on empty body', () {
+      expect(
+          () => RelatedResourceFetched(emptyResponse), throwsFormatException);
+    });
+  });
+
+  group('RelationshipFetched', () {
+    test('.many() throws on empty body', () {
+      expect(
+          () => RelationshipFetched.many(emptyResponse), throwsFormatException);
+    });
+
+    test('.one() throws on empty body', () {
+      expect(
+          () => RelationshipFetched.one(emptyResponse), throwsFormatException);
+    });
+  });
+
+  group('ResourceCreated', () {
+    test('throws on empty body', () {
+      expect(() => ResourceCreated(emptyResponse), throwsFormatException);
+    });
+  });
+
+  group('ResourceFetched', () {
+    test('throws on empty body', () {
+      expect(() => ResourceFetched(emptyResponse), throwsFormatException);
+    });
+  });
+}

From f338772d89459c8cf06759a14ce199bacf622c0d Mon Sep 17 00:00:00 2001
From: Alexey <f3ath@users.noreply.github.com>
Date: Thu, 29 Aug 2024 20:34:51 -0700
Subject: [PATCH 25/25] 8.1 (#145)

---
 CHANGELOG.md | 8 ++++++++
 pubspec.yaml | 2 +-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42e78d19..fbc3aae2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
 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).
 
+## [8.1.0] - 2024-08-29
+### Added
+- The rawResponse property to the responses of the RoutingClient
+
+### Deprecated
+- The httpResponse property of the responses of the RoutingClient
+
 ## [8.0.0] - 2024-07-01
 ### Added
 - CORS middware
@@ -257,6 +264,7 @@ the Document model.
 ### Added
 - Client: fetch resources, collections, related resources and relationships
 
+[8.1.0]: https://github.com/f3ath/json-api-dart/compare/8.0.0...8.1.0
 [8.0.0]: https://github.com/f3ath/json-api-dart/compare/7.0.1...8.0.0
 [7.0.1]: https://github.com/f3ath/json-api-dart/compare/7.0.0...7.0.1
 [7.0.0]: https://github.com/f3ath/json-api-dart/compare/6.0.1...7.0.0
diff --git a/pubspec.yaml b/pubspec.yaml
index d378e2d2..75619d36 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: json_api
-version: 8.0.0
+version: 8.1.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:
