Skip to content

Commit 3c554a6

Browse files
authored
fix(laravel): docs _format and open swagger ui (#6595)
1 parent c9f18d4 commit 3c554a6

File tree

15 files changed

+290
-124
lines changed

15 files changed

+290
-124
lines changed

src/Documentation/Action/DocumentationAction.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ class: OpenApi::class,
9898
);
9999

100100
if ('html' === $format) {
101-
// TODO: support laravel this bounds Documentation with Symfony so it's not perfect
102101
$operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true);
103102
}
104103

src/Laravel/ApiPlatformProvider.php

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313

1414
namespace ApiPlatform\Laravel;
1515

16-
use ApiPlatform\Documentation\Action\DocumentationAction;
17-
use ApiPlatform\Documentation\Action\EntrypointAction;
1816
use ApiPlatform\GraphQl\Error\ErrorHandler as GraphQlErrorHandler;
1917
use ApiPlatform\GraphQl\Error\ErrorHandlerInterface;
2018
use ApiPlatform\GraphQl\Executor;
@@ -72,6 +70,8 @@
7270
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
7371
use ApiPlatform\Laravel\ApiResource\Error;
7472
use ApiPlatform\Laravel\Controller\ApiPlatformController;
73+
use ApiPlatform\Laravel\Controller\DocumentationController;
74+
use ApiPlatform\Laravel\Controller\EntrypointController;
7575
use ApiPlatform\Laravel\Eloquent\Extension\FilterQueryExtension;
7676
use ApiPlatform\Laravel\Eloquent\Extension\QueryExtensionInterface;
7777
use ApiPlatform\Laravel\Eloquent\Filter\DateFilter;
@@ -321,6 +321,11 @@ public function register(): void
321321
$this->app->singleton(ResourceMetadataCollectionFactoryInterface::class, function (Application $app) {
322322
/** @var ConfigRepository */
323323
$config = $app['config'];
324+
$formats = $config->get('api-platform.formats');
325+
326+
if ($config->get('api-platform.swagger_ui.enabled', false) && !isset($formats['html'])) {
327+
$formats['html'] = ['text/html'];
328+
}
324329

325330
return new CacheResourceCollectionMetadataFactory(
326331
new EloquentResourceCollectionMetadataFactory(
@@ -361,7 +366,7 @@ public function register(): void
361366
)
362367
)
363368
),
364-
$config->get('api-platform.formats'),
369+
$formats,
365370
$config->get('api-platform.patch_formats'),
366371
)
367372
)
@@ -417,7 +422,10 @@ public function register(): void
417422
});
418423

419424
$this->app->singleton(SwaggerUiProvider::class, function (Application $app) {
420-
return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class));
425+
/** @var ConfigRepository */
426+
$config = $app['config'];
427+
428+
return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false));
421429
});
422430

423431
$this->app->singleton(ValidateProvider::class, function (Application $app) {
@@ -476,9 +484,14 @@ public function register(): void
476484

477485
$this->app->tag([RemoveProcessor::class, PersistProcessor::class], ProcessorInterface::class);
478486
$this->app->singleton(CallableProcessor::class, function (Application $app) {
487+
/** @var ConfigRepository */
488+
$config = $app['config'];
479489
$tagged = iterator_to_array($app->tagged(ProcessorInterface::class));
480-
// TODO: tag SwaggerUiProcessor instead?
481-
$tagged['api_platform.swagger_ui.processor'] = $app->make(SwaggerUiProcessor::class);
490+
491+
if ($config->get('api-platform.swagger_ui.enabled', false)) {
492+
// TODO: tag SwaggerUiProcessor instead?
493+
$tagged['api_platform.swagger_ui.processor'] = $app->make(SwaggerUiProcessor::class);
494+
}
482495

483496
return new CallableProcessor(new ServiceLocator($tagged));
484497
});
@@ -628,18 +641,18 @@ public function register(): void
628641
return new Options(title: $config->get('api-platform.title') ?? '');
629642
});
630643

