Skip to content

Commit f5fd9bf

Browse files
committed
feat(state): Added "Accept-Post" and "Allow" headers
To ensure compliance with the LDP specification (https://www.w3.org/TR/ldp/): Added the "Accept-Post" header containing the list of supported Post formats. Added the "Allow" header with values based on the allowed operations on the queried resources.
1 parent 510fa55 commit f5fd9bf

File tree

8 files changed

+430
-0
lines changed

8 files changed

+430
-0
lines changed

src/Laravel/ApiPlatformProvider.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
use ApiPlatform\State\Pagination\Pagination;
147147
use ApiPlatform\State\Pagination\PaginationOptions;
148148
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
149+
use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
149150
use ApiPlatform\State\Processor\RespondProcessor;
150151
use ApiPlatform\State\Processor\SerializeProcessor;
151152
use ApiPlatform\State\Processor\WriteProcessor;
@@ -424,6 +425,14 @@ public function register(): void
424425
return new AddLinkHeaderProcessor($decorated, new HttpHeaderSerializer());
425426
});
426427

428+
$this->app->singleton(LinkedDataPlatformProcessor::class, function (Application $app) {
429+
return new LinkedDataPlatformProcessor(
430+
$app->make(AddLinkHeaderProcessor::class), // Original service
431+
$app->make(ResourceClassResolverInterface::class),
432+
$app->make(ResourceMetadataCollectionFactoryInterface::class)
433+
);
434+
});
435+
427436
$this->app->singleton(SerializeProcessor::class, function (Application $app) {
428437
return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
429438
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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_METHODS = ['OPTIONS', 'HEAD'];
33+
34+
/**
35+
* @param ProcessorInterface<T1, T2> $decorated
36+
*/
37+
public function __construct(
38+
private readonly ProcessorInterface $decorated,
39+
private readonly ResourceClassResolverInterface $resourceClassResolver,
40+
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory,
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+
|| !$operation->getUriTemplate()
52+
|| !$this->resourceClassResolver->isResourceClass($operation->getClass())
53+
) {
54+
return $response;
55+
}
56+
57+
$acceptPost = null;
58+
$allowedMethods = self::DEFAULT_ALLOWED_METHODS;
59+
$resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass());
60+
foreach ($resourceCollection as $resource) {
61+
foreach ($resource->getOperations() as $op) {
62+
if ($op->getUriTemplate() === $operation->getUriTemplate()) {
63+
$allowedMethods[] = $method = $op->getMethod();
64+
if ('POST' === $method && \is_array($outputFormats = $op->getOutputFormats())) {
65+
$acceptPost = implode(', ', array_merge(...array_values($outputFormats)));
66+
}
67+
}
68+
}
69+
}
70+
if ($acceptPost) {
71+
$response->headers->set('Accept-Post', $acceptPost);
72+
}
73+
74+
$response->headers->set('Allow', implode(', ', $allowedMethods));
75+
76+
return $response;
77+
}
78+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
18+
#[ApiResource()]
19+
class Dummy
20+
{
21+
public int $id;
22+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Processor;
15+
16+
use ApiPlatform\Hal\Tests\Fixtures\Dummy;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Delete;
19+
use ApiPlatform\Metadata\Error;
20+
use ApiPlatform\Metadata\Get;
21+
use ApiPlatform\Metadata\GetCollection;
22+
use ApiPlatform\Metadata\Post;
23+
use ApiPlatform\Metadata\Put;
24+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
26+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
27+
use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
28+
use ApiPlatform\State\ProcessorInterface;
29+
use PHPUnit\Framework\MockObject\MockObject;
30+
use PHPUnit\Framework\TestCase;
31+
use Symfony\Component\HttpFoundation\Request;
32+
use Symfony\Component\HttpFoundation\Response;
33+
34+
class LinkedDataPlatformProcessorTest extends TestCase
35+
{
36+
private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory;
37+
38+
private ResourceClassResolverInterface&MockObject $resourceClassResolver;
39+
40+
private ProcessorInterface&MockObject $decorated;
41+
42+
protected function setUp(): void
43+
{
44+
$this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
45+
$this->resourceClassResolver
46+
->method('isResourceClass')
47+
->willReturn(true);
48+
49+
$this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
50+
$this->resourceMetadataCollectionFactory
51+
->method('create')
52+
->willReturn(
53+
new ResourceMetadataCollection($this->getResourceClassName(), [
54+
new ApiResource(operations: [
55+
new Get(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: $this->getResourceClassName(), name: 'get'),
56+
new GetCollection(uriTemplate: '/dummy{._format}', class: $this->getResourceClassName(), name: 'get_collections'),
57+
new Post(uriTemplate: '/dummy{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], class: $this->getResourceClassName(), name: 'post'),
58+
new Delete(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: $this->getResourceClassName(), name: 'delete'),
59+
new Put(uriTemplate: '/dummy/{dummyResourceId}{._format}', class: $this->getResourceClassName(), name: 'put'),
60+
]),
61+
])
62+
);
63+
64+
$this->decorated = $this->createMock(ProcessorInterface::class);
65+
$this->decorated->method('process')->willReturn(new Response());
66+
}
67+
68+
public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
69+
{
70+
/** @var class-string $dummy */
71+
$dummy = 'dummy';
72+
$operation = new Get('/dummy{._format}', class: $this->getResourceClassName());
73+
74+
$context = $this->getContext();
75+
76+
$processor = new LinkedDataPlatformProcessor(
77+
$this->decorated,
78+
$this->resourceClassResolver,
79+
$this->resourceMetadataCollectionFactory
80+
);
81+
/** @var Response $response */
82+
$response = $processor->process(null, $operation, [], $context);
83+
84+
$this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post'));
85+
}
86+
87+
public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
88+
{
89+
$operation = new Get('/dummy/{dummyResourceId}{._format}', class: $this->getResourceClassName());
90+
$context = $this->getContext();
91+
92+
$processor = new LinkedDataPlatformProcessor(
93+
$this->decorated,
94+
$this->resourceClassResolver,
95+
$this->resourceMetadataCollectionFactory
96+
);
97+
/** @var Response $response */
98+
$response = $processor->process(null, $operation, [], $context);
99+
100+
$this->assertNull($response->headers->get('Accept-Post'));
101+
}
102+
103+
public function testHeaderAllowReflectsResourceAllowedMethods(): void
104+
{
105+
$operation = new Get('/dummy{._format}', class: $this->getResourceClassName());
106+
$context = $this->getContext();
107+
108+
$processor = new LinkedDataPlatformProcessor(
109+
$this->decorated,
110+
$this->resourceClassResolver,
111+
$this->resourceMetadataCollectionFactory
112+
);
113+
/** @var Response $response */
114+
$response = $processor->process(null, $operation, [], $context);
115+
$allowHeader = $response->headers->get('Allow');
116+
$this->assertStringContainsString('OPTIONS', $allowHeader);
117+
$this->assertStringContainsString('HEAD', $allowHeader);
118+
$this->assertStringContainsString('GET', $allowHeader);
119+
$this->assertStringContainsString('POST', $allowHeader);
120+
$operation = new Get('/dummy/{dummyResourceId}{._format}', class: $this->getResourceClassName());
121+
122+
/** @var Response $response */
123+
$processor = new LinkedDataPlatformProcessor(
124+
$this->decorated,
125+
$this->resourceClassResolver,
126+
$this->resourceMetadataCollectionFactory
127+
);
128+
/** @var Response $response */
129+
$response = $processor->process('data', $operation, [], $this->getContext());
130+
$allowHeader = $response->headers->get('Allow');
131+
$this->assertStringContainsString('OPTIONS', $allowHeader);
132+
$this->assertStringContainsString('HEAD', $allowHeader);
133+
$this->assertStringContainsString('GET', $allowHeader);
134+
$this->assertStringContainsString('PUT', $allowHeader);
135+
$this->assertStringContainsString('DELETE', $allowHeader);
136+
}
137+
138+
public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void
139+
{
140+
// Operation is an Error
141+
$processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory);
142+
$response = $processor->process(null, new Error(), $this->getContext());
143+
$this->assertNull($response->headers->get('Allow'));
144+
}
145+
146+
private function createGetRequest(): Request
147+
{
148+
$request = new Request();
149+
$request->setMethod('GET');
150+
$request->setRequestFormat('json');
151+
$request->headers->set('Accept', 'application/ld+json');
152+
153+
return $request;
154+
}
155+
156+
private function getContext(): array
157+
{
158+
return [
159+
'resource_class' => $this->getResourceClassName(),
160+
'request' => $this->createGetRequest(),
161+
];
162+
}
163+
164+
private function getResourceClassName(): string
165+
{
166+
return Dummy::class;
167+
}
168+
}

src/Symfony/Bundle/Resources/config/state/processor.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,11 @@
2626
<service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
2727
<argument type="service" id="api_platform.state_processor.add_link_header.inner" />
2828
</service>
29+
30+
<service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
31+
<argument type="service" id="api_platform.state_processor.linked_data_platform.inner" />
32+
<argument type="service" id="api_platform.resource_class_resolver" />
33+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
34+
</service>
2935
</services>
3036
</container>

src/Symfony/Bundle/Resources/config/symfony/events.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@
7373
<argument type="service" id="api_platform.state_processor.add_link_header.inner" />
7474
</service>
7575

76+
<service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
77+
<argument type="service" id="api_platform.state_processor.linked_data_platform.inner" />
78+
<argument type="service" id="api_platform.resource_class_resolver" />
79+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
80+
</service>
81+
7682
<service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener">
7783
<argument type="service" id="api_platform.state_processor.write" />
7884
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Delete;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Post;
22+
23+
#[ApiResource(operations: [
24+
new Get(
25+
uriTemplate: '/dummy_get_post_delete_operations/{id}',
26+
provider: [self::class, 'provideItem'],
27+
),
28+
new GetCollection(
29+
uriTemplate: '/dummy_get_post_delete_operations',
30+
provider: [self::class, 'provide'], ),
31+
new Post(
32+
uriTemplate: '/dummy_get_post_delete_operations',
33+
provider: [self::class, 'provide'], ),
34+
new Delete(
35+
uriTemplate: '/dummy_get_post_delete_operations/{id}',
36+
provider: [self::class, 'provideItem'], ),
37+
])]
38+
class DummyGetPostDeleteOperation
39+
{
40+
public ?int $id;
41+
42+
public ?string $name = null;
43+
44+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array
45+
{
46+
$dummyResource = new self();
47+
$dummyResource->id = 1;
48+
$dummyResource->name = 'Dummy name';
49+
50+
return [
51+
$dummyResource,
52+
];
53+
}
54+
55+
public static function provideItem(Operation $operation, array $uriVariables = [], array $context = []): self
56+
{
57+
$dummyResource = new self();
58+
$dummyResource->id = $uriVariables['id'];
59+
$dummyResource->name = 'Dummy name';
60+
61+
return $dummyResource;
62+
}
63+
}

0 commit comments

Comments
 (0)