Skip to content

Commit dea6352

Browse files
committed
feat(state): improvements and fixes for LinkedDataPlatform support
- use LinkedDataPlatformProcessor instead of RespondProcessor to handle the allow and allow-post headers - add new Processor in symfony events and Laravel
1 parent 42d61d3 commit dea6352

File tree

11 files changed

+285
-233
lines changed

11 files changed

+285
-233
lines changed

src/Laravel/ApiPlatformProvider.php

+5
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
use ApiPlatform\State\Pagination\PaginationOptions;
184184
use ApiPlatform\State\ParameterProviderInterface;
185185
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
186+
use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
186187
use ApiPlatform\State\Processor\RespondProcessor;
187188
use ApiPlatform\State\Processor\SerializeProcessor;
188189
use ApiPlatform\State\Processor\WriteProcessor;
@@ -560,6 +561,10 @@ public function register(): void
560561
return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
561562
});
562563

564+
$this->app->singleton(RespondProcessor::class, function (Application $app) {
565+
return new LinkedDataPlatformProcessor(new RespondProcessor(), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class));
566+
});
567+
563568
$this->app->singleton(SerializeProcessor::class, function (Application $app) {
564569
return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
565570
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
15+
use Illuminate\Contracts\Config\Repository;
16+
use Illuminate\Foundation\Application;
17+
use Illuminate\Foundation\Testing\RefreshDatabase;
18+
use Orchestra\Testbench\Concerns\WithWorkbench;
19+
use Orchestra\Testbench\TestCase;
20+
use Workbench\App\Models\Book;
21+
use Workbench\Database\Factories\BookFactory;
22+
23+
class LinkedDataPlatformTest extends TestCase
24+
{
25+
use ApiTestAssertionsTrait;
26+
use RefreshDatabase;
27+
use WithWorkbench;
28+
29+
/**
30+
* @param Application $app
31+
*/
32+
protected function defineEnvironment($app): void
33+
{
34+
tap($app['config'], function (Repository $config): void {
35+
$config->set('api-platform.formats', ['jsonld' => ['application/ld+json'], 'turtle' => ['text/turtle']]);
36+
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
37+
$config->set('app.debug', true);
38+
});
39+
}
40+
41+
public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
42+
{
43+
$response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
44+
$response->assertStatus(200);
45+
$response->assertHeader('accept-post', 'application/ld+json, text/turtle, text/html');
46+
}
47+
48+
public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
49+
{
50+
BookFactory::new()->createOne();
51+
$book = Book::first();
52+
$response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
53+
$response->assertStatus(200);
54+
$response->assertHeaderMissing('accept-post');
55+
}
56+
57+
public function testHeaderAllowReflectsResourceAllowedMethods(): void
58+
{
59+
$response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
60+
$response->assertHeader('allow', 'OPTIONS, HEAD, POST, GET');
61+
62+
BookFactory::new()->createOne();
63+
$book = Book::first();
64+
$response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
65+
$response->assertStatus(200);
66+
$response->assertHeader('allow', 'OPTIONS, HEAD, PUT, PATCH, GET, DELETE');
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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\State\Processor;
15+
16+
use ApiPlatform\Metadata\Error;
17+
use ApiPlatform\Metadata\HttpOperation;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
20+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
21+
use ApiPlatform\State\ProcessorInterface;
22+
use Symfony\Component\HttpFoundation\Response;
23+
24+
/**
25+
* @template T1
26+
* @template T2
27+
*
28+
* @implements ProcessorInterface<T1, T2>
29+
*/
30+
final class LinkedDataPlatformProcessor implements ProcessorInterface
31+
{
32+
private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
33+
34+
/**
35+
* @param ProcessorInterface<T1, T2> $decorated
36+
*/
37+
public function __construct(
38+
private readonly ProcessorInterface $decorated, // todo is processor interface nullable
39+
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
40+
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
41+
) {
42+
}
43+
44+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
45+
{
46+
$response = $this->decorated->process($data, $operation, $uriVariables, $context);
47+
if (
48+
!$response instanceof Response
49+
|| !$operation instanceof HttpOperation
50+
|| $operation instanceof Error
51+
|| null === $this->resourceCollectionMetadataFactory
52+
|| !($context['resource_class'] ?? null)
53+
|| null === $operation->getUriTemplate()
54+
|| !$this->resourceClassResolver?->isResourceClass($context['resource_class'])
55+
) {
56+
return $response;
57+
}
58+
59+
$allowedMethods = self::DEFAULT_ALLOWED_METHOD;
60+
$resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
61+
foreach ($resourceMetadataCollection as $resource) {
62+
foreach ($resource->getOperations() as $resourceOperation) {
63+
if ($resourceOperation->getUriTemplate() === $operation->getUriTemplate()) {
64+
$operationMethod = $resourceOperation->getMethod();
65+
$allowedMethods[] = $operationMethod;
66+
if ('POST' === $operationMethod && \is_array($outputFormats = $operation->getOutputFormats())) {
67+
$response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats))));
68+
}
69+
}
70+
}
71+
}
72+
73+
$response->headers->set('Allow', implode(', ', $allowedMethods));
74+
75+
return $response;
76+
}
77+
}

src/State/Processor/RespondProcessor.php

-25
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
use ApiPlatform\Metadata\Operation;
2323
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2424
use ApiPlatform\Metadata\Put;
25-
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2625
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2726
use ApiPlatform\Metadata\UrlGeneratorInterface;
2827
use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -46,13 +45,10 @@ final class RespondProcessor implements ProcessorInterface
4645
'DELETE' => Response::HTTP_NO_CONTENT,
4746
];
4847

49-
private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
50-
5148
public function __construct(
5249
private ?IriConverterInterface $iriConverter = null,
5350
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
5451
private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
55-
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
5652
) {
5753
}
5854

@@ -92,27 +88,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
9288
$headers['Accept-Patch'] = $acceptPatch;
9389
}
9490

95-
if (!$exception) {
96-
$isPostAllowed = false;
97-
$allowedMethods = self::DEFAULT_ALLOWED_METHOD;
98-
if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) {
99-
$resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
100-
foreach ($resourceMetadataCollection as $resource) {
101-
foreach ($resource->getOperations() as $resourceOperation) {
102-
if ($resourceOperation->getUriTemplate() === $currentUriTemplate) {
103-
$allowedMethods[] = $operationMethod = $resourceOperation->getMethod();
104-
$isPostAllowed = $isPostAllowed || ('POST' === $operationMethod);
105-
}
106-
}
107-
}
108-
}
109-
$headers['Allow'] = implode(', ', $allowedMethods);
110-
111-
if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) {
112-
$headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
113-
}
114-
}
115-
11691
$method = $request->getMethod();
11792
$originalData = $context['original_data'] ?? null;
11893

0 commit comments

Comments
 (0)