Skip to content

Commit 28c4ab1

Browse files
committed
feat(state): add headers to comply with LDP specification
To ensure compliance with the LDP specification (https://www.w3.org/TR/ldp/): - Added the "Accept-Post" header with the value "text/turtle, application/ld+json". - Added the "Allow" header with values based on the allowed operations on the queried resources.
1 parent ad54075 commit 28c4ab1

File tree

7 files changed

+336
-0
lines changed

7 files changed

+336
-0
lines changed

features/main/ldp_resources.feature

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Feature: LDP Resources
2+
In order to use an API compliant with the LDP specification (https://www.w3.org/TR/ldp/)
3+
As a client software developer
4+
I must have some specific headers returned by the API
5+
6+
@createSchema
7+
Scenario: Test Accept-Post and Allow headers for a LDP resource
8+
When I add "Content-Type" header equal to "application/ld+json"
9+
And I send a "GET" request to "/dummy_get_post_delete_operations"
10+
Then the header "Accept-Post" should be equal to "text/turtle, application/ld+json"
11+
And the header "Allow" should be equal to "OPTIONS, HEAD, GET, POST, DELETE"

src/State/Processor/RespondProcessor.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\Metadata\Operation;
2323
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
2424
use ApiPlatform\Metadata\Put;
25+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2526
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2627
use ApiPlatform\Metadata\UrlGeneratorInterface;
2728
use ApiPlatform\Metadata\Util\ClassInfoTrait;
@@ -45,10 +46,13 @@ final class RespondProcessor implements ProcessorInterface
4546
'DELETE' => Response::HTTP_NO_CONTENT,
4647
];
4748

49+
private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];
50+
4851
public function __construct(
4952
private ?IriConverterInterface $iriConverter = null,
5053
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
5154
private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
55+
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
5256
) {
5357
}
5458

@@ -88,6 +92,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
8892
$headers['Accept-Patch'] = $acceptPatch;
8993
}
9094

95+
$headers['Accept-Post'] = 'text/turtle, application/ld+json';
96+
$headers['Allow'] = $this->getAllowedMethods($context['resource_class'] ?? null);
97+
9198
$method = $request->getMethod();
9299
$originalData = $context['original_data'] ?? null;
93100

