Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(state): add headers to comply with LDP specification #6917

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
17 changes: 15 additions & 2 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@
use ApiPlatform\State\Pagination\PaginationOptions;
use ApiPlatform\State\ParameterProviderInterface;
use ApiPlatform\State\Processor\AddLinkHeaderProcessor;
use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
use ApiPlatform\State\Processor\RespondProcessor;
use ApiPlatform\State\Processor\SerializeProcessor;
use ApiPlatform\State\Processor\WriteProcessor;
Expand Down Expand Up @@ -557,11 +558,23 @@ public function register(): void
});

$this->app->singleton(RespondProcessor::class, function () {
return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
return new RespondProcessor();
});

$this->app->singleton(AddLinkHeaderProcessor::class, function (Application $app) {
return new AddLinkHeaderProcessor($app->make(RespondProcessor::class), new HttpHeaderSerializer());
});

$this->app->singleton(LinkedDataPlatformProcessor::class, function (Application $app) {
return new LinkedDataPlatformProcessor(
$app->make(AddLinkHeaderProcessor::class), // Original service
$app->make(ResourceClassResolverInterface::class),
$app->make(ResourceMetadataCollectionFactoryInterface::class)
);
});

$this->app->singleton(SerializeProcessor::class, function (Application $app) {
return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
return new SerializeProcessor($app->make(LinkedDataPlatformProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class));
});

$this->app->singleton(WriteProcessor::class, function (Application $app) {
Expand Down
68 changes: 68 additions & 0 deletions src/Laravel/Tests/LinkedDataPlatformTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
use Workbench\App\Models\Book;
use Workbench\Database\Factories\BookFactory;

class LinkedDataPlatformTest extends TestCase
{
use ApiTestAssertionsTrait;
use RefreshDatabase;
use WithWorkbench;

/**
* @param Application $app
*/
protected function defineEnvironment($app): void
{
tap($app['config'], function (Repository $config): void {
$config->set('api-platform.formats', ['jsonld' => ['application/ld+json'], 'turtle' => ['text/turtle']]);
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
$config->set('app.debug', true);
});
}

public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
{
$response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
$response->assertStatus(200);
$response->assertHeader('accept-post', 'application/ld+json, text/turtle, text/html');
}

public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
{
BookFactory::new()->createOne();
$book = Book::first();
$response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
$response->assertStatus(200);
$response->assertHeaderMissing('accept-post');
}

public function testHeaderAllowReflectsResourceAllowedMethods(): void
{
$response = $this->get('/api/books', ['accept' => ['application/ld+json']]);
$response->assertHeader('allow', 'OPTIONS, HEAD, POST, GET');

BookFactory::new()->createOne();
$book = Book::first();
$response = $this->get($this->getIriFromResource($book), ['accept' => ['application/ld+json']]);
$response->assertStatus(200);
$response->assertHeader('allow', 'OPTIONS, HEAD, PUT, PATCH, GET, DELETE');
}
}
76 changes: 76 additions & 0 deletions src/State/Processor/LinkedDataPlatformProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\State\Processor;

use ApiPlatform\Metadata\Error;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\State\ProcessorInterface;
use Symfony\Component\HttpFoundation\Response;

/**
* @template T1
* @template T2
*
* @implements ProcessorInterface<T1, T2>
*/
final class LinkedDataPlatformProcessor implements ProcessorInterface
{
private const DEFAULT_ALLOWED_METHODS = ['OPTIONS', 'HEAD'];

/**
* @param ProcessorInterface<T1, T2> $decorated
*/
public function __construct(
private readonly ProcessorInterface $decorated,
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null,
) {
}

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$response = $this->decorated->process($data, $operation, $uriVariables, $context);
if (
!$response instanceof Response
|| !$operation instanceof HttpOperation
|| $operation instanceof Error
|| !$this->resourceMetadataCollectionFactory
|| !($context['resource_class'] ?? null)
|| !$operation->getUriTemplate()
|| !$this->resourceClassResolver?->isResourceClass($context['resource_class'])
) {
return $response;
}

$allowedMethods = self::DEFAULT_ALLOWED_METHODS;
$resourceCollection = $this->resourceMetadataCollectionFactory->create($context['resource_class']);
foreach ($resourceCollection as $resource) {
foreach ($resource->getOperations() as $op) {
if ($op->getUriTemplate() === $operation->getUriTemplate()) {
$allowedMethods[] = $method = $op->getMethod();
if ('POST' === $method && \is_array($outputFormats = $op->getOutputFormats())) {
$response->headers->set('Accept-Post', implode(', ', array_merge(...array_values($outputFormats))));
}
}
}
}

$response->headers->set('Allow', implode(', ', $allowedMethods));

return $response;
}
}
183 changes: 183 additions & 0 deletions src/State/Tests/Processor/LinkedDataPlatformProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\State\Tests\Processor;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Error;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\State\Processor\LinkedDataPlatformProcessor;
use ApiPlatform\State\ProcessorInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class LinkedDataPlatformProcessorTest extends TestCase
{
private ResourceMetadataCollectionFactoryInterface&MockObject $resourceMetadataCollectionFactory;
private ResourceClassResolverInterface&MockObject $resourceClassResolver;

private ProcessorInterface&MockObject $decorated;

protected function setUp(): void
{
$this->resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class);
$this->resourceClassResolver
->method('isResourceClass')
->willReturn(true);

$this->resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class);
$this->resourceMetadataCollectionFactory
->method('create')
->willReturn(
new ResourceMetadataCollection('DummyResource', [ // todo mock $dummy_resource
new ApiResource(operations: [
new Get(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'get'),
new GetCollection(uriTemplate: '/dummy_resources{._format}', name: 'get_collections'),
new Post(uriTemplate: '/dummy_resources{._format}', outputFormats: ['jsonld' => ['application/ld+json'], 'text/turtle' => ['text/turtle']], name: 'post'),
new Delete(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'delete'),
new Put(uriTemplate: '/dummy_resources/{dummyResourceId}{._format}', name: 'put'),
]),
])
);

$this->decorated = $this->createMock(ProcessorInterface::class);
$this->decorated->method('process')->willReturn(new Response());
}

