diff --git a/experience_builder.routing.yml b/experience_builder.routing.yml index 9736b30da7e44e96553f047d779e8217e79ef82d..fdef394b93a0a247beab34babefb65e1e0ba98c6 100644 --- a/experience_builder.routing.yml +++ b/experience_builder.routing.yml @@ -38,7 +38,7 @@ experience_builder.api.content.update: experience_builder.api.content.create: path: '/xb/api/content-create/xb_page' defaults: - _controller: 'Drupal\experience_builder\Controller\ApiContentCreateXbPage' + _controller: 'Drupal\experience_builder\Controller\ApiContentControllers::post' requirements: _entity_create_access: 'xb_page:xb_page' methods: ['POST'] @@ -70,6 +70,16 @@ entity.component.delete_form: requirements: _entity_access: 'component.delete' +# @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types by using a route callback +experience_builder.api.content.list: + path: '/xb/api/content/xb_page' + defaults: + _controller: 'Drupal\experience_builder\Controller\ApiContentControllers::list' + requirements: + # @todo update in https://www.drupal.org/i/3502048 + _permission: 'administer xb_page' + methods: [GET] + experience_builder.api.component_inputs_form: path: '/xb-field-form/{entity_type}/{entity}' defaults: diff --git a/experience_builder.services.yml b/experience_builder.services.yml index 6cd42453add27fb684cc8817098b4c392d1117ef..9e00b4b63448227c88013a30a41cc08c0e9566e5 100644 --- a/experience_builder.services.yml +++ b/experience_builder.services.yml @@ -97,7 +97,7 @@ services: Drupal\experience_builder\Controller\ApiLogController: {} Drupal\experience_builder\Controller\ApiPreviewController: {} Drupal\experience_builder\Controller\ApiContentUpdateForDemoController: {} - Drupal\experience_builder\Controller\ApiContentCreateXbPage: {} + Drupal\experience_builder\Controller\ApiContentControllers: {} Drupal\experience_builder\Controller\ComponentStatusController: {} Drupal\experience_builder\Controller\ExperienceBuilderController: {} Drupal\experience_builder\Controller\ApiPublishAllController: {} diff --git a/openapi.yml b/openapi.yml index c2ef8a258ed517c3fa95e8f2f375400ee644c2fa..a36d132547c49d5397984203712e3c26d3c0e27a 100644 --- a/openapi.yml +++ b/openapi.yml @@ -30,6 +30,50 @@ info: # @see https://spec.openapis.org/oas/v3.1.0.html#paths-object # @see https://spec.openapis.org/oas/v3.1.0.html#path-item-object paths: + '/xb/api/content/xb_page': + get: + description: Provides api for page listing. + responses: + 200: + description: Page list generated successfully. + content: + application/json: + schema: + type: object + minProperties: 0 + additionalProperties: false + patternProperties: + '^[0-9]$': + type: object + required: + - id + - title + - status + - path + properties: + id: + type: integer + description: The page ID. + title: + type: string + description: The page title. + status: + type: boolean + description: The page status. + examples: + pageList: + value: + '1': + id: 1 + title: Home + status: true + path: /home + '2': + id: 2 + title: About + status: false + path: /about + '/api/layout/{entityTypeId}/{entityId}': description: TODO get: diff --git a/src/Controller/ApiContentControllers.php b/src/Controller/ApiContentControllers.php new file mode 100644 index 0000000000000000000000000000000000000000..735c01d92cfe855d8587dea38979fb3e8e62a9b3 --- /dev/null +++ b/src/Controller/ApiContentControllers.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\experience_builder\Controller; + +use Drupal\Core\Cache\CacheableJsonResponse; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\Query\QueryInterface; +use Drupal\Core\Render\RenderContext; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; + +/** + * HTTP API for interacting with XB-eligible Content entity types. + * + * @internal This HTTP API is intended only for the XB UI. These controllers + * and associated routes may change at any time. + * + * @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types + */ +final class ApiContentControllers { + + use StringTranslationTrait; + + public function __construct( + private readonly EntityTypeManagerInterface $entityTypeManager, + private readonly RendererInterface $renderer, + ) {} + + public function post(): JsonResponse { + // Note: this intentionally does not catch content entity type storage + // handler exceptions: the generic XB API exception subscriber handles them. + // @see \Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber + $page = $this->entityTypeManager->getStorage('xb_page')->create([ + 'title' => $this->t('Untitled page'), + 'status' => FALSE, + ]); + $page->save(); + + return new JsonResponse([ + 'entity_type' => $page->getEntityTypeId(), + 'entity_id' => $page->id(), + ], RESPONSE::HTTP_CREATED); + } + + /** + * Returns a list of XB Page content entities, with only high-level metadata. + * + * TRICKY: there are reasons XB has its own internal HTTP API rather than + * using Drupal core's JSON:API. As soon as this method is updated to return + * all fields instead of just high-level metadata, those reasons may start to + * outweigh the downsides of adding a dependency on JSON:API. + * + * @see https://www.drupal.org/project/experience_builder/issues/3500052#comment-15966496 + */ + public function list(): CacheableJsonResponse { + // @todo introduce pagination in https://www.drupal.org/i/3502691 + $storage = $this->entityTypeManager->getStorage('xb_page'); + $query_cacheability = (new CacheableMetadata()) + ->addCacheContexts($storage->getEntityType()->getListCacheContexts()) + ->addCacheTags($storage->getEntityType()->getListCacheTags()); + $url_cacheability = new CacheableMetadata(); + // We don't need to worry about the status of the page, as we need both + // published and unpublished pages on the frontend. + $entity_query = $storage->getQuery()->accessCheck(TRUE); + $ids = $this->executeQueryInRenderContext($entity_query, $query_cacheability); + /** @var \Drupal\Core\Entity\EntityPublishedInterface[] $content_entities */ + $content_entities = $storage->loadMultiple($ids); + $content_list = []; + foreach ($content_entities as $content_entity) { + $id = (int) $content_entity->id(); + $generated_url = $content_entity->toUrl()->toString(TRUE); + $content_list[$id] = [ + 'id' => $id, + 'title' => $content_entity->label(), + 'status' => $content_entity->isPublished(), + 'path' => $generated_url->getGeneratedUrl(), + ]; + $url_cacheability->addCacheableDependency($generated_url); + } + $json_response = new CacheableJsonResponse($content_list); + // @todo add cache contexts for query params when introducing pagination in https://www.drupal.org/i/3502691. + $json_response->addCacheableDependency($query_cacheability) + ->addCacheableDependency($url_cacheability); + return $json_response; + } + + /** + * Executes the query in a render context, to catch bubbled cacheability. + * + * @param \Drupal\Core\Entity\Query\QueryInterface $query + * The query to execute to get the return results. + * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability + * The value object to carry the query cacheability. + * + * @return array + * Returns IDs of entities. + * + * @see \Drupal\jsonapi\Controller\EntityResource::executeQueryInRenderContext() + */ + private function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) : array { + $context = new RenderContext(); + $results = $this->renderer->executeInRenderContext($context, function () use ($query) { + return $query->execute(); + }); + if (!$context->isEmpty()) { + $query_cacheability->addCacheableDependency($context->pop()); + } + return $results; + } + +} diff --git a/src/Controller/ApiContentCreateXbPage.php b/src/Controller/ApiContentCreateXbPage.php deleted file mode 100644 index fd4a54fd822a1fbb6834021a1c31e3bbf0a08fcf..0000000000000000000000000000000000000000 --- a/src/Controller/ApiContentCreateXbPage.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\experience_builder\Controller; - -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\StringTranslation\StringTranslationTrait; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Response; - -/** - * Creates new xb_page entity. - * - * New entity must be unpublished and have a hardcoded initial title. - * - * @internal This HTTP API is intended only for the XB UI. These controllers - * and associated routes may change at any time. - * - * @todo https://www.drupal.org/i/3498525 should generalize this to all eligible content entity types - */ -final class ApiContentCreateXbPage { - - use StringTranslationTrait; - - public function __construct( - private readonly EntityTypeManagerInterface $entityTypeManager, - ) {} - - public function __invoke(): JsonResponse { - // Note: this intentionally does not catch content entity type storage - // handler exceptions: the generic XB API exception subscriber handles them. - // @see \Drupal\experience_builder\EventSubscriber\ApiExceptionSubscriber - $page = $this->entityTypeManager->getStorage('xb_page')->create([ - 'title' => $this->t('Untitled page'), - 'status' => FALSE, - ]); - $page->save(); - - return new JsonResponse([ - 'entity_type' => $page->getEntityTypeId(), - 'entity_id' => $page->id(), - ], RESPONSE::HTTP_CREATED); - } - -} diff --git a/tests/src/Functional/HttpApiTestBase.php b/tests/src/Functional/HttpApiTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..6cfaba4a54ac6e7b8fbfe7fa18075ec682c98ba3 --- /dev/null +++ b/tests/src/Functional/HttpApiTestBase.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\experience_builder\Functional; + +use Drupal\Core\Url; +use Drupal\Tests\ApiRequestTrait; +use Drupal\Tests\BrowserTestBase; + +abstract class HttpApiTestBase extends BrowserTestBase { + + use ApiRequestTrait; + + /** + * @return ?array + * The decoded JSON response, or NULL if there is no body. + * + * @throws \JsonException + */ + protected function assertExpectedResponse(string $method, Url $url, array $request_options, int $expected_status, ?array $expected_cache_contexts, ?array $expected_cache_tags, ?string $expected_page_cache, ?string $expected_dynamic_page_cache, array $additional_expected_response_headers = []): ?array { + $request_options['headers']['X-CSRF-Token'] = $this->drupalGet('session/token'); + $response = $this->makeApiRequest($method, $url, $request_options); + $body = (string) $response->getBody(); + $this->assertSame($expected_status, $response->getStatusCode(), $body); + + // Cacheability headers. + $this->assertSame($expected_page_cache !== NULL, $response->hasHeader('X-Drupal-Cache')); + if ($expected_page_cache !== NULL) { + $this->assertSame($expected_page_cache, $response->getHeader('X-Drupal-Cache')[0], 'Page Cache response header'); + } + $this->assertSame($expected_dynamic_page_cache !== NULL, $response->hasHeader('X-Drupal-Dynamic-Cache')); + if ($expected_dynamic_page_cache !== NULL) { + $this->assertSame($expected_dynamic_page_cache, $response->getHeader('X-Drupal-Dynamic-Cache')[0], 'Dynamic Page Cache response header'); + } + $this->assertSame($expected_cache_tags !== NULL, $response->hasHeader('X-Drupal-Cache-Tags')); + if ($expected_cache_tags !== NULL) { + $this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); + } + $this->assertSame($expected_cache_contexts !== NULL, $response->hasHeader('X-Drupal-Cache-Contexts')); + if ($expected_cache_contexts !== NULL) { + $this->assertEqualsCanonicalizing($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); + } + + // Optionally, additional expected response headers can be validated. + if ($additional_expected_response_headers) { + foreach ($additional_expected_response_headers as $header_name => $expected_value) { + $this->assertSame($response->getHeader($header_name), $expected_value); + } + } + + // Response must at least be decodable JSON, let this throw an exception + // otherwise. (Assertions of the contents happen outside this method.) + if ($body === '') { + return NULL; + } + $json = json_decode($body, associative: TRUE, flags: JSON_THROW_ON_ERROR); + + return $json; + } + +} diff --git a/tests/src/Functional/XbConfigEntityHttpApiTest.php b/tests/src/Functional/XbConfigEntityHttpApiTest.php index 18baf91e7817d9aa00468eddd909a9f310e37b0d..26de0072c0d652bba46cbd7012150e03d8feed2c 100644 --- a/tests/src/Functional/XbConfigEntityHttpApiTest.php +++ b/tests/src/Functional/XbConfigEntityHttpApiTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); +namespace Drupal\Tests\experience_builder\Functional; + use Drupal\Core\Url; use Drupal\system\Entity\Menu; -use Drupal\Tests\ApiRequestTrait; -use Drupal\Tests\BrowserTestBase; use Drupal\Tests\experience_builder\Traits\ContribStrictConfigSchemaTestTrait; use Drupal\Tests\experience_builder\Traits\TestDataUtilitiesTrait; use Drupal\user\UserInterface; @@ -16,9 +16,8 @@ use GuzzleHttp\RequestOptions; * @group experience_builder * @internal */ -class XbConfigEntityHttpApiTest extends BrowserTestBase { +class XbConfigEntityHttpApiTest extends HttpApiTestBase { - use ApiRequestTrait; use TestDataUtilitiesTrait; use ContribStrictConfigSchemaTestTrait; @@ -632,51 +631,4 @@ class XbConfigEntityHttpApiTest extends BrowserTestBase { $this->assertSame([], $individual_body); } - /** - * @return ?array - * The decoded JSON response, or NULL if there is no body. - * - * @throws \JsonException - */ - private function assertExpectedResponse(string $method, Url $url, array $request_options, int $expected_status, ?array $expected_cache_contexts, ?array $expected_cache_tags, ?string $expected_page_cache, ?string $expected_dynamic_page_cache, array $additional_expected_response_headers = []): ?array { - $request_options['headers']['X-CSRF-Token'] = $this->drupalGet('session/token'); - $response = $this->makeApiRequest($method, $url, $request_options); - $body = (string) $response->getBody(); - $this->assertSame($expected_status, $response->getStatusCode(), $body); - - // Cacheability headers. - $this->assertSame($expected_page_cache !== NULL, $response->hasHeader('X-Drupal-Cache')); - if ($expected_page_cache !== NULL) { - $this->assertSame($expected_page_cache, $response->getHeader('X-Drupal-Cache')[0], 'Page Cache response header'); - } - $this->assertSame($expected_dynamic_page_cache !== NULL, $response->hasHeader('X-Drupal-Dynamic-Cache')); - if ($expected_dynamic_page_cache !== NULL) { - $this->assertSame($expected_dynamic_page_cache, $response->getHeader('X-Drupal-Dynamic-Cache')[0], 'Dynamic Page Cache response header'); - } - $this->assertSame($expected_cache_tags !== NULL, $response->hasHeader('X-Drupal-Cache-Tags')); - if ($expected_cache_tags !== NULL) { - $this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); - } - $this->assertSame($expected_cache_contexts !== NULL, $response->hasHeader('X-Drupal-Cache-Contexts')); - if ($expected_cache_contexts !== NULL) { - $this->assertEqualsCanonicalizing($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); - } - - // Optionally, additional expected response headers can be validated. - if ($additional_expected_response_headers) { - foreach ($additional_expected_response_headers as $header_name => $expected_value) { - $this->assertSame($response->getHeader($header_name), $expected_value); - } - } - - // Response must at least be decodable JSON, let this throw an exception - // otherwise. (Assertions of the contents happen outside this method.) - if ($body === '') { - return NULL; - } - $json = json_decode($body, associative: TRUE, flags: JSON_THROW_ON_ERROR); - - return $json; - } - } diff --git a/tests/src/Functional/XbContentEntityHttpApiTest.php b/tests/src/Functional/XbContentEntityHttpApiTest.php index 197a5b335f8dcf6d851057c65d53daf4f8432a1e..2e71d19ed3b1bd0b85b8bc88a6511797962d6821 100644 --- a/tests/src/Functional/XbContentEntityHttpApiTest.php +++ b/tests/src/Functional/XbContentEntityHttpApiTest.php @@ -5,20 +5,17 @@ declare(strict_types=1); namespace Drupal\Tests\experience_builder\Functional; use Drupal\Core\Url; -use Drupal\Tests\ApiRequestTrait; -use Drupal\Tests\BrowserTestBase; +use Drupal\experience_builder\Entity\Page; use Drupal\user\Entity\Role; use Drupal\user\UserInterface; use GuzzleHttp\RequestOptions; /** - * @covers \Drupal\experience_builder\Controller\ApiContentCreateXbPage + * @covers \Drupal\experience_builder\Controller\ApiContentControllers * @group experience_builder * @internal */ -final class XbContentEntityHttpApiTest extends BrowserTestBase { - - use ApiRequestTrait; +final class XbContentEntityHttpApiTest extends HttpApiTestBase { /** * {@inheritdoc} @@ -32,7 +29,29 @@ final class XbContentEntityHttpApiTest extends BrowserTestBase { */ protected $defaultTheme = 'stark'; - public function test(): void { + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + Page::create([ + 'title' => "Page 1", + 'status' => TRUE, + 'path' => ['alias' => "/page-1"], + ])->save(); + Page::create([ + 'title' => "Page 2", + 'status' => FALSE, + 'path' => ['alias' => "/page-2"], + ])->save(); + Page::create([ + 'title' => "Page 3", + 'status' => TRUE, + 'path' => ['alias' => "/page-3"], + ])->save(); + } + + public function testPost(): void { $url = Url::fromUri('base:/xb/api/content-create/xb_page'); $request_options = [ RequestOptions::HEADERS => [ @@ -66,9 +85,58 @@ final class XbContentEntityHttpApiTest extends BrowserTestBase { $response = $this->makeApiRequest('POST', $url, $request_options); $this->assertSame(201, $response->getStatusCode()); $this->assertSame( - '{"entity_type":"xb_page","entity_id":"1"}', + '{"entity_type":"xb_page","entity_id":"4"}', (string) $response->getBody() ); } + public function testList(): void { + $url = Url::fromUri('base:/xb/api/content/xb_page'); + + // Anonymously: 403. + $body = $this->assertExpectedResponse('GET', $url, [], 403, ['user.permissions'], ['4xx-response', 'config:user.role.anonymous', 'http_response'], 'MISS', NULL); + $this->assertSame([ + 'message' => "The 'administer xb_page' permission is required.", + ], $body); + + // User without permission. + $user = $this->createUser(['access content'], 'access_content_user'); + assert($user instanceof UserInterface); + $this->drupalLogin($user); + $body = $this->assertExpectedResponse('GET', $url, [], 403, ['user.permissions'], ['4xx-response', 'http_response'], 'UNCACHEABLE (request policy)', NULL); + $this->assertSame([ + 'message' => "The 'administer xb_page' permission is required.", + ], $body); + + // Authenticated, authorized: 200. + $user = $this->createUser(['administer xb_page'], 'administer_xb_page_user'); + assert($user instanceof UserInterface); + $this->drupalLogin($user); + $body = $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], ['http_response', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'MISS'); + $this->assertEquals( + [ + '1' => [ + 'id' => 1, + 'title' => 'Page 1', + 'status' => TRUE, + 'path' => base_path() . 'page-1', + ], + '2' => [ + 'id' => 2, + 'title' => 'Page 2', + 'status' => FALSE, + 'path' => base_path() . 'page-2', + ], + '3' => [ + 'id' => 3, + 'title' => 'Page 3', + 'status' => TRUE, + 'path' => base_path() . 'page-3', + ], + ], + $body + ); + $this->assertExpectedResponse('GET', $url, [], 200, ['user.permissions'], ['http_response', 'xb_page_list'], 'UNCACHEABLE (request policy)', 'HIT'); + } + }