Skip to content

Commit

Permalink
feat(state): improvements and fixes for LinkedDataPlatform support
Browse files Browse the repository at this point in the history
- use LinkedDataPlatformProcessor instead of RespondProcessor to handle the allow and allow-post headers
- add new Processor in symfony events and Laravel
  • Loading branch information
LaurentHuzard committed Jan 21, 2025
1 parent 42d61d3 commit dea6352
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 233 deletions.
5 changes: 5 additions & 0 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 @@ -560,6 +561,10 @@ public function register(): void
return new AddLinkHeaderProcessor(new RespondProcessor(), new HttpHeaderSerializer());
});

$this->app->singleton(RespondProcessor::class, function (Application $app) {
return new LinkedDataPlatformProcessor(new RespondProcessor(), $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));
});
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');
}
}
77 changes: 77 additions & 0 deletions src/State/Processor/LinkedDataPlatformProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?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_METHOD = ['OPTIONS', 'HEAD'];

/**
* @param ProcessorInterface<T1, T2> $decorated
*/
public function __construct(
private readonly ProcessorInterface $decorated, // todo is processor interface nullable
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = 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
|| null === $this->resourceCollectionMetadataFactory
|| !($context['resource_class'] ?? null)
|| null === $operation->getUriTemplate()
|| !$this->resourceClassResolver?->isResourceClass($context['resource_class'])
) {
return $response;
}

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

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

return $response;
}
}
25 changes: 0 additions & 25 deletions src/State/Processor/RespondProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Metadata\Util\ClassInfoTrait;
Expand All @@ -46,13 +45,10 @@ final class RespondProcessor implements ProcessorInterface
'DELETE' => Response::HTTP_NO_CONTENT,
];

private const DEFAULT_ALLOWED_METHOD = ['OPTIONS', 'HEAD'];

public function __construct(
private ?IriConverterInterface $iriConverter = null,
private readonly ?ResourceClassResolverInterface $resourceClassResolver = null,
private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null,
private readonly ?ResourceMetadataCollectionFactoryInterface $resourceCollectionMetadataFactory = null,
) {
}

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

if (!$exception) {
$isPostAllowed = false;
$allowedMethods = self::DEFAULT_ALLOWED_METHOD;
if (null !== ($context['resource_class'] ?? null) && null !== $this->resourceCollectionMetadataFactory && null !== ($currentUriTemplate = $operation->getUriTemplate()) && $this->resourceClassResolver?->isResourceClass($context['resource_class'])) {
$resourceMetadataCollection = $this->resourceCollectionMetadataFactory->create($context['resource_class']);
foreach ($resourceMetadataCollection as $resource) {
foreach ($resource->getOperations() as $resourceOperation) {
if ($resourceOperation->getUriTemplate() === $currentUriTemplate) {
$allowedMethods[] = $operationMethod = $resourceOperation->getMethod();
$isPostAllowed = $isPostAllowed || ('POST' === $operationMethod);
}
}
}
}
$headers['Allow'] = implode(', ', $allowedMethods);

if ($isPostAllowed && \is_array($outputFormats = ($outputFormats = $operation->getOutputFormats())) && [] !== $outputFormats) {
$headers['Accept-Post'] = implode(', ', array_merge(...array_values($outputFormats)));
}
}

$method = $request->getMethod();
$originalData = $context['original_data'] ?? null;

Expand Down
Loading

0 comments on commit dea6352

Please sign in to comment.