diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index d81bbbf100b23db2735d4810e92ad3495e70a00c..149f681dcc7e6c4eeea3828d340ec152ff272021 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -5,8 +5,6 @@ namespace JsonApi;
 use JsonApi\Contracts\JsonApiPlugin;
 use JsonApi\Middlewares\Authentication;
 use JsonApi\Middlewares\DangerousRouteHandler;
-use JsonApi\Middlewares\JsonApi as JsonApiMiddleware;
-use JsonApi\Middlewares\StudipMockNavigation;
 use JsonApi\Routes\Holidays\HolidaysShow;
 use Slim\Routing\RouteCollectorProxy;
 
@@ -49,7 +47,6 @@ use Slim\Routing\RouteCollectorProxy;
  *
  *   $this->app->post('/article/{id}/comments', MeineRoute::class);
  *
- * @see \JsonApi\Middlewares\JsonApi
  * @see \JsonApi\Middlewares\Authentication
  * @see \JsonApi\Contracts\JsonApiPlugin
  * @see http://www.slimframework.com/docs/objects/router.html#how-to-create-routes
@@ -118,6 +115,7 @@ class RouteMap
         $group->get('/status-groups/{id}', Routes\StatusgroupShow::class);
 
         $this->addAuthenticatedBlubberRoutes($group);
+        $this->addAuthenticatedClipboardRoutes($group);
         $this->addAuthenticatedConsultationRoutes($group);
         $this->addAuthenticatedContactsRoutes($group);
         $this->addAuthenticatedCoursesRoutes($group);
@@ -205,6 +203,21 @@ class RouteMap
         );
     }
 
+    private function addAuthenticatedClipboardRoutes(RouteCollectorProxy $group): void
+    {
+        $group->post('/clipboards', Routes\Clipboards\ClipboardsCreate::class);
+        $group->patch('/clipboards/{id}', Routes\Clipboards\ClipboardsUpdate::class);
+        $group->delete('/clipboards/{id}', Routes\Clipboards\ClipboardsDelete::class);
+
+        $group->get('/clipboard-items/{id}', Routes\Clipboards\ClipboardItemsShow::class);
+        $group->post('/clipboards/{id}/items', Routes\Clipboards\ClipboardItemsCreate::class);
+        $group->delete('/clipboards/{id}/items', Routes\Clipboards\ClipboardItemsDelete::class);
+        $group->delete('/clipboards/{id}/items/{itemId}', Routes\Clipboards\ClipboardItemsDelete::class);
+
+        $group->post('/clipboard-items', Routes\Clipboards\ClipboardItemsCreate::class);
+        $group->delete('/clipboard-items/{id}', Routes\Clipboards\ClipboardItemsDelete::class);
+    }
+
     private function addAuthenticatedConsultationRoutes(RouteCollectorProxy $group): void
     {
         $group->get('/{type:courses|institutes|users}/{id}/consultations', Routes\Consultations\BlocksByRangeIndex::class);
diff --git a/lib/classes/JsonApi/Routes/Clipboards/Authority.php b/lib/classes/JsonApi/Routes/Clipboards/Authority.php
new file mode 100644
index 0000000000000000000000000000000000000000..5cc053a4563d0a2c1e37320cb50d8dc1b5bc5acc
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/Authority.php
@@ -0,0 +1,28 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use User;
+
+final class Authority
+{
+    public static function canCreateClipboard(User $user): bool
+    {
+        return true;
+    }
+
+    public static function canAccessClipboard(User $user, \Clipboard $clipboard): bool
+    {
+        return $user->id === $clipboard->user_id
+            || $user->perms === 'root';
+    }
+
+    public static function canUpdateClipboard(User $user, \Clipboard $clipboard): bool
+    {
+        return self::canAccessClipboard($user, $clipboard);
+    }
+
+    public static function canDeleteClipboard(User $user, \Clipboard $clipboard): bool
+    {
+        return self::canUpdateClipboard($user, $clipboard);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..d57d0c5f28c2d291c651fb97dc7755f91d65a33d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php
@@ -0,0 +1,106 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\Clipboard;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardItemsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $json = $this->validate($request, $args);
+
+        $clipboard_id = $args['id'] ?? $json['data']['relationships']['clipboard']['data']['id'];
+        $clipboard = \Clipboard::find($clipboard_id);
+
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateClipboard($user, $clipboard)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $range_id   = $json['data']['attributes']['range_id'];
+        $range_type = $json['data']['attributes']['range_type'];
+
+        $item = \ClipboardItem::findOneBySql(
+            'clipboard_id = ? AND range_id = ? AND range_type = ?',
+            [$clipboard_id, $range_id, $range_type]
+        );
+
+        if ($item) {
+            return $this->getCodeResponse(302, [
+                'Location' => $this->getLinkToItem($item),
+            ]);
+        }
+
+        $item = \ClipboardItem::create([
+            'clipboard_id' => $clipboard_id,
+            'range_id'     => $range_id,
+            'range_type'   => $range_type,
+        ]);
+
+        return $this->getContentResponse($item);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        $clipboardValidationError = $this->validateRequestContainsValidClipboard($json, $data);
+        if ($clipboardValidationError !== null) {
+            return $clipboardValidationError;
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.range_id')) {
+            return 'No range_id defined';
+        }
+
+        if (!self::arrayHas($json, 'data.attributes.range_type')) {
+            return 'No range_type defined';
+        }
+
+        $range_type = self::arrayGet($json, 'data.attributes.range_type');
+        if (!is_a($range_type, \StudipItem::class, true)) {
+            return 'Range type must implement interface StudipItem';
+        }
+
+        return null;
+    }
+
+    private function validateRequestContainsValidClipboard($json, $data): ?string
+    {
+        if (isset($data['id'])) {
+            if (!\Clipboard::exists($data['id'])) {
+                return 'Provided clipboard id is invalid';
+            }
+        } else {
+            if (!self::arrayHas($json, 'data.relationships.clipboard')) {
+                return 'No clipboard relationship defined';
+            }
+
+            $clipboard = self::arrayGet($json, 'data.relationships.clipboard');
+            if (
+                !isset($clipboard['data']['type'], $clipboard['data']['id'])
+                || $clipboard['data']['type'] !== Clipboard::TYPE
+            ) {
+                return 'Defined clipboard relationship has invalid format.';
+            }
+            if (!\Clipboard::exists($clipboard['data']['id'])) {
+                return 'Related clipboard does not exist.';
+            }
+        }
+
+        return null;
+    }
+
+    private function getLinkToItem(\ClipboardItem $item): string
+    {
+        $json = $this->encoder->encodeData($item);
+        return json_decode($json, true)['data']['links']['self'];
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..a9c7cd428ef49ac542951a421acab77e6e126359
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php
@@ -0,0 +1,54 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardItemsDelete extends JsonApiController
+{
+    protected $allowedFilteringParameters = ['range_id'];
+
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $clipboard = \Clipboard::find($args['id']);
+        if (!$clipboard) {
+            throw new RecordNotFoundException('Clipboard not found');
+        }
+
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateClipboard($user, $clipboard)) {
+            throw new \AccessDeniedException();
+        }
+
+        $item = null;
+        if (isset($args['itemId'])) {
+            $item = \ClipboardItem::find($args['itemId']);
+        } else {
+            $filtering = iterator_to_array($this->getQueryParameters()->getFilters());
+            if (!isset($filtering['range_id'])) {
+                throw new BadRequestException('No range_id filter given');
+            }
+            $item = \ClipboardItem::findOneBySQL(
+                'clipboard_id = ? AND range_id = ?',
+                [$clipboard->id, $filtering['range_id']]
+            );
+        }
+
+        if (!$item) {
+            throw new RecordNotFoundException('Item not found');
+        }
+
+        if ($item->clipboard_id !== $clipboard->id) {
+            throw new BadRequestException('Item does not belong to clipboard');
+        }
+
+        $item->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php
new file mode 100644
index 0000000000000000000000000000000000000000..3c91708a46f2b1663d4dab43be2ad9d1f6e1d63e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php
@@ -0,0 +1,28 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardItemsShow extends JsonApiController
+{
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $item = \ClipboardItem::find($args['id']);
+        if (!$item) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+        if (!Authority::canAccessClipboard($user, $item->clipboard)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($item);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..57fd9b90b259664be79b5846e3c6fe82d094fe34
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php
@@ -0,0 +1,46 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardsCreate extends JsonApiController
+{
+    use ValidationTrait;
+
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $user = $this->getUser($request);
+
+        if (!Authority::canCreateClipboard($user)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $json = $this->validate($request, $args);
+
+        $clipboard = \Clipboard::create([
+            'name'    => $json['data']['attributes']['name'],
+            'user_id' => $user->id,
+        ]);
+
+        return $this->getContentResponse($clipboard);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data.attributes.name')) {
+            return 'No name for the clipboard defined';
+        }
+
+        if (!trim(self::arrayGet($json, 'data.attributes.name'))) {
+            return 'Name of the clipboard may not be empty';
+        }
+
+        return null;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..0897843bc9bf40d5707fc7c13691c519693f703c
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php
@@ -0,0 +1,31 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardsDelete extends JsonApiController
+{
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $clipboard = \Clipboard::find($args['id']);
+        if (!$clipboard) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+
+        if (!Authority::canDeleteClipboard($user, $clipboard)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $clipboard->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..83d9539dbd3d8f26f6205f1d9a946726f2c8a318
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php
@@ -0,0 +1,50 @@
+<?php
+namespace JsonApi\Routes\Clipboards;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Psr\Http\Message\{
+    ResponseInterface as Response,
+    ServerRequestInterface as Request
+};
+
+final class ClipboardsUpdate extends JsonApiController
+{
+    use ValidationTrait;
+
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $clipboard = \Clipboard::find($args['id']);
+        if (!$clipboard) {
+            throw new RecordNotFoundException();
+        }
+
+        $user = $this->getUser($request);
+
+        if (!Authority::canUpdateClipboard($user, $clipboard)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $json = $this->validate($request, $args);
+
+        $clipboard->name = $json['data']['attributes']['name'];
+        $clipboard->store();
+
+        return $this->getContentResponse($clipboard);
+    }
+
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data.attributes.name')) {
+            return 'No name for the clipboard defined';
+        }
+
+        if (!trim(self::arrayGet($json, 'data.attributes.name'))) {
+            return 'Name of the clipboard may not be empty';
+        }
+
+        return null;
+    }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 97212bc65524bc7603199880c399811d81519b55..1498daf692583de42f5132670548f47c616d4915 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -2,6 +2,8 @@
 
 namespace JsonApi;
 
+use JsonApi\Schemas\Clipboard;
+
 /**
  * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  */
@@ -19,6 +21,8 @@ class SchemaMap
             \BlubberThread::class => Schemas\BlubberThread::class,
 
             \CalendarDateAssignment::class => Schemas\CalendarDateAssignment::class,
+            \Clipboard::class => Schemas\Clipboard::class,
+            \ClipboardItem::class => Schemas\ClipboardItem::class,
             \ConsultationBlock::class => Schemas\ConsultationBlock::class,
             \ConsultationBooking::class => Schemas\ConsultationBooking::class,
             \ConsultationSlot::class => Schemas\ConsultationSlot::class,
diff --git a/lib/classes/JsonApi/Schemas/Clipboard.php b/lib/classes/JsonApi/Schemas/Clipboard.php
new file mode 100644
index 0000000000000000000000000000000000000000..af90d73daf2f2195238359f5530632fbfbe0105f
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Clipboard.php
@@ -0,0 +1,81 @@
+<?php
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+final class Clipboard extends SchemaProvider
+{
+    public const TYPE = 'clipboards';
+    public const REL_USER = 'user';
+    public const REL_ITEMS = 'clipboard-items';
+
+    /**
+     * @param \Clipboard $resource
+     */
+    public function getId($resource): ?string
+    {
+        return (string) $resource->id;
+    }
+
+    /**
+     * @param \Clipboard $resource
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'name'              => $resource->name,
+            'handler'           => $resource->handler,
+            'allows_item_class' => $resource->allowed_item_class,
+            'mkdate'            => date('c', $resource->mkdate),
+            'chdate'            => date('c', $resource->chdate),
+        ];
+    }
+
+    /**
+     * @param \Clipboard $resource
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $isPrimary = $context->getPosition()->getLevel() === 0;
+        if ($isPrimary) {
+            $relationships = $this->getUserRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_USER));
+            $relationships = $this->getItemsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ITEMS));
+        }
+
+
+        return $relationships;
+    }
+
+    private function getUserRelationship(array $relationships, \Clipboard $clipboard, bool $includeData): array
+    {
+        $relationships[self::REL_USER] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($clipboard->user),
+            ],
+            self::RELATIONSHIP_DATA => $includeData ? $clipboard->user : \User::build(['id' => $clipboard->user_id], false),
+        ];
+
+        return $relationships;
+    }
+
+    private function getItemsRelationship(array $relationships, \Clipboard $clipboard, bool $includeData): array
+    {
+        if ($includeData) {
+            $relatedItems = $clipboard->items;
+        } else {
+            $relatedItems = $clipboard->items->map(fn($item) => \ClipboardItem::build(['id' => $item->id], false));
+        }
+
+        $relationships[self::REL_ITEMS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($clipboard, self::REL_ITEMS),
+            ],
+            self::RELATIONSHIP_DATA => $relatedItems,
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/ClipboardItem.php b/lib/classes/JsonApi/Schemas/ClipboardItem.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c848235eea99d9c4e39db5c10f52034ebc12cf7
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/ClipboardItem.php
@@ -0,0 +1,61 @@
+<?php
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+final class ClipboardItem extends SchemaProvider
+{
+    public const TYPE = 'clipboard-items';
+    public const REL_CLIPBOARD = 'clipboard';
+
+    /**
+     * @param \ClipboardItem $resource
+     */
+    public function getId($resource): ?string
+    {
+        return (string) $resource->id;
+    }
+
+    /**
+     * @param \ClipboardItem $resource
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'range_id'   => $resource->range_id,
+            'range_type' => $resource->range_type,
+            'name'       => $resource->name,
+            'mkdate'     => date('c', $resource->mkdate),
+            'chdate'     => date('c', $resource->chdate),
+        ];
+    }
+
+    /**
+     * @param \ClipboardItem $resource
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $isPrimary = $context->getPosition()->getLevel() === 0;
+        if ($isPrimary) {
+            $relationships = $this->getClipboardRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_CLIPBOARD));
+        }
+
+
+        return $relationships;
+    }
+
+    private function getClipboardRelationship(array $relationships, \ClipboardItem $clipboardItem, bool $includeData): array
+    {
+        $relationships[self::REL_CLIPBOARD] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($clipboardItem->clipboard),
+            ],
+            self::RELATIONSHIP_DATA => $includeData ? $clipboardItem->clipboard : \User::build(['id' => $clipboardItem->clipboard_id], false),
+        ];
+
+        return $relationships;
+    }
+}
diff --git a/lib/models/ClipboardItem.class.php b/lib/models/ClipboardItem.class.php
index 030388a8e601c2892b42d8c5746991899ef5dc96..888250f16e3c8e85490b73dbcbea503e370764ec 100644
--- a/lib/models/ClipboardItem.class.php
+++ b/lib/models/ClipboardItem.class.php
@@ -23,6 +23,8 @@
  * @property int $mkdate database column
  * @property int $chdate database column
  * @property Clipboard $clipboard belongs_to Clipboard
+ *
+ * @property-read string $name
  */
 class ClipboardItem extends SimpleORMap
 {
@@ -36,36 +38,32 @@ class ClipboardItem extends SimpleORMap
             'assoc_func' => 'find'
         ];
 
+        $config['additional_fields']['name'] = [
+            'get' => fn(ClipboardItem $item) => $item->__toString(),
+        ];
+
         parent::configure($config);
     }
 
-
     /**
      * @returns string representation of this clipboard item.
      */
     public function __toString()
     {
-        //Get the class $range_type and the object with ID $range_id,
-        //if $range_type is a StudipItem:
-
-        $use_generic_name = true;
-        $object = null;
-        if (is_subclass_of($this->range_type, 'StudipItem', true)) {
+        // Get the class $range_type and the object with ID $range_id,
+        // if $range_type is a StudipItem:
+        if (is_subclass_of($this->range_type, StudipItem::class)) {
             $range_class_name = $this->range_type;
             $object = $range_class_name::find($this->range_id);
             if ($object) {
-                $use_generic_name = false;
+                return $object->getItemName(false);
             }
         }
 
-        if ($use_generic_name) {
-            //$range_type is not a class name of a StudipItem class
-            //or no object of a StudipItem class could be found:
-            //We cannot determine the name and must therefore use
-            //a generic name:
-            return $this->range_type . '_' . $this->range_id;
-        } else {
-            return $object->getItemName(false);
-        }
+        // $range_type is not a class name of a StudipItem class
+        // or no object of a StudipItem class could be found:
+        // We cannot determine the name and must therefore use
+        // a generic name:
+        return $this->range_type . '_' . $this->range_id;
     }
 }
diff --git a/resources/assets/javascripts/bootstrap/clipboard.js b/resources/assets/javascripts/bootstrap/clipboard.js
index e525b35674483a4016a81f67485d9b6b9aef5d49..a64a605dfc7ad620ceec3519eabf23753d29e5ae 100644
--- a/resources/assets/javascripts/bootstrap/clipboard.js
+++ b/resources/assets/javascripts/bootstrap/clipboard.js
@@ -25,7 +25,9 @@ STUDIP.domReady(function () {
 
     jQuery(document).on('click', '.clipboard-remove-button', function (event) {
         event.preventDefault();
-        STUDIP.Dialog.confirm($(this).data('confirm-message'), function() {
+
+        const message = $(this).data('confirm-message');
+        STUDIP.Dialog.confirm(message).done(() => {
             STUDIP.Clipboard.handleRemoveClick(event.target);
         });
     });
@@ -62,10 +64,11 @@ STUDIP.domReady(function () {
         });
     });
 
-    jQuery(document).on('submit', '.clipboard-widget .new-clipboard-form', function (event) {
-        event.preventDefault();
-        STUDIP.Clipboard.handleAddForm(event);
-    });
+    jQuery(document).on(
+        'submit',
+        '.clipboard-widget .new-clipboard-form',
+        STUDIP.Clipboard.handleAddForm
+    );
 
     jQuery(document).on('click', '.clipboard-add-item-button', function (event) {
         event.preventDefault();
diff --git a/resources/assets/javascripts/lib/abstract-api.js b/resources/assets/javascripts/lib/abstract-api.js
index eafca85f1252783eaf55ada6340168116d958b83..cf9aed15a09ebd84018e5f58fc4b6739947b9684 100644
--- a/resources/assets/javascripts/lib/abstract-api.js
+++ b/resources/assets/javascripts/lib/abstract-api.js
@@ -52,6 +52,8 @@ class AbstractAPI
 
         var deferred;
 
+        const request = this.#createRequest(url, options);
+
         if (options.async && this.request_count > 0) {
             // Request should be sent asynchronous after every other request
             // is finished. The configuration for this particular request is
@@ -73,10 +75,10 @@ class AbstractAPI
             this.total_requests += 1;
 
             // Actual request
-            deferred = $.ajax(STUDIP.URLHelper.getURL(`${this.base_url}/${url}`, {}, true), {
+            deferred = $.ajax(request.url, {
                 contentType: options.contentType || 'application/x-www-form-urlencoded; charset=UTF-8',
                 method: options.method.toUpperCase(),
-                data: this.encodeData(options.data, options.method.toUpperCase()),
+                data: this.encodeData(request.data, options.method.toUpperCase()),
                 headers: options.headers
             }).always(() => {
                 // Decrease request counter, remove overlay if neccessary
@@ -93,6 +95,27 @@ class AbstractAPI
             }
         }).promise();
     }
+
+    #createRequest(url, options) {
+        const hasBody = ['post', 'put', 'patch'].includes(options.method.toLowerCase());
+        const query = hasBody ? '' : `?${this.convertDataToRequestParameters(options.data)}`;
+
+        return {
+            url: STUDIP.URLHelper.getURL(`${this.base_url}/${url}${query}`, {}, true),
+            data: hasBody ? options.data : {},
+        };
+    }
+
+    convertDataToRequestParameters(data, prefix = '') {
+        return Object.entries(data).map(([key, value]) => {
+            const name = prefix ? `${prefix}[${key}]` : `${key}`;
+            if (value.constructor.name === 'Object') {
+                return this.convertDataToRequestParameters(value, name);
+            } else {
+                return `${name}=${value}`;
+            }
+        }).join('&');
+    }
 }
 
 // Create shortcut methods for easier access by method
diff --git a/resources/assets/javascripts/lib/clipboard.js b/resources/assets/javascripts/lib/clipboard.js
index e5890abff4312f5711cdf26542b937209534ff95..8af31116ee0777d02e87bab3a9d6b7ef4ddb6a24 100644
--- a/resources/assets/javascripts/lib/clipboard.js
+++ b/resources/assets/javascripts/lib/clipboard.js
@@ -1,4 +1,14 @@
-import {$gettext} from './gettext';
+function extractAttribute(node, attribute) {
+    return node.querySelector(`input[name="${attribute}"]`)?.value.trim();
+}
+
+function extractAttributes(node, attributes) {
+    const result = {};
+    for (const key of attributes) {
+        result[key] = extractAttribute(node, key);
+    }
+    return result;
+}
 
 const Clipboard = {
     switchClipboard: function(event) {
@@ -32,32 +42,30 @@ const Clipboard = {
         }
     },
 
-    handleAddForm: function(event) {
-        if (!event) {
-            return false;
-        }
-
+    handleAddForm(event) {
+        event.preventDefault();
 
+        const attributes = extractAttributes(event.target, ['name', 'allowed_item_class']);
 
         //Check if a name is entered in the form:
-        let name_input = jQuery(event.target).find('input[type="text"][name="name"]');
+        const name_input = event.target.querySelector('input[name="name"]');
         if (!name_input) {
             //Something is wrong with the HTML:
             return false;
         }
-        let name = jQuery(name_input).val().trim();
-        if (!name) {
+        if (!attributes.name) {
             //The name field is empty. Why send an empty field?
             return false;
         }
 
-        //Submit the form via AJAX:
-        STUDIP.api.POST(
-            'clipboard/add',
-            {
-                data: jQuery(event.target).serialize()
-            }
-        ).done(STUDIP.Clipboard.add);
+        // Submit the form via AJAX:
+        STUDIP.jsonapi.POST('clipboards', {data: {data: {attributes}}}).done(({data}) => {
+            STUDIP.Clipboard.add({
+                id: data.id,
+                name: data.attributes.name,
+                widget_id: extractAttribute(event.target, 'widget_id')
+            });
+        });
     },
 
     add: function(data) {
@@ -134,11 +142,9 @@ const Clipboard = {
         jQuery(widget_node).find('#clipboard-group-container').removeClass('invisible');
 
         //Call the droppable jQuery method on the new clipboard area:
-        jQuery(clipboard_node).droppable(
-            {
-                drop: STUDIP.Clipboard.handleItemDrop
-            }
-        );
+        jQuery(clipboard_node).droppable({
+            drop: STUDIP.Clipboard.handleItemDrop
+        });
 
         //Clear the text input in the "add clipboard" form:
         jQuery(widget_node).find(
@@ -238,17 +244,19 @@ const Clipboard = {
         }
 
         //Add the item to the clipboard via AJAX:
-        STUDIP.api.POST(
-            'clipboard/' + clipboard_id + '/item',
-            {
+        STUDIP.jsonapi.POST(`clipboards/${clipboard_id}/items`, {
+            data: {
                 data: {
-                    'range_id': range_id,
-                    'range_type': range_type,
-                    'widget_id': widget_id
+                    attributes: { range_id, range_type }
                 }
             }
-        ).done(function(data) {
-            STUDIP.Clipboard.addDroppedItem(data);
+        }).done(({data}) => {
+            STUDIP.Clipboard.addDroppedItem({
+                id: data.id,
+                name: data.attributes.name,
+                range_id: data.attributes.range_id,
+                widget_id
+            });
         });
     },
 
@@ -263,6 +271,7 @@ const Clipboard = {
         let widget = jQuery('#ClipboardWidget_' + response_data['widget_id']);
         let clipboard_id = jQuery(widget).find(".clipboard-selector").val();
 
+
         if (!widget) {
             //The widget with the speicified widget-ID
             //is not present on the current page.
@@ -325,25 +334,16 @@ const Clipboard = {
         );
     },
 
-    rename: function(widget_id) {
-         if (!widget_id) {
-            //Required data are missing!
-            return;
-        }
+    rename(widget_id) {
+        const widget = jQuery('#ClipboardWidget_' + widget_id);
+        const clipboard_id = widget.find('.clipboard-selector').val();
+        const name = widget.find('input.clipboard-name').val();
 
-        let widget = jQuery('#ClipboardWidget_' + widget_id);
-        let clipboard_id = jQuery(widget).find(".clipboard-selector").val();
-        let namer = jQuery(widget).find("input.clipboard-name");
-
-        STUDIP.api.PUT(
-            'clipboard/' + clipboard_id,
-            {
-                data: {
-                    name: namer.val()
-                }
-            }
-        ).done(function(data) {
-            STUDIP.Clipboard.update(data, widget_id)
+        STUDIP.jsonapi.PATCH(`clipboards/${clipboard_id}`, {data: {data: {attributes: {name}}}}).done(({data}) => {
+            STUDIP.Clipboard.update({
+                id: data.id,
+                name: data.attributes.name,
+            }, widget_id)
         });
     },
 
@@ -358,7 +358,7 @@ const Clipboard = {
         STUDIP.Clipboard.toggleEditButtons(widget_id);
     },
 
-    remove: function(clipboard_id, widget_id) {
+    remove(clipboard_id, widget_id) {
         if (!clipboard_id || !widget_id) {
             //Required data are missing!
             return;
@@ -427,10 +427,6 @@ const Clipboard = {
     },
 
     handleRemoveClick: function(delete_icon) {
-        if (!delete_icon) {
-            return;
-        }
-
         //Get the data of the clipboard:
         let clipboard_select = jQuery(delete_icon).siblings('.clipboard-selector')[0];
         if (!clipboard_select) {
@@ -444,52 +440,42 @@ const Clipboard = {
             //Another case where something is wrong with the HTML.
             return;
         }
-        let widget_id = jQuery(widget).data('widget_id');
 
-        STUDIP.api.DELETE(
-            'clipboard/' + clipboard_id,
-            {
-                data: {
-                    widget_id: widget_id
-                }
-            }
-        ).done(function() {
+        const widget_id = jQuery(widget).data('widget_id');
+
+        STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}`).done(() => {
             STUDIP.Clipboard.remove(clipboard_id, widget_id);
         });
     },
 
-    removeItem: function(delete_icon) {
-        if (!delete_icon) {
-            return;
-        }
-
-        //Get the item-ID:
-        let item_html = jQuery(delete_icon).parents('tr');
-        let range_id = jQuery(item_html).data('range_id');
-        let clipboard_element = jQuery(item_html).parents('table');
-        let clipboard_id = jQuery(clipboard_element).data('id');
+    removeItem(delete_icon) {
+        // Get the item-ID:
+        const item_element = jQuery(delete_icon).parents('tr');
+        const range_id = jQuery(item_element).data('range_id');
+        const clipboard_element = jQuery(item_element).parents('table');
+        const clipboard_id = jQuery(clipboard_element).data('id');
 
         if (!range_id || !clipboard_id) {
             //We cannot proceed without the item-ID and the clipboard-ID!
             return;
         }
 
-        STUDIP.api.DELETE(
-            'clipboard/' + clipboard_id + '/item/' + range_id
-        ).done(function() {
+        STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}/items`, {
+            data: {
+                filter: { range_id }
+            }
+        }).done(() => {
             //Check if the item has siblings:
-            let siblings = jQuery(item_html).siblings();
+            let siblings = item_element.siblings();
             if (siblings.length < 3) {
                 //Only the "no items" element and the template
                 //are siblings of the item.
                 //We must display the "no items" element:
-                jQuery(item_html).siblings(
-                    '.empty-clipboard-message'
-                ).removeClass('invisible');
+                item_element.siblings('.empty-clipboard-message').removeClass('invisible');
                 jQuery("#clipboard-group-container").find('.widget-links').addClass('invisible');
             }
             //Finally remove the item:
-            jQuery(item_html).remove();
+            item_element.remove();
         });
     },
 
diff --git a/tests/_support/Helper/StudipDb.php b/tests/_support/Helper/StudipDb.php
index c80338ced5022448fe1894191f89802132f23455..b95e703c056ad462a1815d136a2c8a48e9c0da65 100644
--- a/tests/_support/Helper/StudipDb.php
+++ b/tests/_support/Helper/StudipDb.php
@@ -11,19 +11,11 @@ class StudipDb extends \Codeception\Module
 {
     /**
      * @api
-     *
-     * @var
      */
     public ?\StudipPdo $dbh;
 
-    /**
-     * @var array
-     */
     protected array $config = [];
 
-    /**
-     * @var array
-     */
     protected array $requiredFields = ['dsn', 'user', 'password'];
 
     /**
diff --git a/tests/jsonapi/ClipboardRoutesTest.php b/tests/jsonapi/ClipboardRoutesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..71a851a9d9b9ea240a3427fded68f11e9b4844d0
--- /dev/null
+++ b/tests/jsonapi/ClipboardRoutesTest.php
@@ -0,0 +1,167 @@
+<?php
+
+use JsonApi\Routes\Clipboards\ClipboardItemsCreate;
+use JsonApi\Routes\Clipboards\ClipboardItemsDelete;
+use JsonApi\Routes\Clipboards\ClipboardsCreate;
+use JsonApi\Routes\Clipboards\ClipboardsDelete;
+use JsonApi\Routes\Clipboards\ClipboardsUpdate;
+use JsonApi\Schemas\Clipboard as ClipboardSchema;
+use JsonApi\Schemas\ClipboardItem as ClipboardItemSchema;
+use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse;
+use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject;
+
+require_once __DIR__ . '/JSONAPIHelperTrait.php';
+
+class ClipboardRoutesTest extends Codeception\Test\Unit
+{
+    use JSONAPIHelperTrait;
+
+    public function testCreateClipboard(): void
+    {
+        $resource = $this->createClipboard(
+            $this->tester->getCredentialsForTestDozent()
+        );
+
+        $this->assertHasRelations($resource, 'user', 'clipboard-items');
+        $this->assertEquals(ClipboardSchema::TYPE, $resource->type());
+        $this->assertEquals('Test-Clipboard', $resource->attribute('name'));
+    }
+
+    public function testUpdateClipboard(): void
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+        $resource = $this->createClipboard($credentials);
+
+        $response = $this->sendMockRequest(
+            "/clipboards/{id}",
+            ClipboardsUpdate::class,
+            $credentials,
+            ['id' => $resource->id()],
+            [
+                'considered_successful' => [200],
+                'method' => 'PATCH',
+                'json_body' => [
+                    'data' => [
+                        'attributes' => ['name' => 'Foo Bar'],
+                    ],
+                ],
+            ],
+        );
+
+        $resource = $this->getResourceFromResponse($response);
+
+        $this->assertEquals('Foo Bar', $resource->attribute('name'));
+    }
+
+    public function testDeleteClipboard(): void
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+
+        $resource = $this->createClipboard($credentials);
+
+        $this->sendMockRequest(
+            "/clipboards/{id}",
+            ClipboardsDelete::class,
+            $credentials,
+            ['id' => $resource->id()],
+            [
+                'considered_successful' => [204],
+                'method' => 'DELETE',
+            ],
+        );
+    }
+
+    public function testAddItemToClipboard(): void
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+        $resource = $this->createClipboard($credentials);
+
+        $resource = $this->createClipboardItem(
+            $credentials,
+            $resource->id(),
+            'abcd1234',
+            'Room'
+        );
+
+        $this->assertHasRelations($resource, 'clipboard');
+        $this->assertEquals(ClipboardItemSchema::TYPE, $resource->type());
+        $this->assertEquals('abcd1234', $resource->attribute('range_id'));
+        $this->assertEquals('Room', $resource->attribute('range_type'));
+    }
+
+    public function testRemoveItemFromClipboard(): void
+    {
+        $credentials = $this->tester->getCredentialsForTestDozent();
+        $clipboard = $this->createClipboard($credentials);
+        $item = $this->createClipboardItem(
+            $credentials,
+            $clipboard->id(),
+            'abcd1234',
+            'Room'
+        );
+
+        $this->sendMockRequest(
+            "/clipboards/{id}/items/{itemId}",
+            ClipboardItemsDelete::class,
+            $credentials,
+            [
+                'id' => $clipboard->id(),
+                'itemId' => $item->id(),
+            ],
+            [
+                'considered_successful' => [204],
+                'method' => 'DELETE',
+            ],
+        );
+    }
+
+    protected function createClipboard(array $credentials, string $name = 'Test-Clipboard'): ResourceObject
+    {
+        $response = $this->sendMockRequest(
+            "/clipboards",
+            ClipboardsCreate::class,
+            $credentials,
+            [],
+            [
+                'considered_successful' => [200],
+                'method' => 'POST',
+                'json_body' => [
+                    'data' => [
+                        'type' => ClipboardSchema::TYPE,
+                        'attributes' => ['name' => $name],
+                    ],
+                ],
+            ],
+        );
+
+        return $this->getResourceFromResponse($response);
+    }
+
+    protected function createClipboardItem(
+        array $credentials,
+        string $clipboard_id,
+        string $range_id,
+        string $range_type
+    ): ResourceObject {
+        $response = $this->sendMockRequest(
+            "/clipboards/{id}/items",
+            ClipboardItemsCreate::class,
+            $credentials,
+            ['id' => $clipboard_id],
+            [
+                'considered_successful' => [200],
+                'method' => 'POST',
+                'json_body' => [
+                    'data' => [
+                        'attributes' => [
+                            'range_id'   => $range_id,
+                            'range_type' => $range_type,
+                        ],
+                    ],
+                ],
+            ],
+        );
+
+        return $this->getResourceFromResponse($response);
+    }
+}
diff --git a/tests/jsonapi/ConsultationHelper.php b/tests/jsonapi/ConsultationHelper.php
index 673174e13660e96451c72e3301d54e7fb25ea52f..f84820b3125f4ca26118b08d8a11439e42580f47 100644
--- a/tests/jsonapi/ConsultationHelper.php
+++ b/tests/jsonapi/ConsultationHelper.php
@@ -1,19 +1,9 @@
 <?php
-use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse;
-use WoohooLabs\Yang\JsonApi\Schema\Document;
-use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject;
+require_once __DIR__ . '/JSONAPIHelperTrait.php';
 
 trait ConsultationHelper
 {
-    /**
-     * @var \UnitTester
-     */
-    protected $tester;
-
-    protected function _before()
-    {
-        \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
-    }
+    use JSONAPIHelperTrait;
 
     protected static $BLOCK_DATA = [
         'room'              => 'Testraum',
@@ -88,23 +78,6 @@ trait ConsultationHelper
         return $block->slots->first();
     }
 
-    protected function withStudipEnv(array $credentials, callable $fn)
-    {
-        // Create global template factory if neccessary
-        $has_template_factory = isset($GLOBALS['template_factory']);
-        if (!$has_template_factory) {
-            $GLOBALS['template_factory'] = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/templates');
-        }
-
-        $result = $this->tester->withPHPLib($credentials, $fn);
-
-        if (!$has_template_factory) {
-            unset($GLOBALS['template_factory']);
-        }
-
-        return $result;
-    }
-
     protected function createBookingForSlot(array $credentials, ConsultationSlot $slot, User $user): ConsultationBooking
     {
         return $this->withStudipEnv(
@@ -122,81 +95,4 @@ trait ConsultationHelper
             }
         );
     }
-
-    protected function sendMockRequest(string $route, string $handler, array $credentials, array $variables = [], array $options = []): JsonApiResponse
-    {
-        $options = array_merge([
-            'method'                => 'GET',
-            'considered_successful' => [200],
-            'json_body'             => null,
-        ], $options);
-
-        $app = $this->tester->createApp(
-            $credentials,
-            strtolower($options['method']),
-            $route,
-            $handler
-        );
-
-        $evaluated_route = preg_replace_callback(
-            '/\{(.+?)(:[^}]+)?}/',
-            function ($match) use ($variables) {
-                $key = $match[1];
-                if (!isset($variables[$key])) {
-                    throw new Exception("No variable '{$key}' defined");
-                }
-                return $variables[$key];
-            },
-            $route
-        );
-
-        $requestBuilder = $this->tester->createRequestBuilder($credentials);
-        $requestBuilder->setUri($evaluated_route)->setMethod(strtoupper($options['method']));
-
-        if (isset($options['json_body'])) {
-            $requestBuilder->setJsonApiBody($options['json_body']);
-
-        }
-
-        /** @var JsonApiResponse $response */
-        $response = $this->withStudipEnv($credentials, function () use ($app, $requestBuilder) {
-            return $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-        });
-
-        if ($options['considered_successful']) {
-            $this->assertTrue(
-                $response->isSuccessful($options['considered_successful']),
-                'Actual status code is ' . $response->getStatusCode()
-            );
-        }
-
-        return $response;
-    }
-
-    protected function getSingleResourceDocument(JsonApiResponse $response): Document
-    {
-        $this->assertTrue($response->hasDocument());
-
-        $document = $response->document();
-        $this->assertTrue($document->isSingleResourceDocument());
-
-        return $document;
-    }
-
-    protected function getResourceCollectionDocument(JsonApiResponse $response): Document
-    {
-        $this->assertTrue($response->hasDocument());
-
-        $document = $response->document();
-        $this->assertTrue($document->isResourceCollectionDocument());
-
-        return $document;
-    }
-
-    protected function assertHasRelations(ResourceObject $resource, ...$relations)
-    {
-        foreach ($relations as $relation) {
-            $this->assertTrue($resource->hasRelationship($relation));
-        }
-    }
 }
diff --git a/tests/jsonapi/JSONAPIHelperTrait.php b/tests/jsonapi/JSONAPIHelperTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..666e1981f29dacc83449024b49d6f95c60e3fa8d
--- /dev/null
+++ b/tests/jsonapi/JSONAPIHelperTrait.php
@@ -0,0 +1,117 @@
+<?php
+
+use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse;
+use WoohooLabs\Yang\JsonApi\Schema\Document;
+use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject;
+
+trait JSONAPIHelperTrait
+{
+    protected JSONAPITester $tester;
+
+    protected function _before()
+    {
+        DBManager::getInstance()->setConnection(
+            'studip',
+            $this->getModule('\\Helper\\StudipDb')->dbh
+        );
+    }
+
+    protected function withStudipEnv(array $credentials, callable $fn)
+    {
+        // Create global template factory if neccessary
+        $has_template_factory = isset($GLOBALS['template_factory']);
+        if (!$has_template_factory) {
+            $GLOBALS['template_factory'] = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/templates');
+        }
+
+        $result = $this->tester->withPHPLib($credentials, $fn);
+
+        if (!$has_template_factory) {
+            unset($GLOBALS['template_factory']);
+        }
+
+        return $result;
+    }
+
+    protected function sendMockRequest(string $route, string $handler, array $credentials, array $variables = [], array $options = []): JsonApiResponse
+    {
+        $options = array_merge([
+            'method'                => 'GET',
+            'considered_successful' => [200],
+            'json_body'             => null,
+        ], $options);
+
+        $app = $this->tester->createApp(
+            $credentials,
+            strtolower($options['method']),
+            $route,
+            $handler
+        );
+
+        $evaluated_route = preg_replace_callback(
+            '/\{(.+?)(:[^}]+)?}/',
+            function ($match) use ($variables) {
+                $key = $match[1];
+                if (!isset($variables[$key])) {
+                    throw new Exception("No variable '{$key}' defined");
+                }
+                return $variables[$key];
+            },
+            $route
+        );
+
+        $requestBuilder = $this->tester->createRequestBuilder($credentials);
+        $requestBuilder->setUri($evaluated_route)->setMethod(strtoupper($options['method']));
+
+        if (isset($options['json_body'])) {
+            $requestBuilder->setJsonApiBody($options['json_body']);
+
+        }
+
+        /** @var JsonApiResponse $response */
+        $response = $this->withStudipEnv($credentials, function () use ($app, $requestBuilder) {
+            return $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
+        });
+
+        if ($options['considered_successful']) {
+            $this->assertTrue(
+                $response->isSuccessful($options['considered_successful']),
+                'Actual status code is ' . $response->getStatusCode()
+            );
+        }
+
+        return $response;
+    }
+
+    protected function getSingleResourceDocument(JsonApiResponse $response): Document
+    {
+        $this->assertTrue($response->hasDocument());
+
+        $document = $response->document();
+        $this->assertTrue($document->isSingleResourceDocument());
+
+        return $document;
+    }
+
+    protected function getResourceCollectionDocument(JsonApiResponse $response): Document
+    {
+        $this->assertTrue($response->hasDocument());
+
+        $document = $response->document();
+        $this->assertTrue($document->isResourceCollectionDocument());
+
+        return $document;
+    }
+
+    protected function assertHasRelations(ResourceObject $resource, ...$relations)
+    {
+        foreach ($relations as $relation) {
+            $this->assertTrue($resource->hasRelationship($relation));
+        }
+    }
+
+    protected function getResourceFromResponse(JsonApiResponse $response): ResourceObject
+    {
+        return $this->getSingleResourceDocument($response)->primaryResource();
+    }
+}