631-
$this->app->singleton(DocumentationAction::class, function (Application $app) {
644+
$this->app->singleton(DocumentationController::class, function (Application $app) {
632645
/** @var ConfigRepository */
633646
$config = $app['config'];
634647

635-
return new DocumentationAction($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'));
648+
return new DocumentationController($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'), $config->get('api-platform.swagger_ui.enabled', false));
636649
});
637650

638-
$this->app->singleton(EntrypointAction::class, function (Application $app) {
651+
$this->app->singleton(EntrypointController::class, function (Application $app) {
639652
/** @var ConfigRepository */
640653
$config = $app['config'];
641654

642-
return new EntrypointAction($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $config->get('api-platform.docs_formats'));
655+
return new EntrypointController($app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $config->get('api-platform.docs_formats'));
643656
});
644657

645658
$this->app->singleton(Pagination::class, function (Application $app) {
@@ -1144,17 +1157,16 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect
11441157
$route = new Route(['GET'], $prefix.'/contexts/{shortName?}{_format?}', [ContextAction::class, '__invoke']);
11451158
$route->name('api_jsonld_context')->middleware(ApiPlatformMiddleware::class);
11461159
$routeCollection->add($route);
1147-
// Maybe that we can alias Symfony Request to Laravel Request within the provider ?
11481160
$route = new Route(['GET'], $prefix.'/docs{_format?}', function (Request $request, Application $app) {
1149-
$documentationAction = $app->make(DocumentationAction::class);
1161+
$documentationAction = $app->make(DocumentationController::class);
11501162

11511163
return $documentationAction->__invoke($request);
11521164
});
11531165
$route->name('api_doc')->middleware(ApiPlatformMiddleware::class);
11541166
$routeCollection->add($route);
11551167

11561168
$route = new Route(['GET'], $prefix.'/{index?}{_format?}', function (Request $request, Application $app) {
1157-
$entrypointAction = $app->make(EntrypointAction::class);
1169+
$entrypointAction = $app->make(EntrypointController::class);
11581170

11591171
return $entrypointAction->__invoke($request);
11601172
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Controller;
15+
16+
use ApiPlatform\Documentation\Documentation;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Util\ContentNegotiationTrait;
20+
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
21+
use ApiPlatform\OpenApi\OpenApi;
22+
use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer;
23+
use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer;
24+
use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer;
25+
use ApiPlatform\State\ProcessorInterface;
26+
use ApiPlatform\State\ProviderInterface;
27+
use Negotiation\Negotiator;
28+
use Symfony\Component\HttpFoundation\Request;
29+
use Symfony\Component\HttpFoundation\Response;
30+
31+
/**
32+
* Generates the API documentation.
33+
*
34+
* @author Amrouche Hamza <[email protected]>
35+
*/
36+
final class DocumentationController
37+
{
38+
use ContentNegotiationTrait;
39+
40+
/**
41+
* @param array<string, string[]> $documentationFormats
42+
* @param ProviderInterface<object> $provider
43+
* @param ProcessorInterface<mixed, Response> $processor
44+
*/
45+
public function __construct(
46+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
47+
private readonly string $title = '',
48+
private readonly string $description = '',
49+
private readonly string $version = '',
50+
private readonly ?OpenApiFactoryInterface $openApiFactory = null,
51+
private readonly ?ProviderInterface $provider = null,
52+
private readonly ?ProcessorInterface $processor = null,
53+
?Negotiator $negotiator = null,
54+
private readonly array $documentationFormats = [OpenApiNormalizer::JSON_FORMAT => ['application/vnd.openapi+json'], OpenApiNormalizer::FORMAT => ['application/json']],
55+
private readonly bool $swaggerUiEnabled = true,
56+
) {
57+
$this->negotiator = $negotiator ?? new Negotiator();
58+
}
59+
60+
public function __invoke(Request $request): Response
61+
{
62+
$context = [
63+
'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY),
64+
'base_url' => $request->getBaseUrl(),
65+
'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION),
66+
];
67+
$request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context);
68+
// We want to find the format early on, this code is also executed later on by the ContentNegotiationProvider.
69+
$this->addRequestFormats($request, $this->documentationFormats);
70+
$format = $this->getRequestFormat($request, $this->documentationFormats);
71+
72+
if ('html' === $format || OpenApiNormalizer::FORMAT === $format || OpenApiNormalizer::JSON_FORMAT === $format || OpenApiNormalizer::YAML_FORMAT === $format) {
73+
return $this->getOpenApiDocumentation($context, $format, $request);
74+
}
75+
76+
return $this->getHydraDocumentation($context, $request);
77+
}
78+
79+
/**
80+
* @param array<string,mixed> $context
81+
*/
82+
private function getOpenApiDocumentation(array $context, string $format, Request $request): Response
83+
{
84+
$context['request'] = $request;
85+
$operation = new Get(
86+
class: OpenApi::class,
87+
read: true,
88+
serialize: true,
89+
provider: fn () => $this->openApiFactory->__invoke($context),
90+
normalizationContext: [
91+
ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null,
92+
LegacyOpenApiNormalizer::SPEC_VERSION => $context['spec_version'] ?? null,
93+
],
94+
outputFormats: $this->documentationFormats
95+
);
96+
97+
if ('html' === $format && $this->swaggerUiEnabled) {
98+
$operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true);
99+
}
100+
101+
return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context);
102+
}
103+
104+
/**
105+
* TODO: the logic behind the Hydra Documentation is done in a ApiPlatform\Hydra\Serializer\DocumentationNormalizer.
106+
* We should transform this to a provider, it'd improve performances also by a bit.
107+
*
108+
* @param array<string,mixed> $context
109+
*/
110+
private function getHydraDocumentation(array $context, Request $request): Response
111+
{
112+
$context['request'] = $request;
113+
$operation = new Get(
114+
class: Documentation::class,
115+
read: true,
116+
serialize: true,
117+
provider: fn () => new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version)
118+
);
119+
120+
return $this->processor->process($this->provider->provide($operation, [], $context), $operation, [], $context);
121+
}
122+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Controller;
15+
16+
use ApiPlatform\Documentation\Entrypoint;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
20+
use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer;
21+
use ApiPlatform\State\ProcessorInterface;
22+
use ApiPlatform\State\ProviderInterface;
23+
use Symfony\Component\HttpFoundation\Request;
24+
use Symfony\Component\HttpFoundation\Response;
25+
26+
/**
27+
* Generates the API entrypoint.
28+
*
29+
* @author Kévin Dunglas <[email protected]>
30+
*/
31+
final class EntrypointController
32+
{
33+
private static ResourceNameCollection $resourceNameCollection;
34+
35+
/**
36+
* @param array<string, string[]> $documentationFormats
37+
* @param ProviderInterface<object> $provider
38+
* @param ProcessorInterface<mixed, Response> $processor
39+
*/
40+
public function __construct(
41+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
42+
private readonly ProviderInterface $provider,
43+
private readonly ProcessorInterface $processor,
44+
private readonly array $documentationFormats = [],
45+
) {
46+
}
47+
48+
public function __invoke(Request $request): Response
49+
{
50+
self::$resourceNameCollection = $this->resourceNameCollectionFactory->create();
51+
$context = [
52+
'request' => $request,
53+
'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION),
54+
];
55+
$request->attributes->set('_api_platform_disable_listeners', true);
56+
$operation = new Get(
57+
outputFormats: $this->documentationFormats,
58+
read: true,
59+
serialize: true,
60+
class: Entrypoint::class,
61+
provider: [self::class, 'provide']
62+
);
63+
$request->attributes->set('_api_operation', $operation);
64+
$body = $this->provider->provide($operation, [], $context);
65+
$operation = $request->attributes->get('_api_operation');
66+
67+
return $this->processor->process($body, $operation, [], $context);
68+
}
69+
70+
public static function provide(): Entrypoint
71+
{
72+
return new Entrypoint(self::$resourceNameCollection);
73+
}
74+
}

src/Laravel/State/SwaggerUiProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ final class SwaggerUiProvider implements ProviderInterface
3737
public function __construct(
3838
private readonly ProviderInterface $decorated,
3939
private readonly OpenApiFactoryInterface $openApiFactory,
40+
private readonly bool $swaggerUiEnabled = true,
4041
) {
4142
}
4243

@@ -51,6 +52,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5152
!($operation instanceof HttpOperation)
5253
|| !($request = $context['request'] ?? null)
5354
|| 'html' !== $request->getRequestFormat()
55+
|| !$this->swaggerUiEnabled
5456
) {
5557
return $this->decorated->provide($operation, $uriVariables, $context);
5658
}

src/Laravel/Tests/AuthTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function testAuthenticatedPolicy(): void
4444
{
4545
$response = $this->post('/tokens/create');
4646
$token = $response->json()['token'];
47-
$response = $this->post('/api/vaults', [], ['content-type' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]);
47+
$response = $this->post('/api/vaults', [], ['accept' => ['application/ld+json'], 'content-type' => ['application/ld+json'], 'authorization' => 'Bearer '.$token]);
4848
$response->assertStatus(403);
4949
}
5050
}

src/Laravel/Tests/DocsTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Orchestra\Testbench\Concerns\WithWorkbench;
18+
use Orchestra\Testbench\TestCase;
19+
20+
class DocsTest extends TestCase
21+
{
22+
use ApiTestAssertionsTrait;
23+
use WithWorkbench;
24+
25+
public function testOpenApi(): void
26+
{
27+
$res = $this->get('/api/docs.jsonopenapi');
28+
$this->assertArrayHasKey('openapi', $res->json());
29+
$this->assertSame('application/vnd.openapi+json; charset=utf-8', $res->headers->get('content-type'));
30+
}
31+
32+
public function testOpenApiAccept(): void
33+
{
34+
$res = $this->get('/api/docs', headers: ['accept' => 'application/vnd.openapi+json']);
35+
$this->assertArrayHasKey('openapi', $res->json());
36+
$this->assertSame('application/vnd.openapi+json; charset=utf-8', $res->headers->get('content-type'));
37+
}
38+
39+
public function testJsonLd(): void
40+
{
41+
$res = $this->get('/api/docs.jsonld');
42+
$this->assertArrayHasKey('@context', $res->json());
43+
$this->assertSame('application/ld+json; charset=utf-8', $res->headers->get('content-type'));
44+
}
45+
46+
public function testJsonLdAccept(): void
47+
{
48+
$res = $this->get('/api/docs', headers: ['accept' => 'application/ld+json']);
49+
$this->assertArrayHasKey('@context', $res->json());
50+
$this->assertSame('application/ld+json; charset=utf-8', $res->headers->get('content-type'));
51+
}
52+
}

0 commit comments

Comments
 (0)