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 1/7] 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 38a2c18..f8c2d35 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 302bb29..f882fd3 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 06f7d06..9094870 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 1a7df5a..a9b2387 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 9293f90..57dccb9 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 e42f2ef..c419a0a 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 9f973cd..0000000
--- 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 d9a625f..0871d21 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 defdb7e..1d8510d 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 2cd63ed..e838353 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 5e4100d..c1f9dd9 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 a9a0240..0000000
--- 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 9f4f0ef..034266e 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 03bac6c..2618a54 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 f7c2930..0cd26e6 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 1265a94..335eb69 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 f3913a8..08f79a3 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 be1a50d..083f05d 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 54cf3aa..e3ed74b 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 b6d5007..0000000
--- 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 e9fa7c2..d2f30bf 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 8c51622..20732c1 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 99c94e4..d643258 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 a6fb4ad..39a71a7 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 fa6ff0e..a99c78e 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 e596d92..2adb742 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 924c729..096a84c 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 7cd3069..3b15781 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 8cb4496..3e62829 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 1a8b67a..ca9b919 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 f4c03c6..9a250d0 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 76a0c13..0000000
--- 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 9a16a54..0000000
--- 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 82f1122..962052a 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 93605b1..0000000
--- 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 0000000..1c0653d
--- /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 0000000..f985ea0
--- /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 b8ee227..b111ab7 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 0000000..7d8da3d
--- /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 0000000..aefcd46
--- /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 99680bd..0000000
--- 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 a74d431..78192f6 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 2eecb4a..3526ffc 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 1b775dc..b9ca5f7 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 95533d8..0000000
--- 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 ef522fb..0000000
--- 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 0000000..902b1ff
--- /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 0000000..ae61979
--- /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 6259810..0000000
--- 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 c59ca8d..0000000
--- 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 f6dfb1e..baea320 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 05d99b1..0291e86 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 4338740..0000000
--- 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 8b25bdb..8d26b23 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 d4f441e..e00ceea 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 0000000..f4356e4
--- /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 0000000..055cead
--- /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 85200cc..e75f631 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 9759d0b..6936003 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 0000000..3f45e37
--- /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 0000000..b1d9126
--- /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 0000000..c6110e1
--- /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 0000000..9a2ae1d
--- /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 0000000..6848d9f
--- /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 0000000..ac6d685
--- /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 0000000..8fde40a
--- /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 90d092e..b15084c 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 aeb29b7..0000000
--- 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 0000000..312d1ea
--- /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 18d2116..a3a3cde 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 a2a2177..4d3e32b 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 d399bcb..db60cff 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 e1b0990..a48d2b9 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 a33c45e..21de2ef 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 04f63be..4d7a3fd 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 578430f..ff36393 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 1c3d98f..d644f35 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 0000000..d26214e
--- /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 c01b518..6fe5120 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 67605d1..0000000
--- 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 be0b862..0000000
--- 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 9dfe2ff..d25b8a7 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 ef1057a..a4dde40 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 a89e5f0..ce1c81e 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 f3d826a..1e0cd9d 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 5631f3a..7886a66 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 96eaaff..0000000
--- 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 2708c98..965c631 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 5c8f028..0000000
--- 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 ef91bcc..7f4eef5 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 5b878e8..facc6d7 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 effa6e7..2591013 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 0000000..b2fbc4b
--- /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 2/7] 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 f882fd3..0707da2 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 2618a54..04efafb 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 a3a3cde..f93f095 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 3/7] 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 f8c2d35..0297fc1 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 0000000..d79d8c8
--- /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 0707da2..679d663 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 57dccb9..bea19e0 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 c419a0a..073ee0d 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 0cd26e6..5a190c6 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 083f05d..405267e 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 d2f30bf..0046727 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 962052a..4571c69 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 f985ea0..65de471 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 b111ab7..3cf4be7 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 27d1382..2b474f6 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 3f45e37..08f25a6 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 b15084c..d8cabaa 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 f93f095..d47cfb3 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 db60cff..edccf8b 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 d26214e..3b29817 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 6fe5120..c617682 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 ce1c81e..1a64603 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 0123e38..d5b4c22 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 0000000..504d442
--- /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 1e0cd9d..eba069e 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 0000000..6853d3a
--- /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 c7d6cb0..9653ac3 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 4/7] 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 679d663..4dac7f2 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 bea19e0..9235e38 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 0291e86..a09bab7 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 8d26b23..838cfc3 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 08f25a6..e10d519 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 d47cfb3..eab220f 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 5/7] 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 4dac7f2..42e78d1 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 9094870..75a866a 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 a9b2387..e2e6f67 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 9235e38..a8173b4 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 073ee0d..0000000
--- 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 1d8510d..9430821 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 c1f9dd9..cd40607 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 5a190c6..9a3ced9 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 08f79a3..1cf7a97 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 405267e..c5b1f0f 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 e75f631..47fbad6 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 e10d519..4171d1f 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 b1d9126..6300408 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 d8cabaa..16109f9 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 312d1ea..0000000
--- 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 0000000..b9cba2b
--- /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 eab220f..d378e2d 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 4d3e32b..41520f5 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 edccf8b..96d10f0 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 a48d2b9..06b215b 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 21de2ef..decb697 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 ff36393..71121a3 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 0000000..fe86e6d
--- /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 d644f35..50d1e12 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 3b29817..e9177b1 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 c617682..eaf38e0 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 d25b8a7..a0ad79e 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 1a64603..a5569c8 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 0000000..6dc2939
--- /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 0000000..ca1faaf
--- /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 6/7] 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 d643258..f77b709 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 39a71a7..ab3cd60 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 a99c78e..889070f 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 2adb742..9a885d8 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 096a84c..1f69695 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 3b15781..d67a8ec 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 3e62829..2b69446 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 ca9b919..381ebe9 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 9a250d0..7a11e24 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 41520f5..e2af254 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 06b215b..c29991a 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 4d7a3fd..6dc67ec 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 eaf38e0..1ce542a 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 0000000..85525cd
--- /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 7/7] 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 42e78d1..fbc3aae 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 d378e2d..75619d3 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:
