aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan-Hendrik Willms <tleilax+studip@gmail.com>2024-05-21 11:41:55 +0000
committerDavid Siegfried <david.siegfried@uni-vechta.de>2024-05-21 11:41:55 +0000
commitc21817bfe8bd8695f612e47c2a1d8de03148fb88 (patch)
tree791a588cd0c276f117e1bf8f70a4b56d9c83013f
parent98ee46ee0cb82934ff111a72d5cb7a93b68219d8 (diff)
implement jsonapi for clipboard and clipboard items and replace old clipboards route with new ones, fixes #4198
Closes #4198 Merge request studip/studip!3029
-rw-r--r--lib/classes/JsonApi/RouteMap.php19
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/Authority.php28
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php106
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php54
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php28
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php46
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php31
-rw-r--r--lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php50
-rw-r--r--lib/classes/JsonApi/SchemaMap.php4
-rw-r--r--lib/classes/JsonApi/Schemas/Clipboard.php81
-rw-r--r--lib/classes/JsonApi/Schemas/ClipboardItem.php61
-rw-r--r--lib/models/ClipboardItem.class.php32
-rw-r--r--resources/assets/javascripts/bootstrap/clipboard.js13
-rw-r--r--resources/assets/javascripts/lib/abstract-api.js27
-rw-r--r--resources/assets/javascripts/lib/clipboard.js144
-rw-r--r--tests/_support/Helper/StudipDb.php8
-rw-r--r--tests/jsonapi/ClipboardRoutesTest.php167
-rw-r--r--tests/jsonapi/ConsultationHelper.php108
-rw-r--r--tests/jsonapi/JSONAPIHelperTrait.php117
19 files changed, 904 insertions, 220 deletions
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index d81bbbf..149f681 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 0000000..5cc053a
--- /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 0000000..d57d0c5
--- /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 0000000..a9c7cd4
--- /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 0000000..3c91708
--- /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 0000000..57fd9b9
--- /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 0000000..0897843
--- /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 0000000..83d9539
--- /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 97212bc..1498daf 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 0000000..af90d73
--- /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 0000000..9c84823
--- /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 030388a..888250f 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 e525b35..a64a605 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 eafca85..cf9aed1 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 e5890ab..8af3111 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 c80338c..b95e703 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 0000000..71a851a
--- /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 673174e..f84820b 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 0000000..666e198
--- /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();
+ }
+}