Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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\(\).#'
-
Expand Down
136 changes: 136 additions & 0 deletions src/Documentation/ApiCatalogFactory.php
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;

yield $iri;
}
}
}
}
}
3 changes: 2 additions & 1 deletion src/Documentation/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 21 additions & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 49 additions & 0 deletions src/Laravel/Controller/ApiCatalogController.php
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);
}
}
10 changes: 10 additions & 0 deletions src/Laravel/Controller/EntrypointController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -42,6 +45,7 @@ public function __construct(
private readonly ProviderInterface $provider,
private readonly ProcessorInterface $processor,
private readonly array $documentationFormats = [],
private readonly ?ApiCatalogFactory $apiCatalogFactory = null,
) {
}

Expand All @@ -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);
}

Expand Down
57 changes: 57 additions & 0 deletions src/Laravel/Tests/ApiCatalogTest.php
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']);
}
}
2 changes: 1 addition & 1 deletion src/Laravel/Tests/LinkHeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ public function testLinkHeader(): void
{
$response = $this->get('/api/', ['accept' => ['application/ld+json']]);
$response->assertStatus(200);
$response->assertHeader('link', '<http://localhost/api/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"');
$response->assertHeader('link', '<http://localhost/.well-known/api-catalog>; rel="api-catalog",<http://localhost/api/docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"');
}
}
3 changes: 2 additions & 1 deletion src/Laravel/Tests/LinkHeaderWithoutJsonldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<http://localhost/.well-known/api-catalog>; rel="api-catalog"');
}
}
6 changes: 6 additions & 0 deletions src/Laravel/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading