Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ final class ItemNormalizer extends AbstractItemNormalizer
*/
public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id';

/**
* Denormalization context flag making the resource object "type" mandatory (400) and, on POST,
* checked against the endpoint resource type (409). Set in the context or via the bundle
* configuration ("api_platform.jsonapi.strict_resource_type").
*/
public const STRICT_RESOURCE_TYPE = 'strict_resource_type';

private array $componentsCache = [];
private bool $useIriAsId;
private readonly ResourceLinkageResolver $resourceLinkageResolver;
Expand Down Expand Up @@ -439,15 +446,4 @@ private function getIdStringFromIdentifiers(array $identifiers): string

return CompositeIdentifierParser::stringify($identifiers);
}

private function getResourceShortName(string $resourceClass): string
{
if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
$resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass);

return $resourceMetadata->getOperation()->getShortName();
}

return (new \ReflectionClass($resourceClass))->getShortName();
}
}
50 changes: 50 additions & 0 deletions src/JsonApi/Serializer/ItemNormalizerTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
namespace ApiPlatform\JsonApi\Serializer;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Exception\BadRequestException;
use ApiPlatform\Metadata\Exception\ConflictException;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\UrlGeneratorInterface;
Expand Down Expand Up @@ -53,6 +55,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
}

$operation = $context['operation'] ?? null;
$this->assertValidRequestDocument($data, $operation, $type, $context);

$isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod();
$allowClientGeneratedId = true === ($context[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[ItemNormalizer::ALLOW_CLIENT_GENERATED_ID] ?? false);

Expand Down Expand Up @@ -89,6 +93,52 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
return parent::denormalize($dataToDenormalize, $type, $format, $context);
}

private function assertValidRequestDocument(mixed $data, mixed $operation, string $resourceClass, array $context): void
{
if (!\is_array($data) || !isset($data['data']) || !$this->isJsonObject($data['data'])) {
throw new BadRequestException('The request document must be an object with a "data" member holding a resource object.');
}

foreach (['attributes', 'relationships'] as $member) {
if (\array_key_exists($member, $data['data']) && !$this->isJsonObject($data['data'][$member])) {
throw new BadRequestException(\sprintf('The "%s" member of the resource object must be an object.', $member));
}
}

if (true !== ($context[ItemNormalizer::STRICT_RESOURCE_TYPE] ?? $this->defaultContext[ItemNormalizer::STRICT_RESOURCE_TYPE] ?? false)) {
return;
}

if (!isset($data['data']['type']) || !\is_string($data['data']['type'])) {
throw new BadRequestException('The resource object must have a "type" member.');
}

if (!$operation instanceof HttpOperation || 'POST' !== $operation->getMethod()) {
return;
}

$acceptedTypes = array_filter([$operation->getShortName(), $this->getResourceShortName($resourceClass)]);
if (!\in_array($data['data']['type'], $acceptedTypes, true)) {
throw new ConflictException(\sprintf('The resource object type "%s" does not match the type of this endpoint, expected one of "%s".', $data['data']['type'], implode('", "', array_unique($acceptedTypes))));
}
}

private function getResourceShortName(string $resourceClass): string
{
if ($this->resourceClassResolver->isResourceClass($resourceClass)) {
$resourceMetadata = $this->resourceMetadataCollectionFactory->create($resourceClass);

return $resourceMetadata->getOperation()->getShortName();
}

return (new \ReflectionClass($resourceClass))->getShortName();
}

private function isJsonObject(mixed $value): bool
{
return \is_array($value) && ([] === $value || !array_is_list($value));
}

protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool
{
return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context);
Expand Down
2 changes: 2 additions & 0 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ public function register(): void
$config = $app['config'];
$defaultContext = $config->get('api-platform.serializer', []);
$defaultContext[JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID] = (bool) $config->get('api-platform.jsonapi.allow_client_generated_id', false);
$defaultContext[JsonApiItemNormalizer::STRICT_RESOURCE_TYPE] = (bool) $config->get('api-platform.jsonapi.strict_resource_type', false);
$useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', false);

return new JsonApiItemNormalizer(
Expand All @@ -1109,6 +1110,7 @@ public function register(): void
$this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) {
$config = $app['config'];
$defaultContext = $config->get('api-platform.serializer', []);
$defaultContext[JsonApiItemNormalizer::STRICT_RESOURCE_TYPE] = (bool) $config->get('api-platform.jsonapi.strict_resource_type', false);

return new JsonApiItemDenormalizer(
$app->make(PropertyNameCollectionFactoryInterface::class),
Expand Down
81 changes: 81 additions & 0 deletions src/Laravel/Tests/JsonApiStrictResourceTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Laravel\Tests;

use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
use Symfony\Component\HttpFoundation\Response;
use Workbench\App\Models\Author;
use Workbench\Database\Factories\AuthorFactory;

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

/**
* @param Application $app
*/
protected function defineEnvironment($app): void
{
tap($app['config'], static function (Repository $config): void {
$config->set('api-platform.formats', ['jsonapi' => ['application/vnd.api+json']]);
$config->set('api-platform.docs_formats', ['jsonapi' => ['application/vnd.api+json']]);
$config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]);
$config->set('api-platform.jsonapi', ['use_iri_as_id' => false, 'strict_resource_type' => true]);
$config->set('api-platform.defaults', ['route_prefix' => '/api']);
$config->set('app.debug', true);
});
}

