-
-
Notifications
You must be signed in to change notification settings - Fork 978
feat: Link-Template (RFC 9652), link sets (RFC 9264) and api-catalog (RFC 9727) #8468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 4.4
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the API Platform project. | ||
| * | ||
| * (c) Kévin Dunglas <dunglas@gmail.com> | ||
| * | ||
| * 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 <florent.morselli@spomky-labs.com> | ||
| */ | ||
| 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<string, string[]> $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<string> | ||
| */ | ||
| 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we really need this? imo it can be that there are several collection endpoints on a resource, not sure what the rfc says about this. |
||
|
|
||
| yield $iri; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we put this on main (5.0 target) maybe we can even remove 7.4 |
||
| }, | ||
| "extra": { | ||
| "branch-alias": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the API Platform project. | ||
| * | ||
| * (c) Kévin Dunglas <dunglas@gmail.com> | ||
| * | ||
| * 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 <florent.morselli@spomky-labs.com> | ||
| */ | ||
| 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the API Platform project. | ||
| * | ||
| * (c) Kévin Dunglas <dunglas@gmail.com> | ||
| * | ||
| * 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', '<http://localhost/.well-known/api-catalog>; 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']); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.