diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c914e6fb06cbedd2f0b9069465c17e3cb89cff92..e41b5000dd58bdda98e8636ddc636dd123cace63 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -48,6 +48,7 @@ include: ################ variables: OPT_IN_TEST_PREVIOUS_MAJOR: 1 + CORE_PREVIOUS_PHP_MIN: 8.0 OPT_IN_TEST_PREVIOUS_MINOR: 1 OPT_IN_TEST_NEXT_MINOR: 1 diff --git a/src/Unstable/ResourceResponseFactory.php b/src/Unstable/ResourceResponseFactory.php index a5429fbfec62eb9d890889db53ca235b7f1c623c..0637b4877bff61b50c698b91ff7867570a333996 100644 --- a/src/Unstable/ResourceResponseFactory.php +++ b/src/Unstable/ResourceResponseFactory.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\jsonapi_resources\Unstable; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Http\Exception\CacheableBadRequestHttpException; use Drupal\Core\Url; use Drupal\jsonapi\CacheableResourceResponse; use Drupal\jsonapi\IncludeResolver; @@ -13,6 +14,7 @@ use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel; use Drupal\jsonapi\JsonApiResource\Link; use Drupal\jsonapi\JsonApiResource\LinkCollection; use Drupal\jsonapi\JsonApiResource\NullIncludedData; +use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface; use Drupal\jsonapi\JsonApiResource\ResourceObject; use Drupal\jsonapi\JsonApiResource\ResourceObjectData; use Drupal\jsonapi\ResourceResponse; @@ -97,21 +99,83 @@ final class ResourceResponseFactory { * * @param \Symfony\Component\HttpFoundation\Request $request * The request object. - * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\ResourceObjectData $data + * @param \Drupal\jsonapi\JsonApiResource\ResourceObjectData|\Drupal\jsonapi\JsonApiResource\ResourceObject $data * The response data from which to resolve includes. * * @return \Drupal\jsonapi\JsonApiResource\IncludedData - * A Data object to be included or a NullData object if the request does not + * A Data object to be included or a NullData object if the request does + * not * specify any include paths. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ - private function getIncludes(Request $request, $data): IncludedData { + private function getIncludes(Request $request, ResourceObjectData|ResourceObject $data): IncludedData { assert($data instanceof ResourceObject || $data instanceof ResourceObjectData); - return $request->query->has('include') && ($include_parameter = $request->query->get('include')) && !empty($include_parameter) - ? $this->includeResolver->resolve($data, $include_parameter) - : new NullIncludedData(); + if (!$request->query->has('include')) { + return new NullIncludedData(); + } + $include_parameter = $request->query->get('include'); + if (empty($include_parameter)) { + return new NullIncludedData(); + } + if ($data instanceof ResourceObject) { + return $this->includeResolver->resolve($data, $include_parameter); + } + + // Group resource objects to optimize IncludeResolver::toIncludeTree. + $data_by_resource_type = []; + $relatable_resource_types = []; + foreach ($data as $resource_object) { + assert($resource_object instanceof ResourceIdentifierInterface); + if (!isset($data_by_resource_type[$resource_object->getTypeName()])) { + $data_by_resource_type[$resource_object->getTypeName()] = []; + $relatable_resource_types[] = array_keys($resource_object->getResourceType()->getRelatableResourceTypes()); + } + $data_by_resource_type[$resource_object->getTypeName()][] = $resource_object; + } + + $include_paths = array_map('trim', explode(',', $include_parameter)); + $unresolved_include_paths = []; + $included_data = []; + foreach ($data_by_resource_type as $resource_objects) { + foreach ($include_paths as $include_path) { + try { + $included_data[] = $this->includeResolver->resolve( + new ResourceObjectData($resource_objects), + $include_path + ); + $unresolved_include_paths[$include_path] = FALSE; + } + catch (\Exception $e) { + if (!isset($unresolved_include_paths[$include_path])) { + $unresolved_include_paths[$include_path] = TRUE; + } + } + } + } + + if (count(array_filter($unresolved_include_paths)) > 0) { + // Throw an error if invalid include paths provided. + // @see \Drupal\jsonapi\Context\FieldResolver::resolveInternalIncludePath(). + $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:include']); + $message = sprintf( + '%s are not valid relationship names.', + implode(',', array_map(static fn (string $path) => "`$path`", array_keys($unresolved_include_paths))) + ); + if (count($relatable_resource_types) > 0) { + $message .= sprintf(' Possible values: %s', implode(', ', array_unique(array_merge(...$relatable_resource_types)))); + } + throw new CacheableBadRequestHttpException($cacheability, $message); + } + + $included_data = array_reduce( + $included_data, + [IncludedData::class, 'merge'], + new IncludedData([]) + ); + + return IncludedData::deduplicate($included_data); } } diff --git a/tests/src/Kernel/ResourceResponseFactoryTest.php b/tests/src/Kernel/ResourceResponseFactoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5a8a5699fa337048f20a3db02d764c34a184a708 --- /dev/null +++ b/tests/src/Kernel/ResourceResponseFactoryTest.php @@ -0,0 +1,219 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\jsonapi_resources\Kernel; + +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\jsonapi\CacheableResourceResponse; +use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel; +use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface; +use Drupal\jsonapi\JsonApiResource\ResourceObject; +use Drupal\jsonapi\JsonApiResource\ResourceObjectData; +use Drupal\KernelTests\KernelTestBase; +use Drupal\node\Entity\Node; +use Drupal\node\Entity\NodeType; +use Drupal\node\NodeInterface; +use Drupal\Tests\field\Traits\EntityReferenceTestTrait; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Symfony\Component\HttpFoundation\Request; + +/** + * Tests ResourceResponseFactory. + * + * @coversDefaultClass \Drupal\jsonapi_resources\Unstable\ResourceResponseFactory + * @group jsonapi_resources + */ +final class ResourceResponseFactoryTest extends KernelTestBase { + + use EntityReferenceTestTrait; + use UserCreationTrait; + + private const NODE_TYPE_ARTICLE_UUID = 'e5da5021-d7a0-4606-a21c-9586a8cf79a4'; + + private const NODE_TYPE_PAGE_UUID = '8378b97d-36fd-4515-b2eb-22e90dfdc8dc'; + + private const NODE_TYPE_EVENT_UUID = '12cce39f-fa9c-4c64-b7f6-a0ec511ba1e7'; + + private const NODE_ARTICLE_1_UUID = '7bf77016-93d2-4098-84e4-c2634c4d8ecf'; + + private const NODE_ARTICLE_2_UUID = '36405873-6b42-44ec-9f47-b771d83149b1'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'field', + 'file', + 'serialization', + 'jsonapi', + 'jsonapi_resources', + 'node', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installEntitySchema('user'); + $this->installEntitySchema('node'); + + $this->account = $this->createUser(); + $this->container->get('current_user')->setAccount($this->account); + + NodeType::create([ + 'uuid' => self::NODE_TYPE_ARTICLE_UUID, + 'name' => 'article', + 'type' => 'article', + ])->save(); + NodeType::create([ + 'uuid' => self::NODE_TYPE_PAGE_UUID, + 'name' => 'page', + 'type' => 'page', + ])->save(); + NodeType::create([ + 'uuid' => self::NODE_TYPE_EVENT_UUID, + 'name' => 'event', + 'type' => 'event', + ])->save(); + $this->createEntityReferenceField( + 'node', + 'page', + 'field_related_articles', + 'Related articles', + 'node', + 'default', + [ + 'target_bundles' => [ + 'reminder' => 'article', + ], + ], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + + $this->container->get('router.builder')->rebuildIfNeeded(); + } + + /** + * @covers ::create + * @dataProvider createData + */ + public function testCreate( + array $query, + array $expected_includes, + string $expected_error = '', + ): void { + if ($expected_error !== '') { + $this->expectExceptionMessage($expected_error); + } + + $article1 = Node::create([ + 'uuid' => self::NODE_ARTICLE_1_UUID, + 'type' => 'article', + 'title' => $this->randomString(), + 'status' => 1, + ]); + $article1->save(); + $article2 = Node::create([ + 'uuid' => self::NODE_ARTICLE_2_UUID, + 'type' => 'article', + 'title' => $this->randomString(), + 'status' => 1, + ]); + $article2->save(); + $page = Node::create([ + 'type' => 'page', + 'title' => $this->randomString(), + 'status' => 1, + 'field_related_articles' => [$article1->id(), $article2->id()], + ]); + $page->save(); + $event = Node::create([ + 'type' => 'event', + 'title' => $this->randomString(), + 'status' => 1, + ]); + $event->save(); + + $resource_type_repository = $this->container->get('jsonapi.resource_type.repository'); + $resource_objects = array_map( + static fn (NodeInterface $node) => ResourceObject::createFromEntity( + $resource_type_repository->get($node->getEntityTypeId(), $node->bundle()), + $node + ), + [$article1, $page, $article2, $event] + ); + + $request = Request::create('/foo?' . http_build_query($query)); + + $sut = $this->container->get('jsonapi_resources.resource_response_factory'); + $response = $sut->create( + new ResourceObjectData($resource_objects), + $request + ); + self::assertInstanceOf(CacheableResourceResponse::class, $response); + $document_top_level = $response->getResponseData(); + self::assertInstanceOf(JsonApiDocumentTopLevel::class, $document_top_level); + /** @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[] $includes_data */ + $includes_data = $document_top_level->getIncludes()->toArray(); + $includes_data = array_map( + static fn (ResourceIdentifierInterface $identifier) => [ + 'id' => $identifier->getId(), + 'type' => $identifier->getTypeName(), + ], + $includes_data + ); + self::assertEquals($expected_includes, $includes_data); + } + + /** + * Test data for testCreate. + * + * @return array[] + * The test data. + */ + public static function createData(): array { + return [ + 'mixed resource objects with same include' => [ + ['include' => 'node_type'], + [ + [ + 'id' => self::NODE_TYPE_ARTICLE_UUID, + 'type' => 'node_type--node_type', + ], + [ + 'id' => self::NODE_TYPE_PAGE_UUID, + 'type' => 'node_type--node_type', + ], + [ + 'id' => self::NODE_TYPE_EVENT_UUID, + 'type' => 'node_type--node_type', + ], + ], + ], + 'mixed resource objects with mismatched includes' => [ + ['include' => 'field_related_articles'], + [ + [ + 'id' => self::NODE_ARTICLE_1_UUID, + 'type' => 'node--article', + ], + [ + 'id' => self::NODE_ARTICLE_2_UUID, + 'type' => 'node--article', + ], + ], + ], + 'missing relationship in includes' => [ + ['include' => 'field_foobar'], + [], + 'field_foobar` are not valid relationship names. Possible values: node_type, revision_uid, uid, field_related_articles', + ], + ]; + } + +}