public function testHeadersAcceptPostIsReturnWhenPostAllowed(): void
{
$operation = (new HttpOperation('GET', '/dummy_resources{._format}'));

$context = $this->getContext();

$processor = new LinkedDataPlatformProcessor(
$this->decorated,
$this->resourceClassResolver,
$this->resourceMetadataCollectionFactory
);
/** @var Response $response */
$response = $processor->process(null, $operation, [], $context);

$this->assertSame('application/ld+json, text/turtle', $response->headers->get('Accept-Post'));
}

public function testHeadersAcceptPostIsNotSetWhenPostIsNotAllowed(): void
{
$operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));
$context = $this->getContext();

$processor = new LinkedDataPlatformProcessor(
$this->decorated,
$this->resourceClassResolver,
$this->resourceMetadataCollectionFactory
);
/** @var Response $response */
$response = $processor->process(null, $operation, [], $context);

$this->assertNull($response->headers->get('Accept-Post'));
}

public function testHeaderAllowReflectsResourceAllowedMethods(): void
{
$operation = (new HttpOperation('GET', '/dummy_resources{._format}'));
$context = $this->getContext();

$processor = new LinkedDataPlatformProcessor(
$this->decorated,
$this->resourceClassResolver,
$this->resourceMetadataCollectionFactory
);
/** @var Response $response */
$response = $processor->process(null, $operation, [], $context);
$allowHeader = $response->headers->get('Allow');
$this->assertStringContainsString('OPTIONS', $allowHeader);
$this->assertStringContainsString('HEAD', $allowHeader);
$this->assertStringContainsString('GET', $allowHeader);
$this->assertStringContainsString('POST', $allowHeader);

$operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));

/** @var Response $response */
$processor = new LinkedDataPlatformProcessor(
$this->decorated,
$this->resourceClassResolver,
$this->resourceMetadataCollectionFactory
);
/** @var Response $response */
$response = $processor->process('data', $operation, [], $this->getContext());
$allowHeader = $response->headers->get('Allow');
$this->assertStringContainsString('OPTIONS', $allowHeader);
$this->assertStringContainsString('HEAD', $allowHeader);
$this->assertStringContainsString('GET', $allowHeader);
$this->assertStringContainsString('PUT', $allowHeader);
$this->assertStringContainsString('DELETE', $allowHeader);
}

public function testProcessorWithoutRequiredConditionReturnOriginalResponse(): void
{
$operation = (new HttpOperation('GET', '/dummy_resources/{dummyResourceId}{._format}'));

// No collection factory
$processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, null);
/** @var Response $response */
$response = $processor->process(null, $operation, [], $this->getContext());

$this->assertNull($response->headers->get('Allow'));

// No uri variable in context
$processor = new LinkedDataPlatformProcessor($this->decorated, null, $this->resourceMetadataCollectionFactory);
$response = $processor->process(null, $operation, [], $this->getContext());
$this->assertNull($response->headers->get('Allow'));

// Operation is an Error
$processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory);
$response = $processor->process(null, new Error(), $this->getContext());
$this->assertNull($response->headers->get('Allow'));

// Not a resource class
$this->resourceClassResolver
->method('isResourceClass')
->willReturn(false);
$processor = new LinkedDataPlatformProcessor($this->decorated, $this->resourceClassResolver, $this->resourceMetadataCollectionFactory);
$response = $processor->process(null, $operation, []);
$this->assertNull($response->headers->get('Allow'));
}

private function createGetRequest(): Request
{
$request = new Request();
$request->setMethod('GET');
$request->setRequestFormat('json');
$request->headers->set('Accept', 'application/ld+json');

return $request;
}

private function getContext(): array
{
return [
'resource_class' => 'DummyResource',
'request' => $this->createGetRequest(),
];
}
}
6 changes: 6 additions & 0 deletions src/Symfony/Bundle/Resources/config/state/processor.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,11 @@
<service id="api_platform.state_processor.add_link_header" class="ApiPlatform\State\Processor\AddLinkHeaderProcessor" decorates="api_platform.state_processor.respond">
<argument type="service" id="api_platform.state_processor.add_link_header.inner" />
</service>

<service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
<argument type="service" id="api_platform.state_processor.linked_data_platform.inner" />
<argument type="service" id="api_platform.resource_class_resolver" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
</service>
</services>
</container>
6 changes: 6 additions & 0 deletions src/Symfony/Bundle/Resources/config/symfony/events.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
<argument type="service" id="api_platform.state_processor.add_link_header.inner" />
</service>

<service id="api_platform.state_processor.linked_data_platform" class="ApiPlatform\State\Processor\LinkedDataPlatformProcessor" decorates="api_platform.state_processor.respond">
<argument type="service" id="api_platform.state_processor.linked_data_platform.inner" />
<argument type="service" id="api_platform.resource_class_resolver" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
</service>

<service id="api_platform.listener.view.write" class="ApiPlatform\Symfony\EventListener\WriteListener">
<argument type="service" id="api_platform.state_processor.write" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
Expand Down
Loading
Loading