From 5f12d71e4a2f159f1b02b21a46904b8fc94e6506 Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Tue, 3 Nov 2026 08:23:04 +0000 Subject: [PATCH 1/3] Fix 500 on GET /_/contexts/ in PublishableEventListener (#582) --- CHANGELOG.md | 3 + .../Api/PublishableEventListener.php | 1 + .../Api/PublishableEventListenerTest.php | 53 +++++++++++++++++ .../PublishableJsonLdContextTest.php | 57 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 tests/EventListener/Api/PublishableEventListenerTest.php create mode 100644 tests/Functional/Publishable/PublishableJsonLdContextTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7684ed67..80f526da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio ## Unreleased +### Fixed +- `GET /_/contexts/` (the JSON-LD context) no longer fails with a TypeError in the publishable response listener ([#PRNUM](https://github.com/components-web-app/api-components-bundle/pull/PRNUM)) + ## [2.0.0-alpha.40](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.39...2.0.0-alpha.40) - 2026-10-31 ### Breaking diff --git a/src/EventListener/Api/PublishableEventListener.php b/src/EventListener/Api/PublishableEventListener.php index a5e33ed8..6704958e 100644 --- a/src/EventListener/Api/PublishableEventListener.php +++ b/src/EventListener/Api/PublishableEventListener.php @@ -58,6 +58,7 @@ public function onPostRespond(ResponseEvent $event): void if ( null === $data + || null === $attributes['class'] || !$this->publishableAttributeReader->isConfigured($attributes['class']) || $attributes['operation'] instanceof CollectionOperationInterface ) { diff --git a/tests/EventListener/Api/PublishableEventListenerTest.php b/tests/EventListener/Api/PublishableEventListenerTest.php new file mode 100644 index 00000000..dbd0a255 --- /dev/null +++ b/tests/EventListener/Api/PublishableEventListenerTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silverback\ApiComponentsBundle\Tests\EventListener\Api; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Validator\ValidatorInterface; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\TestCase; +use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader; +use Silverback\ApiComponentsBundle\EventListener\Api\PublishableEventListener; +use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; + +final class PublishableEventListenerTest extends TestCase +{ + public function test_a_response_whose_data_is_not_an_object_and_has_no_resource_class_is_left_unchanged(): void + { + $registry = $this->createStub(ManagerRegistry::class); + $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); + $authorizationChecker->expects(self::never())->method('isGranted'); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects(self::never())->method('validate'); + $listener = new PublishableEventListener( + new PublishableStatusChecker($registry, new PublishableAttributeReader($registry), $authorizationChecker, "is_granted('ROLE_ADMIN')"), + $registry, + $validator, + ); + + $request = Request::create('/contexts/Page'); + $request->attributes->set('_api_operation', new Get()); + $request->attributes->set('data', ['@context' => ['@vocab' => 'http://example.com/docs.jsonld#']]); + $request->attributes->set('_api_normalization_context', []); + $response = new Response('{}'); + $headersBefore = $response->headers->all(); + + $listener->onPostRespond(new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response)); + + self::assertSame($headersBefore, $response->headers->all()); + } +} diff --git a/tests/Functional/Publishable/PublishableJsonLdContextTest.php b/tests/Functional/Publishable/PublishableJsonLdContextTest.php new file mode 100644 index 00000000..76da350a --- /dev/null +++ b/tests/Functional/Publishable/PublishableJsonLdContextTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Silverback\ApiComponentsBundle\Tests\Functional\Publishable; + +use PHPUnit\Framework\Attributes\DataProvider; +use Silverback\ApiComponentsBundle\Tests\Functional\FunctionalTestCase; +use Silverback\ApiComponentsBundle\Tests\Functional\Trait\ApiRequestTrait; +use Silverback\ApiComponentsBundle\Tests\Functional\Trait\AuthenticationTrait; +use Silverback\ApiComponentsBundle\Tests\Functional\Trait\JsonTrait; + +final class PublishableJsonLdContextTest extends FunctionalTestCase +{ + use ApiRequestTrait; + use AuthenticationTrait; + use JsonTrait; + + #[DataProvider('contexts')] + public function test_a_json_ld_context_is_served_to_an_anonymous_caller(string $shortName): void + { + $client = static::createClient(); + + self::requestJsonLd($client, 'GET', '/contexts/' . $shortName); + + self::assertResponseStatusCodeSame(200); + self::assertJsonNodeExists('@context'); + } + + #[DataProvider('contexts')] + public function test_a_json_ld_context_is_served_to_an_admin(string $shortName): void + { + $client = static::createClient(); + self::loginAsAdmin($client); + + self::requestJsonLd($client, 'GET', '/contexts/' . $shortName); + + self::assertResponseStatusCodeSame(200); + self::assertJsonNodeExists('@context'); + } + + /** + * @return iterable + */ + public static function contexts(): iterable + { + yield 'a publishable component' => ['DummyPublishableComponent']; + yield 'a page' => ['Page']; + } +} From 476143b49d684c8e365575def231a18232fafccb Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Tue, 3 Nov 2026 08:23:18 +0000 Subject: [PATCH 2/3] Link changelog entry to #583 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f526da..d0d36618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio ## Unreleased ### Fixed -- `GET /_/contexts/` (the JSON-LD context) no longer fails with a TypeError in the publishable response listener ([#PRNUM](https://github.com/components-web-app/api-components-bundle/pull/PRNUM)) +- `GET /_/contexts/` (the JSON-LD context) no longer fails with a TypeError in the publishable response listener ([#583](https://github.com/components-web-app/api-components-bundle/pull/583)) ## [2.0.0-alpha.40](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.39...2.0.0-alpha.40) - 2026-10-31 From 32cba027dc8c913d322fafbc974e9bff82b86e99 Mon Sep 17 00:00:00 2001 From: silverbackdan Date: Tue, 3 Nov 2026 08:35:54 +0000 Subject: [PATCH 3/3] Pin each branch of PublishableEventListener's early return (#582) --- .../Api/PublishableEventListenerTest.php | 96 ++++++++++++++++--- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/tests/EventListener/Api/PublishableEventListenerTest.php b/tests/EventListener/Api/PublishableEventListenerTest.php index dbd0a255..6eaeda0b 100644 --- a/tests/EventListener/Api/PublishableEventListenerTest.php +++ b/tests/EventListener/Api/PublishableEventListenerTest.php @@ -12,12 +12,16 @@ namespace Silverback\ApiComponentsBundle\Tests\EventListener\Api; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Validator\ValidatorInterface; -use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader; +use Silverback\ApiComponentsBundle\Entity\Core\Layout; use Silverback\ApiComponentsBundle\EventListener\Api\PublishableEventListener; use Silverback\ApiComponentsBundle\Helper\Publishable\PublishableStatusChecker; +use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\DummyPublishableComponent; +use Silverback\ApiComponentsBundle\Tests\Helper\Publishable\PublishableDatabaseTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ResponseEvent; @@ -26,28 +30,98 @@ final class PublishableEventListenerTest extends TestCase { + use PublishableDatabaseTrait; + + private const string PUBLISHED_AT = '2999-01-01 00:00:00 UTC'; + + /** @var list */ + private array $checked = []; + + protected function setUp(): void + { + $this->setUpPublishableDatabase(); + $this->checked = []; + } + public function test_a_response_whose_data_is_not_an_object_and_has_no_resource_class_is_left_unchanged(): void { - $registry = $this->createStub(ManagerRegistry::class); - $authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class); - $authorizationChecker->expects(self::never())->method('isGranted'); + $response = $this->dispatch(['@context' => ['@vocab' => 'http://example.com/docs.jsonld#']], new Get()); + + self::assertSame(['cache-control' => ['no-cache, private']], $this->headersWithoutDate($response)); + self::assertSame([], $this->checked); + } + + public function test_a_resource_whose_class_is_not_publishable_is_left_unchanged(): void + { + $response = $this->dispatch(new Layout(), new Get()); + + self::assertSame(['cache-control' => ['no-cache, private']], $this->headersWithoutDate($response)); + self::assertSame([], $this->checked); + } + + public function test_a_publishable_resource_from_a_collection_operation_is_left_unchanged(): void + { + $response = $this->dispatch($this->scheduledComponent(), new GetCollection()); + + self::assertSame(['cache-control' => ['no-cache, private']], $this->headersWithoutDate($response)); + self::assertSame([], $this->checked); + } + + public function test_a_scheduled_publishable_resource_from_an_item_operation_expires_at_its_publication_date(): void + { + $response = $this->dispatch($this->scheduledComponent(), new Get()); + + self::assertSame([ + 'cache-control' => ['private, must-revalidate'], + 'expires' => ['Tue, 01 Jan 2999 00:00:00 GMT'], + ], $this->headersWithoutDate($response)); + self::assertSame(["is_granted('ROLE_ADMIN')"], $this->checked); + } + + private function scheduledComponent(): DummyPublishableComponent + { + $component = new DummyPublishableComponent(); + $component->setPublishedAt(new \DateTime(self::PUBLISHED_AT)); + + return $component; + } + + private function dispatch(mixed $data, Operation $operation): Response + { + $authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class); + $authorizationChecker->method('isGranted')->willReturnCallback(function (mixed $attribute): bool { + $this->checked[] = (string) $attribute; + + return false; + }); $validator = $this->createMock(ValidatorInterface::class); $validator->expects(self::never())->method('validate'); $listener = new PublishableEventListener( - new PublishableStatusChecker($registry, new PublishableAttributeReader($registry), $authorizationChecker, "is_granted('ROLE_ADMIN')"), - $registry, + new PublishableStatusChecker($this->registry, new PublishableAttributeReader($this->registry), $authorizationChecker, "is_granted('ROLE_ADMIN')"), + $this->registry, $validator, ); - $request = Request::create('/contexts/Page'); - $request->attributes->set('_api_operation', new Get()); - $request->attributes->set('data', ['@context' => ['@vocab' => 'http://example.com/docs.jsonld#']]); + $request = Request::create('/resource'); + $request->attributes->set('_api_operation', $operation); + $request->attributes->set('data', $data); $request->attributes->set('_api_normalization_context', []); $response = new Response('{}'); - $headersBefore = $response->headers->all(); $listener->onPostRespond(new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response)); - self::assertSame($headersBefore, $response->headers->all()); + return $response; + } + + /** + * @return array> + */ + private function headersWithoutDate(Response $response): array + { + $headers = $response->headers->all(); + unset($headers['date']); + ksort($headers); + + return $headers; } }