diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2015d5831b1..33fdad5a8fd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -46,6 +46,16 @@ parameters: - message: '#Symfony\\Component\\PropertyInfo\\Type#' identifier: class.notFound + # TODO: remove once "symfony/web-link" >= 8.2 is required, we fall back to our own implementation before that + - + message: '#Symfony\\Component\\WebLink\\LinkTemplateHeaderSerializer#' + identifier: class.notFound + path: src/State/Util/LinkTemplateHeaderSerializer.php + # TODO: remove once "symfony/web-link" >= 8.2 is required, we fall back to our own implementation before that + - + message: '#Symfony\\Component\\WebLink\\JsonLinksetSerializer#' + identifier: class.notFound + path: src/State/Util/JsonLinksetSerializer.php # False positives - message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\).#' - diff --git a/src/Documentation/ApiCatalogFactory.php b/src/Documentation/ApiCatalogFactory.php new file mode 100644 index 00000000000..2356140d0d8 --- /dev/null +++ b/src/Documentation/ApiCatalogFactory.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Documentation; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\WebLink\Link; + +/** + * Builds the links of the API catalog document. + * + * The catalog is anchored on the API entrypoint and advertises the machine-readable + * description of the API ("service-desc"), its human-readable documentation + * ("service-doc"), its metadata ("service-meta") and the exposed collections ("item"). + * + * @see https://www.rfc-editor.org/rfc/rfc9727.html + * @see https://www.rfc-editor.org/rfc/rfc8631.html + * + * @author Florent Morselli + */ +final class ApiCatalogFactory +{ + public const ROUTE_NAME = 'api_catalog'; + + /** + * The profile identifying an "application/linkset+json" document as an API catalog. + */ + public const PROFILE = 'https://www.rfc-editor.org/info/rfc9727'; + + /** + * Documentation formats, mapped to the link relation type they are described by. + */ + private const DOCUMENTATION_RELATIONS = [ + 'jsonopenapi' => 'service-desc', + 'yamlopenapi' => 'service-desc', + 'jsonld' => 'service-meta', + 'html' => 'service-doc', + ]; + + /** + * @param array $docsFormats + */ + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + private readonly IriConverterInterface $iriConverter, + private readonly UrlGeneratorInterface $urlGenerator, + private readonly array $docsFormats = [], + private readonly bool $docsEnabled = true, + ) { + } + + public function getUrl(): string + { + return $this->urlGenerator->generate(self::ROUTE_NAME, [], UrlGeneratorInterface::ABS_URL); + } + + /** + * @return Link[] + */ + public function create(): array + { + $catalog = $this->getUrl(); + $entrypoint = $this->urlGenerator->generate('api_entrypoint', [], UrlGeneratorInterface::ABS_URL); + + $links = [(new Link('item', $entrypoint))->withAttribute('anchor', $catalog)]; + + if ($this->docsEnabled) { + foreach (self::DOCUMENTATION_RELATIONS as $format => $rel) { + if (!$mimeTypes = $this->docsFormats[$format] ?? null) { + continue; + } + + // The human-readable documentation is content negotiated, the other ones are explicit + $parameters = 'html' === $format ? [] : ['_format' => $format]; + + $links[] = (new Link($rel, $this->urlGenerator->generate('api_doc', $parameters, UrlGeneratorInterface::ABS_URL))) + ->withAttribute('anchor', $entrypoint) + ->withAttribute('type', $mimeTypes[array_key_first($mimeTypes)]); + } + } + + foreach ($this->getCollectionIris() as $iri) { + $links[] = (new Link('item', $iri))->withAttribute('anchor', $entrypoint); + } + + return $links; + } + + /** + * @return iterable + */ + private function getCollectionIris(): iterable + { + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $seen = []; + + foreach ($this->resourceMetadataFactory->create($resourceClass) as $resource) { + foreach ($resource->getOperations() as $operation) { + $shortName = $resource->getShortName(); + + if (true === $operation->getHideHydraOperation() || !$operation instanceof CollectionOperationInterface || isset($seen[$shortName])) { + continue; + } + + try { + $iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_URL, $operation); + } catch (InvalidArgumentException|OperationNotFoundException) { + // Ignore resources without GET operations + continue; + } + + $seen[$shortName] = true; + + yield $iri; + } + } + } + } +} diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 70cf509e52a..3b2e8036c76 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,7 +21,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha" + "api-platform/metadata": "^4.4@alpha", + "symfony/web-link": "^7.4 || ^8.0" }, "extra": { "branch-alias": { diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 3ab77e2b615..6bf56e66c55 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel; +use ApiPlatform\Documentation\ApiCatalogFactory; use ApiPlatform\GraphQl\Error\ErrorHandler as GraphQlErrorHandler; use ApiPlatform\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\GraphQl\Executor; @@ -80,6 +81,7 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\ApiResource\ValidationError; +use ApiPlatform\Laravel\Controller\ApiCatalogController; use ApiPlatform\Laravel\Controller\DocumentationController; use ApiPlatform\Laravel\Controller\EntrypointController; use ApiPlatform\Laravel\Controller\NotExposedController; @@ -870,11 +872,29 @@ public function register(): void ); }); + $this->app->singleton(ApiCatalogFactory::class, static function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ApiCatalogFactory( + $app->make(ResourceNameCollectionFactoryInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(UrlGeneratorInterface::class), + $config->get('api-platform.docs_formats'), + $config->get('api-platform.enable_docs', true), + ); + }); + + $this->app->singleton(ApiCatalogController::class, static function (Application $app) { + return new ApiCatalogController($app->make(ApiCatalogFactory::class)); + }); + $this->app->singleton(EntrypointController::class, static function (Application $app) { /** @var ConfigRepository */ $config = $app['config']; - return new EntrypointController($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $config->get('api-platform.docs_formats')); + return new EntrypointController($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $config->get('api-platform.docs_formats'), $app->make(ApiCatalogFactory::class)); }); $this->app->singleton(Pagination::class, static function (Application $app) { diff --git a/src/Laravel/Controller/ApiCatalogController.php b/src/Laravel/Controller/ApiCatalogController.php new file mode 100644 index 00000000000..2c540f1dfc9 --- /dev/null +++ b/src/Laravel/Controller/ApiCatalogController.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Controller; + +use ApiPlatform\Documentation\ApiCatalogFactory; +use ApiPlatform\State\Util\JsonLinksetSerializer; +use Symfony\Component\HttpFoundation\Response; + +/** + * Serves the API catalog document at the "api-catalog" well-known URI. + * + * @see https://www.rfc-editor.org/rfc/rfc9727.html + * + * @author Florent Morselli + */ +final class ApiCatalogController +{ + public function __construct( + private readonly ApiCatalogFactory $apiCatalogFactory, + private readonly JsonLinksetSerializer $serializer = new JsonLinksetSerializer(), + ) { + } + + public function __invoke(): Response + { + $links = $this->apiCatalogFactory->create(); + + $headers = [ + 'Content-Type' => \sprintf('application/linkset+json; profile="%s"', ApiCatalogFactory::PROFILE), + // RFC 9727, section 2: a HEAD request is answered with the link relation of section 3 + 'Link' => \sprintf('<%s>; rel="api-catalog"', $this->apiCatalogFactory->getUrl()), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + ]; + + return new Response($this->serializer->serialize($links, \JSON_UNESCAPED_SLASHES), Response::HTTP_OK, $headers); + } +} diff --git a/src/Laravel/Controller/EntrypointController.php b/src/Laravel/Controller/EntrypointController.php index d013352c0b6..95bc9f43811 100644 --- a/src/Laravel/Controller/EntrypointController.php +++ b/src/Laravel/Controller/EntrypointController.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel\Controller; +use ApiPlatform\Documentation\ApiCatalogFactory; use ApiPlatform\Documentation\Entrypoint; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; @@ -22,6 +23,8 @@ use ApiPlatform\State\ProviderInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; /** * Generates the API entrypoint. @@ -42,6 +45,7 @@ public function __construct( private readonly ProviderInterface $provider, private readonly ProcessorInterface $processor, private readonly array $documentationFormats = [], + private readonly ?ApiCatalogFactory $apiCatalogFactory = null, ) { } @@ -64,6 +68,12 @@ class: Entrypoint::class, $body = $this->provider->provide($operation, [], $context); $operation = $request->attributes->get('_api_operation'); + // RFC 9727, section 3: point the clients to the API catalog, wherever it is mounted + if ($this->apiCatalogFactory) { + $linkProvider = $request->attributes->get('_api_platform_links') ?? new GenericLinkProvider(); + $request->attributes->set('_api_platform_links', $linkProvider->withLink(new Link('api-catalog', $this->apiCatalogFactory->getUrl()))); + } + return $this->processor->process($body, $operation, [], $context); } diff --git a/src/Laravel/Tests/ApiCatalogTest.php b/src/Laravel/Tests/ApiCatalogTest.php new file mode 100644 index 00000000000..c401b3f8f91 --- /dev/null +++ b/src/Laravel/Tests/ApiCatalogTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9727.html + */ +class ApiCatalogTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('app.debug', true); + $config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json'], 'html' => ['text/html']]); + }); + } + + public function testTheCatalogIsServedOutsideTheApiPrefix(): void + { + $response = $this->get('/.well-known/api-catalog'); + + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'); + $response->assertHeader('link', '; rel="api-catalog"'); + + $contexts = array_column($response->json('linkset'), null, 'anchor'); + + $this->assertSame([['href' => 'http://localhost/api']], $contexts['http://localhost/.well-known/api-catalog']['item']); + + $api = $contexts['http://localhost/api']; + $this->assertContains(['href' => 'http://localhost/api/docs.jsonld', 'type' => 'application/ld+json'], $api['service-meta']); + $this->assertContains(['href' => 'http://localhost/api/docs', 'type' => 'text/html'], $api['service-doc']); + } +} diff --git a/src/Laravel/Tests/LinkHeaderTest.php b/src/Laravel/Tests/LinkHeaderTest.php index 6a2ff5498f2..940e939327b 100644 --- a/src/Laravel/Tests/LinkHeaderTest.php +++ b/src/Laravel/Tests/LinkHeaderTest.php @@ -40,6 +40,6 @@ public function testLinkHeader(): void { $response = $this->get('/api/', ['accept' => ['application/ld+json']]); $response->assertStatus(200); - $response->assertHeader('link', '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"'); + $response->assertHeader('link', '; rel="api-catalog",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"'); } } diff --git a/src/Laravel/Tests/LinkHeaderWithoutJsonldTest.php b/src/Laravel/Tests/LinkHeaderWithoutJsonldTest.php index 97656a0c3b8..10a0069da05 100644 --- a/src/Laravel/Tests/LinkHeaderWithoutJsonldTest.php +++ b/src/Laravel/Tests/LinkHeaderWithoutJsonldTest.php @@ -40,6 +40,7 @@ public function testLinkHeader(): void { $response = $this->get('/api/', ['accept' => ['application/vnd.api+json']]); $response->assertStatus(200); - $response->assertHeaderMissing('link'); + // The Hydra documentation link is gone, only the RFC 9727 api-catalog one remains + $response->assertHeader('link', '; rel="api-catalog"'); } } diff --git a/src/Laravel/routes/api.php b/src/Laravel/routes/api.php index 72d337efcf5..1080046f44a 100644 --- a/src/Laravel/routes/api.php +++ b/src/Laravel/routes/api.php @@ -11,8 +11,10 @@ declare(strict_types=1); +use ApiPlatform\Documentation\ApiCatalogFactory; use ApiPlatform\JsonLd\Action\ContextAction; use ApiPlatform\Laravel\ApiPlatformMiddleware; +use ApiPlatform\Laravel\Controller\ApiCatalogController; use ApiPlatform\Laravel\Controller\ApiPlatformController; use ApiPlatform\Laravel\Controller\DocumentationController; use ApiPlatform\Laravel\Controller\EntrypointController; @@ -118,6 +120,10 @@ ->name('api_entrypoint'); }); }); + + // RFC 8615 roots well-known URIs at the host, so the API catalog lives outside the API prefix + Route::match(['GET', 'HEAD'], '/.well-known/api-catalog', ApiCatalogController::class) + ->name(ApiCatalogFactory::ROUTE_NAME); }); // MCP endpoint (outside the API prefix) diff --git a/src/State/Processor/AddLinkHeaderProcessor.php b/src/State/Processor/AddLinkHeaderProcessor.php index 036213374e9..88bd76647f1 100644 --- a/src/State/Processor/AddLinkHeaderProcessor.php +++ b/src/State/Processor/AddLinkHeaderProcessor.php @@ -17,6 +17,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\StopwatchAwareInterface; use ApiPlatform\State\StopwatchAwareTrait; +use ApiPlatform\State\Util\LinkTemplateHeaderSerializer; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\WebLink\HttpHeaderSerializer; @@ -33,7 +34,7 @@ final class AddLinkHeaderProcessor implements ProcessorInterface, StopwatchAware /** * @param ProcessorInterface $decorated */ - public function __construct(private readonly ProcessorInterface $decorated, private readonly ?HttpHeaderSerializer $serializer = new HttpHeaderSerializer()) + public function __construct(private readonly ProcessorInterface $decorated, private readonly ?HttpHeaderSerializer $serializer = new HttpHeaderSerializer(), private readonly ?LinkTemplateHeaderSerializer $templateSerializer = new LinkTemplateHeaderSerializer()) { } @@ -51,8 +52,15 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->stopwatch?->start('api_platform.processor.add_link_header'); // We add our header here as Symfony does it only for the main Request and we want it to be done on errors (sub-request) as well $linksProvider = $request->attributes->get('_api_platform_links'); - if ($this->serializer && ($links = $linksProvider?->getLinks())) { - $response->headers->set('Link', $this->serializer->serialize($links)); + if ($links = $linksProvider?->getLinks()) { + // Symfony's HttpHeaderSerializer skips templated links, they belong to the Link-Template header (RFC 9652) + if ($this->serializer && null !== ($header = $this->serializer->serialize($links))) { + $response->headers->set('Link', $header); + } + + if ($this->templateSerializer && null !== ($header = $this->templateSerializer->serialize($links))) { + $response->headers->set('Link-Template', $header); + } } $this->stopwatch?->stop('api_platform.processor.add_link_header'); diff --git a/src/State/Tests/Processor/AddLinkHeaderProcessorTest.php b/src/State/Tests/Processor/AddLinkHeaderProcessorTest.php index a18d7980f44..4ce15c3ee1d 100644 --- a/src/State/Tests/Processor/AddLinkHeaderProcessorTest.php +++ b/src/State/Tests/Processor/AddLinkHeaderProcessorTest.php @@ -17,6 +17,10 @@ use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\ProcessorInterface; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; class AddLinkHeaderProcessorTest extends TestCase { @@ -29,4 +33,48 @@ public function testWithoutLinks(): void $processor = new AddLinkHeaderProcessor($decorated); $this->assertEquals($data, $processor->process($data, $operation)); } + + public function testAddsTheLinkAndLinkTemplateHeaders(): void + { + $response = $this->process([ + new Link('http://www.w3.org/ns/hydra/core#apiDocumentation', '/docs.jsonld'), + (new Link('author', '/books/{book_id}/author'))->withAttribute('anchor', '#{book_id}'), + ]); + + $this->assertSame('; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', $response->headers->get('Link')); + $this->assertSame('"/books/{book_id}/author"; rel="author"; anchor="#{book_id}"', $response->headers->get('Link-Template')); + } + + public function testDoesNotAddALinkHeaderWhenEveryLinkIsTemplated(): void + { + $response = $this->process([new Link('item', '/books/{id}')]); + + $this->assertFalse($response->headers->has('Link')); + $this->assertSame('"/books/{id}"; rel="item"', $response->headers->get('Link-Template')); + } + + public function testDoesNotAddALinkTemplateHeaderWithoutTemplatedLinks(): void + { + $response = $this->process([new Link('preload', '/style.css')]); + + $this->assertSame('; rel="preload"', $response->headers->get('Link')); + $this->assertFalse($response->headers->has('Link-Template')); + } + + /** + * @param Link[] $links + */ + private function process(array $links): Response + { + $request = new Request(); + $request->attributes->set('_api_platform_links', new GenericLinkProvider($links)); + + $response = new Response(); + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($response); + + $processor = new AddLinkHeaderProcessor($decorated); + + return $processor->process(new \stdClass(), new Get(), [], ['request' => $request]); + } } diff --git a/src/State/Tests/Util/JsonLinksetSerializerTest.php b/src/State/Tests/Util/JsonLinksetSerializerTest.php new file mode 100644 index 00000000000..f9d45c0e6ef --- /dev/null +++ b/src/State/Tests/Util/JsonLinksetSerializerTest.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Util; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\State\Util\JsonLinksetSerializer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\WebLink\Link; + +/** + * @author Florent Morselli + */ +class JsonLinksetSerializerTest extends TestCase +{ + private JsonLinksetSerializer $serializer; + + protected function setUp(): void + { + $this->serializer = new JsonLinksetSerializer(); + } + + public function testSerializeEmpty(): void + { + $this->assertSame('{"linkset":[]}', $this->serializer->serialize([])); + } + + public function testSerializeSingleLink(): void + { + $links = [ + (new Link('next', 'https://example.com/foo'))->withAttribute('anchor', 'https://example.net/bar'), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "next": [{"href": "https://example.com/foo"}]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeGroupsLinksSharingTheSameContextAndRelation(): void + { + $links = [ + (new Link('item', 'https://example.com/foo1'))->withAttribute('anchor', 'https://example.net/bar'), + (new Link('item', 'https://example.com/foo2'))->withAttribute('anchor', 'https://example.net/bar'), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "item": [ + {"href": "https://example.com/foo1"}, + {"href": "https://example.com/foo2"} + ]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeSplitsDistinctContexts(): void + { + $links = [ + (new Link('next', 'https://example.com/foo1'))->withAttribute('anchor', 'https://example.net/bar'), + (new Link('https://example.com/relations/baz', 'https://example.com/foo2'))->withAttribute('anchor', 'https://example.net/boo'), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "next": [{"href": "https://example.com/foo1"}]}, + {"anchor": "https://example.net/boo", "https://example.com/relations/baz": [{"href": "https://example.com/foo2"}]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeWithoutAnchorOmitsTheContext(): void + { + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"next": [{"href": "https://example.com/foo"}]} + ]} + JSON, $this->serializer->serialize([new Link('next', 'https://example.com/foo')])); + } + + public function testSerializeDistinguishesAnEmptyAnchorFromNoAnchor(): void + { + $links = [ + (new Link('next', 'https://example.com/foo'))->withAttribute('anchor', ''), + new Link('next', 'https://example.com/bar'), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "", "next": [{"href": "https://example.com/foo"}]}, + {"next": [{"href": "https://example.com/bar"}]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeNumericAnchor(): void + { + $links = [(new Link('next', 'https://example.com/foo'))->withAttribute('anchor', '123')]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"anchor":"123","next":[{"href":"https://example.com/foo"}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeRepeatsMultipleRelations(): void + { + $links = [(new Link('alternate', 'https://example.com/foo'))->withRel('next')]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + { + "alternate": [{"href": "https://example.com/foo"}], + "next": [{"href": "https://example.com/foo"}] + } + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeTargetAttributesDefinedByWebLinking(): void + { + $links = [ + (new Link('next', 'https://example.com/foo')) + ->withAttribute('anchor', 'https://example.net/bar') + ->withAttribute('type', 'text/html') + ->withAttribute('hreflang', ['en', 'de']) + ->withAttribute('media', 'screen'), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "next": [{ + "href": "https://example.com/foo", + "type": "text/html", + "hreflang": ["en", "de"], + "media": "screen" + }]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeWrapsSingleValuedHreflangInAnArray(): void + { + $links = [(new Link('next', 'https://example.com/foo'))->withAttribute('hreflang', 'en')]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/foo","hreflang":["en"]}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeKeepsOnlyTheFirstValueOfNonRepeatableAttributes(): void + { + $links = [(new Link('next', 'https://example.com/foo'))->withAttribute('type', ['text/html', 'text/plain'])]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/foo","type":"text/html"}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeInternationalizedTargetAttributes(): void + { + $links = [ + (new Link('next', 'https://example.com/foo')) + ->withAttribute('anchor', 'https://example.net/bar') + ->withAttribute('title', 'Next chapter') + ->withAttribute('title*', "UTF-8'de'n%c3%a4chstes%20Kapitel"), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "next": [{ + "href": "https://example.com/foo", + "title": "Next chapter", + "title*": [{"value": "nächstes Kapitel", "language": "de"}] + }]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeInternationalizedTargetAttributeWithoutLanguage(): void + { + $links = [(new Link('next', 'https://example.com/foo'))->withAttribute('title*', "UTF-8''Next%20chapter")]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/foo","title*":[{"value":"Next chapter"}]}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeExtensionTargetAttributesAsArrays(): void + { + $links = [ + (new Link('next', 'https://example.com/foo')) + ->withAttribute('anchor', 'https://example.net/bar') + ->withAttribute('type', 'text/html') + ->withAttribute('foo', 'foovalue') + ->withAttribute('bar', ['barone', 'bartwo']) + ->withAttribute('baz*', "UTF-8'en'bazvalue"), + ]; + + $this->assertJsonStringEqualsJsonString(<<<'JSON' + {"linkset": [ + {"anchor": "https://example.net/bar", "next": [{ + "href": "https://example.com/foo", + "type": "text/html", + "foo": ["foovalue"], + "bar": ["barone", "bartwo"], + "baz*": [{"value": "bazvalue", "language": "en"}] + }]} + ]} + JSON, $this->serializer->serialize($links)); + } + + public function testSerializeBooleanAttributes(): void + { + $links = [ + (new Link('preload', 'https://example.com/foo')) + ->withAttribute('nopush', true) + ->withAttribute('nofollow', false), + ]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"preload":[{"href":"https://example.com/foo","nopush":[""]}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeSkipsTemplatedLinks(): void + { + $links = [ + new Link('item', 'https://example.com/users/{id}'), + new Link('next', 'https://example.com/foo'), + ]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/foo"}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeSkipsLinksWithoutARelationType(): void + { + $links = [ + new Link(null, 'https://example.com/foo'), + new Link('next', 'https://example.com/bar'), + ]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/bar"}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeTreatsAFalseAnchorAsAbsent(): void + { + $links = [(new Link('next', 'https://example.com/foo'))->withAttribute('anchor', false)]; + + $this->assertJsonStringEqualsJsonString('{"linkset":[{"next":[{"href":"https://example.com/foo"}]}]}', $this->serializer->serialize($links)); + } + + public function testSerializeThrowsOnTheAnchorRelationType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A link with the "anchor" relation type cannot be represented in an "application/linkset+json" document.'); + + $this->serializer->serialize([(new Link('anchor', 'https://example.com/foo'))->withAttribute('anchor', 'https://example.net/bar')]); + } + + public function testSerializeThrowsWhenTheLinksCannotBeEncoded(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The link set cannot be serialized to JSON: "Malformed UTF-8 characters, possibly incorrectly encoded".'); + + $this->serializer->serialize([(new Link('next', '/foo'))->withAttribute('title', "\xB1\x31")]); + } + + public function testSerializeThrowsWhenTheLinksCannotBeEncodedWhateverTheFlags(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->serializer->serialize([(new Link('next', '/foo'))->withAttribute('title', "\xB1\x31")], \JSON_THROW_ON_ERROR); + } + + public function testSerializeForwardsJsonEncodeFlags(): void + { + $links = [new Link('next', 'https://example.com/foo')]; + + $this->assertStringContainsString('https://example.com/foo', $this->serializer->serialize($links, \JSON_UNESCAPED_SLASHES)); + $this->assertStringContainsString('https:\/\/example.com\/foo', $this->serializer->serialize($links)); + } +} diff --git a/src/State/Tests/Util/LinkTemplateHeaderSerializerTest.php b/src/State/Tests/Util/LinkTemplateHeaderSerializerTest.php new file mode 100644 index 00000000000..3d35cfeb01f --- /dev/null +++ b/src/State/Tests/Util/LinkTemplateHeaderSerializerTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Util; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\State\Util\LinkTemplateHeaderSerializer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\WebLink\Link; + +/** + * @author Florent Morselli + */ +class LinkTemplateHeaderSerializerTest extends TestCase +{ + private LinkTemplateHeaderSerializer $serializer; + + protected function setUp(): void + { + $this->serializer = new LinkTemplateHeaderSerializer(); + } + + public function testSerializeEmpty(): void + { + $this->assertNull($this->serializer->serialize([])); + } + + public function testSerialize(): void + { + $this->assertSame('"/{username}"; rel="item"', $this->serializer->serialize([new Link('item', '/{username}')])); + } + + public function testSerializeSeveralTemplates(): void + { + $links = [ + new Link('item', '/{username}'), + (new Link('alternate', '/{username}{?format}'))->withRel('next'), + ]; + + $this->assertSame('"/{username}"; rel="item", "/{username}{?format}"; rel="alternate next"', $this->serializer->serialize($links)); + } + + public function testSerializeTemplatedAnchor(): void + { + $links = [(new Link('author', '/books/{book_id}/author'))->withAttribute('anchor', '#{book_id}')]; + + $this->assertSame('"/books/{book_id}/author"; rel="author"; anchor="#{book_id}"', $this->serializer->serialize($links)); + } + + public function testSerializeVarBase(): void + { + $links = [ + (new Link('https://example.org/rel/widget', '/widgets/{widget_id}')) + ->withAttribute('var-base', 'https://example.org/vars/'), + ]; + + $this->assertSame('"/widgets/{widget_id}"; rel="https://example.org/rel/widget"; var-base="https://example.org/vars/"', $this->serializer->serialize($links)); + } + + public function testSerializeNonAsciiAttributeAsADisplayString(): void + { + $links = [(new Link('author', '/authors/{id}'))->withAttribute('title', 'Björn Järnsida')]; + + $this->assertSame('"/authors/{id}"; rel="author"; title=%"Bj%c3%b6rn J%c3%a4rnsida"', $this->serializer->serialize($links)); + } + + public function testSerializeEscapesStrings(): void + { + $links = [(new Link('item', '/{id}'))->withAttribute('title', 'a "quoted" \\ value')]; + + $this->assertSame('"/{id}"; rel="item"; title="a \"quoted\" \\\\ value"', $this->serializer->serialize($links)); + } + + public function testSerializeNonPrintableAttributeAsADisplayString(): void + { + $links = [(new Link('item', '/{id}'))->withAttribute('title', "Hello\n")]; + + $this->assertSame('"/{id}"; rel="item"; title=%"Hello%0a"', $this->serializer->serialize($links)); + } + + public function testSerializeBooleanAttributes(): void + { + $links = [ + (new Link('item', '/{id}')) + ->withAttribute('nopush', true) + ->withAttribute('nofollow', false), + ]; + + $this->assertSame('"/{id}"; rel="item"; nopush', $this->serializer->serialize($links)); + } + + public function testSerializeRepeatedAttributes(): void + { + $links = [(new Link('item', '/{id}'))->withAttribute('hreflang', ['fr', 'de'])]; + + $this->assertSame('"/{id}"; rel="item"; hreflang="fr"; hreflang="de"', $this->serializer->serialize($links)); + } + + public function testSerializeLowercasesAttributeNames(): void + { + $links = [(new Link('item', '/{id}'))->withAttribute('Title', 'Hello')]; + + $this->assertSame('"/{id}"; rel="item"; title="Hello"', $this->serializer->serialize($links)); + } + + public function testSerializeWithoutRel(): void + { + $this->assertSame('"/{id}"', $this->serializer->serialize([new Link(null, '/{id}')])); + } + + public function testSerializeSkipsLinksThatAreNotTemplated(): void + { + $links = [ + new Link('preload', '/style.css'), + new Link('item', '/{id}'), + ]; + + $this->assertSame('"/{id}"; rel="item"', $this->serializer->serialize($links)); + } + + public function testSerializeThrowsOnAnInvalidParameterKey(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "0invalid" target attribute cannot be serialized as a structured field parameter key.'); + + $this->serializer->serialize([(new Link('item', '/{id}'))->withAttribute('0invalid', 'value')]); + } + + public function testSerializeThrowsOnANonUtf8Value(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Non-ASCII strings must be encoded in UTF-8 to be serialized as structured field display strings.'); + + $this->serializer->serialize([(new Link('item', '/{id}'))->withAttribute('title', "Bj\xF6rn")]); + } +} diff --git a/src/State/Util/JsonLinksetSerializer.php b/src/State/Util/JsonLinksetSerializer.php new file mode 100644 index 00000000000..f729ef84c41 --- /dev/null +++ b/src/State/Util/JsonLinksetSerializer.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Util; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use Psr\Link\LinkInterface; +use Symfony\Component\WebLink\JsonLinksetSerializer as SymfonyJsonLinksetSerializer; + +/** + * Serializes a list of Link instances to an "application/linkset+json" document. + * + * Links are grouped by link context ("anchor" attribute), then by relation type. + * Templated links are skipped, as the format conveys URI references only. + * + * Delegates to symfony/web-link 8.2 and later, and falls back to the + * implementation below otherwise. + * + * @see https://www.rfc-editor.org/rfc/rfc9264.html + * + * @author Florent Morselli + */ +final class JsonLinksetSerializer +{ + /** + * Target attributes that RFC 8288 defines as non-repeatable strings. + * + * TODO: remove once "symfony/web-link" >= 8.2 is required + */ + private const SINGLE_VALUED_ATTRIBUTES = ['media', 'title', 'type']; + + private readonly ?SymfonyJsonLinksetSerializer $inner; + + public function __construct() + { + $this->inner = class_exists(SymfonyJsonLinksetSerializer::class) ? new SymfonyJsonLinksetSerializer() : null; + } + + /** + * Builds an "application/linkset+json" document. + * + * @param LinkInterface[]|\Traversable $links + * @param int $flags Bitmask of json_encode() options + * + * @throws InvalidArgumentException when a link has the "anchor" relation type or when the links cannot be encoded to JSON + */ + public function serialize(iterable $links, int $flags = 0): string + { + if ($this->inner) { + try { + return $this->inner->serialize($links, $flags); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } + + // TODO: remove everything below once "symfony/web-link" >= 8.2 is required, this class then only wraps the Symfony one + $contexts = []; + + foreach ($links as $link) { + if ($link->isTemplated()) { + continue; + } + + $attributes = $link->getAttributes(); + $anchor = $attributes['anchor'] ?? null; + unset($attributes['anchor']); + + if (\is_array($anchor)) { + $anchor = [] === $anchor ? null : $anchor[array_key_first($anchor)]; + } + $anchor = null === $anchor || false === $anchor ? null : self::stringify($anchor); + + $target = ['href' => $link->getHref()] + self::serializeAttributes($attributes); + + $context = null === $anchor ? '' : "\0".$anchor; + + foreach ($link->getRels() as $rel) { + if ('anchor' === $rel) { + throw new InvalidArgumentException('A link with the "anchor" relation type cannot be represented in an "application/linkset+json" document.'); + } + + $contexts[$context]['anchor'] = $anchor; + $contexts[$context]['rels'][$rel][] = $target; + } + } + + $linkset = []; + foreach ($contexts as $context) { + $linkset[] = (null === $context['anchor'] ? [] : ['anchor' => $context['anchor']]) + $context['rels']; + } + + try { + return json_encode(['linkset' => $linkset], \JSON_THROW_ON_ERROR | $flags); + } catch (\JsonException $e) { + throw new InvalidArgumentException(\sprintf('The link set cannot be serialized to JSON: "%s".', $e->getMessage()), previous: $e); + } + } + + /** + * TODO: remove once "symfony/web-link" >= 8.2 is required. + * + * @param array> $attributes + * + * @return array + */ + private static function serializeAttributes(array $attributes): array + { + $target = []; + + foreach ($attributes as $key => $value) { + if (false === $value) { + continue; + } + + $values = \is_array($value) ? array_values($value) : [$value]; + + if (str_ends_with($key, '*')) { + $target[$key] = array_map(self::decodeExtendedValue(...), $values); + + continue; + } + + if (\in_array($key, self::SINGLE_VALUED_ATTRIBUTES, true)) { + $target[$key] = self::stringify($values[0] ?? ''); + + continue; + } + + $target[$key] = array_map(self::stringify(...), $values); + } + + return $target; + } + + /** + * Splits an RFC 8187 encoded value into its unescaped content and its language tag. + * + * TODO: remove once "symfony/web-link" >= 8.2 is required + * + * @return array{value: string, language?: string} + */ + private static function decodeExtendedValue(mixed $value): array + { + $value = self::stringify($value); + $parts = explode("'", $value, 3); + + if (3 !== \count($parts)) { + return ['value' => $value]; + } + + [, $language, $encoded] = $parts; + $decoded = rawurldecode($encoded); + + return '' === $language ? ['value' => $decoded] : ['value' => $decoded, 'language' => $language]; + } + + /** + * TODO: remove once "symfony/web-link" >= 8.2 is required. + */ + private static function stringify(mixed $value): string + { + return true === $value ? '' : (string) $value; + } +} diff --git a/src/State/Util/LinkTemplateHeaderSerializer.php b/src/State/Util/LinkTemplateHeaderSerializer.php new file mode 100644 index 00000000000..115e720f643 --- /dev/null +++ b/src/State/Util/LinkTemplateHeaderSerializer.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Util; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use Psr\Link\LinkInterface; +use Symfony\Component\WebLink\LinkTemplateHeaderSerializer as SymfonyLinkTemplateHeaderSerializer; + +/** + * Serializes a list of Link instances to an HTTP Link-Template header. + * + * Only templated links are serialized: the others belong to the Link header, + * where Symfony's HttpHeaderSerializer silently drops them. + * Repeated target attributes are serialized as repeated structured field + * parameters, of which only the last one is significant. + * + * Delegates to symfony/web-link 8.2 and later, and falls back to the + * implementation below otherwise. + * + * @see https://www.rfc-editor.org/rfc/rfc9652.html + * + * @author Florent Morselli + */ +final class LinkTemplateHeaderSerializer +{ + /** + * Structured field parameter key, as defined by RFC 9651. + * + * TODO: remove once "symfony/web-link" >= 8.2 is required + */ + private const KEY_PATTERN = '/^[a-z*][a-z0-9_.*-]*$/'; + + private readonly ?SymfonyLinkTemplateHeaderSerializer $inner; + + public function __construct() + { + $this->inner = class_exists(SymfonyLinkTemplateHeaderSerializer::class) ? new SymfonyLinkTemplateHeaderSerializer() : null; + } + + /** + * Builds the value of the "Link-Template" HTTP header. + * + * @param LinkInterface[]|\Traversable $links + * + * @throws InvalidArgumentException when a target attribute cannot be serialized as a structured field parameter key + * or when a non-ASCII value is not encoded in UTF-8 + */ + public function serialize(iterable $links): ?string + { + if ($this->inner) { + try { + return $this->inner->serialize($links); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); + } + } + + // TODO: remove everything below once "symfony/web-link" >= 8.2 is required, this class then only wraps the Symfony one + $elements = []; + + foreach ($links as $link) { + if (!$link->isTemplated()) { + continue; + } + + $parts = [self::serializeString($link->getHref())]; + + if ($rels = $link->getRels()) { + $parts[] = 'rel='.self::serializeString(implode(' ', $rels)); + } + + foreach ($link->getAttributes() as $key => $value) { + $key = strtolower($key); + + if (!preg_match(self::KEY_PATTERN, $key)) { + throw new InvalidArgumentException(\sprintf('The "%s" target attribute cannot be serialized as a structured field parameter key.', $key)); + } + + foreach (\is_array($value) ? $value : [$value] as $item) { + if (false === $item) { + continue; + } + + $parts[] = true === $item ? $key : $key.'='.self::serializeString((string) $item); + } + } + + $elements[] = implode('; ', $parts); + } + + return $elements ? implode(', ', $elements) : null; + } + + /** + * Serializes a string as a structured field String, or as a Display String when it holds non-ASCII characters. + * + * TODO: remove once "symfony/web-link" >= 8.2 is required + */ + private static function serializeString(string $value): string + { + if (preg_match('/^[\x20-\x7E]*$/D', $value)) { + return '"'.addcslashes($value, '"\\').'"'; + } + + if (!preg_match('//u', $value)) { + throw new InvalidArgumentException('Non-ASCII strings must be encoded in UTF-8 to be serialized as structured field display strings.'); + } + + return '%"'.preg_replace_callback('/[%"\x00-\x1F\x7F-\xFF]/', static fn (array $m): string => \sprintf('%%%02x', \ord($m[0])), $value).'"'; + } +} diff --git a/src/Symfony/Action/ApiCatalogAction.php b/src/Symfony/Action/ApiCatalogAction.php new file mode 100644 index 00000000000..2179bad0fe6 --- /dev/null +++ b/src/Symfony/Action/ApiCatalogAction.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Action; + +use ApiPlatform\Documentation\ApiCatalogFactory; +use ApiPlatform\State\Util\JsonLinksetSerializer; +use Symfony\Component\HttpFoundation\Response; + +/** + * Serves the API catalog document at the "api-catalog" well-known URI. + * + * @see https://www.rfc-editor.org/rfc/rfc9727.html + * + * @author Florent Morselli + */ +final class ApiCatalogAction +{ + public function __construct( + private readonly ApiCatalogFactory $apiCatalogFactory, + private readonly JsonLinksetSerializer $serializer = new JsonLinksetSerializer(), + ) { + } + + public function __invoke(): Response + { + $links = $this->apiCatalogFactory->create(); + + $headers = [ + 'Content-Type' => \sprintf('application/linkset+json; profile="%s"', ApiCatalogFactory::PROFILE), + // RFC 9727, section 2: a HEAD request is answered with the link relation of section 3 + 'Link' => \sprintf('<%s>; rel="api-catalog"', $this->apiCatalogFactory->getUrl()), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + ]; + + return new Response($this->serializer->serialize($links, \JSON_UNESCAPED_SLASHES), Response::HTTP_OK, $headers); + } +} diff --git a/src/Symfony/Action/EntrypointAction.php b/src/Symfony/Action/EntrypointAction.php index aa43e9afbc2..dc4dbea2f8d 100644 --- a/src/Symfony/Action/EntrypointAction.php +++ b/src/Symfony/Action/EntrypointAction.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Symfony\Action; +use ApiPlatform\Documentation\ApiCatalogFactory; use ApiPlatform\Documentation\Entrypoint; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; @@ -21,6 +22,8 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; /** * Generates the API entrypoint. @@ -36,6 +39,7 @@ public function __construct( private readonly ProviderInterface $provider, private readonly ProcessorInterface $processor, private readonly array $documentationFormats = [], + private readonly ?ApiCatalogFactory $apiCatalogFactory = null, ) { } @@ -58,6 +62,12 @@ class: Entrypoint::class, $body = $this->provider->provide($operation, [], $context); $operation = $request->attributes->get('_api_operation'); + // RFC 9727, section 3: point the clients to the API catalog, wherever it is mounted + if ($this->apiCatalogFactory) { + $linkProvider = $request->attributes->get('_api_platform_links') ?? new GenericLinkProvider(); + $request->attributes->set('_api_platform_links', $linkProvider->withLink(new Link('api-catalog', $this->apiCatalogFactory->getUrl()))); + } + return $this->processor->process($body, $operation, [], $context); } diff --git a/src/Symfony/Bundle/Resources/config/routing/api_catalog.php b/src/Symfony/Bundle/Resources/config/routing/api_catalog.php new file mode 100644 index 00000000000..8d9cba3b546 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/routing/api_catalog.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\Routing\Loader\Configurator; + +use ApiPlatform\Documentation\ApiCatalogFactory; + +return static function (RoutingConfigurator $routes) { + $routes->add(ApiCatalogFactory::ROUTE_NAME, '/.well-known/api-catalog') + ->controller('api_platform.action.api_catalog') + ->methods(['GET', 'HEAD']); +}; diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 3bbed6d6106..3b0a58b3689 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -13,6 +13,8 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Documentation\ApiCatalogFactory; +use ApiPlatform\Symfony\Action\ApiCatalogAction; use ApiPlatform\Symfony\Action\DocumentationAction; use ApiPlatform\Symfony\Action\EntrypointAction; use ApiPlatform\Symfony\Controller\MainController; @@ -37,8 +39,23 @@ service('api_platform.state_provider.main'), service('api_platform.state_processor.main'), '%api_platform.entrypoint_formats%', + service('api_platform.documentation.api_catalog_factory'), ]); + $services->set('api_platform.documentation.api_catalog_factory', ApiCatalogFactory::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.iri_converter'), + service('api_platform.router'), + '%api_platform.docs_formats%', + '%api_platform.enable_docs%', + ]); + + $services->set('api_platform.action.api_catalog', ApiCatalogAction::class) + ->public() + ->args([service('api_platform.documentation.api_catalog_factory')]); + $services->set('api_platform.action.documentation', DocumentationAction::class) ->public() ->args([ diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index ae428bb459d..9ee56f37dac 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Documentation\ApiCatalogFactory; use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\Processor\RespondProcessor; @@ -22,6 +23,7 @@ use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; +use ApiPlatform\Symfony\Action\ApiCatalogAction; use ApiPlatform\Symfony\Action\DocumentationAction; use ApiPlatform\Symfony\Action\EntrypointAction; use ApiPlatform\Symfony\Action\PlaceholderAction; @@ -198,8 +200,23 @@ service('api_platform.state_provider.documentation'), service('api_platform.state_processor.documentation'), '%api_platform.entrypoint_formats%', + service('api_platform.documentation.api_catalog_factory'), ]); + $services->set('api_platform.documentation.api_catalog_factory', ApiCatalogFactory::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.iri_converter'), + service('api_platform.router'), + '%api_platform.docs_formats%', + '%api_platform.enable_docs%', + ]); + + $services->set('api_platform.action.api_catalog', ApiCatalogAction::class) + ->public() + ->args([service('api_platform.documentation.api_catalog_factory')]); + $services->set('api_platform.action.documentation', DocumentationAction::class) ->public() ->args([ diff --git a/src/Symfony/Routing/ApiLoader.php b/src/Symfony/Routing/ApiLoader.php index 1fa70ab2963..e6ca0b6b8ab 100644 --- a/src/Symfony/Routing/ApiLoader.php +++ b/src/Symfony/Routing/ApiLoader.php @@ -153,6 +153,7 @@ private function loadExternalFiles(RouteCollection $routeCollection): void if ($this->entrypointEnabled) { $routeCollection->addCollection($this->fileLoader->load('api.php')); + $routeCollection->addCollection($this->fileLoader->load('api_catalog.php')); } if ($this->graphqlEnabled) { diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index a5d29b5b544..4d80af5688d 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -46,6 +46,7 @@ "symfony/property-access": "^7.4 || ^8.0", "symfony/serializer": "^7.4 || ^8.0", "symfony/security-core": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { diff --git a/tests/Fixtures/TestBundle/ApiResource/ApiCatalogResource.php b/tests/Fixtures/TestBundle/ApiResource/ApiCatalogResource.php new file mode 100644 index 00000000000..b7038bd8397 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ApiCatalogResource.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + operations: [ + new GetCollection(uriTemplate: 'api_catalog_resources', provider: [self::class, 'provideCollection']), + ], + graphQlOperations: [] +)] +class ApiCatalogResource +{ + public int $id; + + /** + * @return self[] + */ + public static function provideCollection(): array + { + $s = new self(); + $s->id = 1; + + return [$s]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkTemplateResource.php b/tests/Fixtures/TestBundle/ApiResource/LinkTemplateResource.php new file mode 100644 index 00000000000..46e2e3efb59 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkTemplateResource.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\WebLink\Link as WebLink; + +/** + * Templated links belong to the Link-Template header (RFC 9652), the others to the Link header (RFC 8288). + */ +#[ApiResource( + operations: [ + new Get( + uriTemplate: 'link_templates/{id}', + links: [ + new WebLink('describedby', '/docs.jsonld'), + new WebLink('author', '/link_templates/{id}/author'), + ], + provider: [self::class, 'provide'], + ), + ], + graphQlOperations: [] +)] +class LinkTemplateResource +{ + public int $id; + + public static function provide(): self + { + $s = new self(); + $s->id = 1; + + return $s; + } +} diff --git a/tests/Functional/ApiCatalogTest.php b/tests/Functional/ApiCatalogTest.php new file mode 100644 index 00000000000..477d849c9a8 --- /dev/null +++ b/tests/Functional/ApiCatalogTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ApiCatalogResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9727.html + */ +final class ApiCatalogTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ApiCatalogResource::class]; + } + + public function testTheCatalogIsALinksetDocument(): void + { + $response = self::createClient()->request('GET', '/.well-known/api-catalog'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'); + + $linkset = json_decode($response->getContent(), true, flags: \JSON_THROW_ON_ERROR)['linkset']; + $contexts = array_column($linkset, null, 'anchor'); + + $this->assertArrayHasKey('http://localhost/.well-known/api-catalog', $contexts); + $this->assertSame([['href' => 'http://localhost/']], $contexts['http://localhost/.well-known/api-catalog']['item']); + + $api = $contexts['http://localhost/']; + $this->assertContains(['href' => 'http://localhost/api_catalog_resources'], $api['item']); + $this->assertContains(['href' => 'http://localhost/docs.jsonopenapi', 'type' => 'application/vnd.openapi+json'], $api['service-desc']); + $this->assertContains(['href' => 'http://localhost/docs', 'type' => 'text/html'], $api['service-doc']); + $this->assertContains(['href' => 'http://localhost/docs.jsonld', 'type' => 'application/ld+json'], $api['service-meta']); + } + + public function testTheEntrypointAdvertisesTheCatalog(): void + { + $response = self::createClient()->request('GET', '/'); + + $this->assertResponseStatusCodeSame(200); + $this->assertStringContainsString('; rel="api-catalog"', $response->getHeaders()['link'][0] ?? ''); + } + + public function testTheCatalogAdvertisesItselfWithTheApiCatalogRelation(): void + { + $response = self::createClient()->request('HEAD', '/.well-known/api-catalog'); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame('; rel="api-catalog"', $response->getHeaders()['link'][0] ?? null); + } +} diff --git a/tests/Functional/LinkTemplateHeaderTest.php b/tests/Functional/LinkTemplateHeaderTest.php new file mode 100644 index 00000000000..346e0ec9777 --- /dev/null +++ b/tests/Functional/LinkTemplateHeaderTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkTemplateResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class LinkTemplateHeaderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [LinkTemplateResource::class]; + } + + public function testTemplatedLinksAreSentInTheLinkTemplateHeader(): void + { + $response = self::createClient()->request('GET', '/link_templates/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Link-Template', '"/link_templates/{id}/author"; rel="author"'); + + $link = $response->getHeaders()['link'][0] ?? ''; + $this->assertStringContainsString('; rel="describedby"', $link); + $this->assertStringNotContainsString('link_templates/{id}/author', $link); + } +}