public function testMissingTypeIsABadRequest(): void
{
$this->postBook(['attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897']])->assertStatus(400);
}

public function testWrongTypeIsAConflict(): void
{
$this->postBook(['type' => 'Author', 'attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897']])->assertStatus(409);
}

public function testCorrectTypeIsAccepted(): void
{
AuthorFactory::new()->create();

$this->postBook([
'type' => 'Book',
'attributes' => ['name' => 'Don Quichotte', 'isbn' => '9781234567897'],
'relationships' => ['author' => ['data' => ['type' => 'Author', 'id' => $this->getIriFromResource(Author::find(1))]]],
])->assertStatus(201);
}

/**
* @param array<string, mixed> $resource
*
* @return TestResponse<Response>
*/
private function postBook(array $resource): TestResponse
{
return $this->postJson('/api/books', ['data' => $resource], [
'accept' => 'application/vnd.api+json',
'content_type' => 'application/vnd.api+json',
]);
}
}
5 changes: 5 additions & 0 deletions src/Laravel/config/api-platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@
// https://jsonapi.org/format/#crud-creating-client-ids. Off by default
// to avoid id spoofing on public endpoints.
'allow_client_generated_id' => false,

// Require the "type" member of JSON:API request resource objects (400
// when missing) and reject a "type" that does not match the endpoint
// resource on POST (409 Conflict). Off by default.
'strict_resource_type' => false,
],

'graphql' => [
Expand Down
33 changes: 33 additions & 0 deletions src/Metadata/Exception/ConflictException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Metadata\Exception;

/**
* Framework-agnostic 409 Conflict, mapped by both the Symfony and Laravel error handlers.
*/
class ConflictException extends \RuntimeException implements ExceptionInterface, HttpExceptionInterface
{
public function getStatusCode(): int
{
return 409;
}

/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return [];
}
}
10 changes: 8 additions & 2 deletions src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -721,12 +721,18 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array
$useIriAsId = $config['jsonapi']['use_iri_as_id'];

$itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item');
$itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]);
$itemNormalizer->replaceArgument(7, [
JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false,
JsonApiItemNormalizer::STRICT_RESOURCE_TYPE => $config['jsonapi']['strict_resource_type'] ?? false,
]);
$itemNormalizer->addArgument($useIriAsId);
$itemNormalizer->addArgument(new Reference('api_platform.jsonapi.resource_linkage_resolver'));

$itemDenormalizer = $container->getDefinition('api_platform.jsonapi.denormalizer.item');
$itemDenormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]);
$itemDenormalizer->replaceArgument(7, [
JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false,
JsonApiItemNormalizer::STRICT_RESOURCE_TYPE => $config['jsonapi']['strict_resource_type'] ?? false,
]);
$itemDenormalizer->addArgument($useIriAsId);
}

Expand Down
4 changes: 4 additions & 0 deletions src/Symfony/Bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultFalse()
->info('Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints.')
->end()
->booleanNode('strict_resource_type')
->defaultFalse()
->info('Require the "type" member of JSON:API request resource objects (400 when missing) and reject a "type" that does not match the endpoint resource on POST (409 Conflict), per https://jsonapi.org/format/#crud-creating-responses-409.')
->end()
->end()
->end()
->arrayNode('eager_loading')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource\JsonApi;

use ApiPlatform\JsonApi\Serializer\ItemNormalizer;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;

#[ApiResource(
shortName: 'JsonApiStrictType',
formats: ['jsonapi' => ['application/vnd.api+json']],
operations: [
new Get(uriTemplate: '/jsonapi_strict_types/{id}', provider: [self::class, 'provide']),
new Post(
uriTemplate: '/jsonapi_strict_types',
denormalizationContext: [ItemNormalizer::STRICT_RESOURCE_TYPE => true],
processor: [self::class, 'process'],
),
new Post(
uriTemplate: '/jsonapi_lenient_types',
processor: [self::class, 'process'],
),
],
)]
#[ApiResource(
shortName: 'JsonApiStrictTypeAlias',
formats: ['jsonapi' => ['application/vnd.api+json']],
operations: [
new Post(
uriTemplate: '/jsonapi_strict_type_aliases',
denormalizationContext: [ItemNormalizer::STRICT_RESOURCE_TYPE => true],
processor: [self::class, 'process'],
),
],
)]
class StrictTypeResource
{
#[ApiProperty(identifier: true)]
public int $id = 1;

public ?string $name = null;

public static function provide(): self
{
return new self();
}

public static function process(self $data): self
{
return $data;
}
}
Loading
Loading