@@ -150,4 +157,19 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
150157
$headers
151158
);
152159
}
160+
161+
private function getAllowedMethods(?string $resourceClass): string
162+
{
163+
$allowedMethods = self::DEFAULT_ALLOWED_METHOD;
164+
if (null !== $resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass)) {
165+
$resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($resourceClass);
166+
foreach ($resourceMetadataCollection as $resource) {
167+
foreach ($resource->getOperations() as $operation) {
168+
$allowedMethods[] = $operation->getMethod();
169+
}
170+
}
171+
}
172+
173+
return implode(', ', array_unique($allowedMethods));
174+
}
153175
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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\Tests\Processor;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Delete;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\HttpOperation;
20+
use ApiPlatform\Metadata\Patch;
21+
use ApiPlatform\Metadata\Post;
22+
use ApiPlatform\Metadata\Put;
23+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
25+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26+
use ApiPlatform\State\Processor\RespondProcessor;
27+
use PHPUnit\Framework\MockObject\MockObject;
28+
use PHPUnit\Framework\TestCase;
29+
use Symfony\Component\HttpFoundation\Request;
30+
use Symfony\Component\HttpFoundation\Response;
31+
32+
class RespondProcessorTest extends TestCase
33+
{
34+
private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory;
35+
private RespondProcessor $processor;
36+
37+
protected function setUp(): void
38+
{
39+
$resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
40+
$resourceClassResolver
41+
->method('isResourceClass')
42+
->willReturn(true);
43+
44+
$this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
45+
46+
$this->processor = new RespondProcessor(
47+
null,
48+
$resourceClassResolver,
49+
null,
50+
$this->resourceMetadataCollectionFactory
51+
);
52+
}
53+
54+
public function testHeadersAcceptPostIsSetCorrectly(): void
55+
{
56+
$this->resourceMetadataCollectionFactory
57+
->method('create')
58+
->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
59+
60+
$operation = new HttpOperation('GET');
61+
$context = [
62+
'resource_class' => 'SomeResourceClass',
63+
'request' => $this->createGetRequest(),
64+
];
65+
66+
/** @var Response $response */
67+
$response = $this->processor->process(null, $operation, [], $context);
68+
69+
$this->assertSame('text/turtle, application/ld+json', $response->headers->get('Accept-Post'));
70+
}
71+
72+
public function testHeaderAllowHasHeadOptionsByDefault(): void
73+
{
74+
$this->resourceMetadataCollectionFactory
75+
->method('create')
76+
->willReturn(new ResourceMetadataCollection('DummyResourceClass'));
77+
78+
$operation = new HttpOperation('GET');
79+
$context = [
80+
'resource_class' => 'SomeResourceClass',
81+
'request' => $this->createGetRequest(),
82+
];
83+
84+
/** @var Response $response */
85+
$response = $this->processor->process(null, $operation, [], $context);
86+
87+
$this->assertSame('OPTIONS, HEAD', $response->headers->get('Allow'));
88+
}
89+
90+
public function testHeaderAllowReflectsResourceAllowedMethods(): void
91+
{
92+
$this->resourceMetadataCollectionFactory
93+
->method('create')
94+
->willReturn(
95+
new ResourceMetadataCollection('DummyResource', [
96+
new ApiResource(operations: [
97+
'get' => new Get(name: 'get'),
98+
'post' => new Post(name: 'post'),
99+
'delete' => new Delete(name: 'delete'),
100+
]),
101+
])
102+
);
103+
104+
$operation = new HttpOperation('GET');
105+
$context = [
106+
'resource_class' => 'SomeResourceClass',
107+
'request' => $this->createGetRequest(),
108+
];
109+
110+
/** @var Response $response */
111+
$response = $this->processor->process(null, $operation, [], $context);
112+
113+
$allowHeader = $response->headers->get('Allow');
114+
$this->assertStringContainsString('OPTIONS', $allowHeader);
115+
$this->assertStringContainsString('HEAD', $allowHeader);
116+
$this->assertStringContainsString('GET', $allowHeader);
117+
$this->assertStringContainsString('POST', $allowHeader);
118+
$this->assertStringContainsString('DELETE', $allowHeader);
119+
$this->assertStringNotContainsString('PATCH', $allowHeader);
120+
$this->assertStringNotContainsString('PUT', $allowHeader);
121+
}
122+
123+
public function testHeaderAllowReflectsAllowedResourcesGetPutPatch(): void
124+
{
125+
$this->resourceMetadataCollectionFactory
126+
->method('create')
127+
->willReturn(
128+
new ResourceMetadataCollection('DummyResource', [
129+
new ApiResource(operations: [
130+
'get' => new Get(name: 'get'),
131+
'patch' => new Patch(name: 'patch'),
132+
'put' => new Put(name: 'put'),
133+
]),
134+
])
135+
);
136+
137+
$operation = new HttpOperation('GET');
138+
$context = [
139+
'resource_class' => 'SomeResourceClass',
140+
'request' => $this->createGetRequest(),
141+
];
142+
143+
/** @var Response $response */
144+
$response = $this->processor->process(null, $operation, [], $context);
145+
146+
$allowHeader = $response->headers->get('Allow');
147+
$this->assertStringContainsString('OPTIONS', $allowHeader);
148+
$this->assertStringContainsString('HEAD', $allowHeader);
149+
$this->assertStringContainsString('GET', $allowHeader);
150+
$this->assertStringContainsString('PATCH', $allowHeader);
151+
$this->assertStringContainsString('PUT', $allowHeader);
152+
$this->assertStringNotContainsString('POST', $allowHeader);
153+
$this->assertStringNotContainsString('DELETE', $allowHeader);
154+
}
155+
156+
private function createGetRequest(): Request
157+
{
158+
$request = new Request();
159+
$request->setMethod('GET');
160+
$request->setRequestFormat('json');
161+
$request->headers->set('Accept', 'application/ld+json');
162+
163+
return $request;
164+
}
165+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<argument type="service" id="api_platform.iri_converter" />
2222
<argument type="service" id="api_platform.resource_class_resolver" />
2323
<argument type="service" id="api_platform.metadata.operation.metadata_factory" />
24+
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
2425
</service>
2526

2627
<service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\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\Post;
21+
22+
#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
23+
class DummyGetPostDeleteOperation
24+
{
25+
public ?int $id;
26+
27+
public ?string $name = null;
28+
}
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\Tests\Fixtures\TestBundle\Document;
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\Post;
21+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
22+
23+
#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
24+
#[ODM\Document]
25+
class DummyGetPostDeleteOperation
26+
{
27+
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
28+
private ?int $id;
29+
30+
#[ODM\Field(type: 'string')]
31+
private ?string $name = null;
32+
33+
public function getId(): ?int
34+
{
35+
return $this->id;
36+
}
37+
38+
public function setId(?int $id): void
39+
{
40+
$this->id = $id;
41+
}
42+
43+
public function getName(): string
44+
{
45+
return $this->name;
46+
}
47+
48+
public function setName(string $name): void
49+
{
50+
$this->name = $name;
51+
}
52+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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\Tests\Fixtures\TestBundle\Entity;
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\Post;
21+
use Doctrine\ORM\Mapping as ORM;
22+
23+
#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Delete()])]
24+
#[ORM\Entity]
25+
class DummyGetPostDeleteOperation
26+
{
27+
/**
28+
* @var int|null The id
29+
*/
30+
#[ORM\Column(type: 'integer')]
31+
#[ORM\Id]
32+
#[ORM\GeneratedValue(strategy: 'AUTO')]
33+
private ?int $id = null;
34+
35+
#[ORM\Column]
36+
private string $name;
37+
38+
public function getId(): ?int
39+
{
40+
return $this->id;
41+
}
42+
43+
public function setId(?int $id): void
44+
{
45+
$this->id = $id;
46+
}
47+
48+
public function getName(): string
49+
{
50+
return $this->name;
51+
}
52+
53+
public function setName(string $name): void
54+
{
55+
$this->name = $name;
56+
}
57+
}

0 commit comments

Comments
 (0)