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
5 changes: 4 additions & 1 deletion src/JsonApi/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void

private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
{
// Capture the resource's operation up front; the relationship loop below reassigns $operation.
$resourceOperation = $operation;
$definitions = $schema->getDefinitions();
$properties = $definitions[$key]['properties'] ?? [];

Expand Down Expand Up @@ -371,7 +373,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
}

// https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource.
$required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id'];
// Only relax the requirement on the input schema; responses still always carry an `id`.
$required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id'];

return [
'data' => [
Expand Down
41 changes: 30 additions & 11 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ final class ItemNormalizer extends AbstractItemNormalizer

public const FORMAT = 'jsonapi';

/**
* Denormalization context flag enabling client-generated IDs on POST per
* https://jsonapi.org/format/#crud-creating-client-ids. Off by default to
* avoid an id-spoofing footgun on public endpoints. Set in the context or
* via the bundle configuration ("api_platform.jsonapi.allow_client_generated_id").
*/
public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id';

private array $componentsCache = [];
private bool $useIriAsId;

Expand Down Expand Up @@ -205,21 +213,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
return parent::denormalize($data, $type, $format, $context);
}

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

// Avoid issues with proxies if we populated the object
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
if (true !== ($context['api_allow_update'] ?? true)) {
if ($isPostOperation) {
if (!$allowClientGeneratedId) {
throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', self::ALLOW_CLIENT_GENERATED_ID));
}
// Fall through: client id is merged into the denormalized payload below.
} elseif (true !== ($context['api_allow_update'] ?? true)) {
throw new NotNormalizableValueException('Update is not allowed for this operation.');
}

$context += ['fetch_data' => false];
if ($this->useIriAsId) {
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
$data['data']['id'],
$context
);
} else {
$operation = $context['operation'] ?? null;
if ($operation instanceof HttpOperation) {
$context += ['fetch_data' => false];
if ($this->useIriAsId) {
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
$data['data']['id'],
$context
);
} elseif ($operation instanceof HttpOperation) {
$iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation);
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context);
}
Expand All @@ -232,6 +246,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
$data['data']['relationships'] ?? []
);

// Surface the client-generated id so the entity setter receives it.
if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) {
$dataToDenormalize['id'] = $data['data']['id'];
}

return parent::denormalize(
$dataToDenormalize,
$type,
Expand Down
10 changes: 10 additions & 0 deletions src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,14 @@ public function testPatchInputSchemaRequiresId(): void
$data = $definitions[$rootDefinitionKey]['properties']['data'];
$this->assertSame(['type', 'id'], $data['required']);
}

public function testPostOutputSchemaRequiresId(): void
{
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post());
$definitions = $resultSchema->getDefinitions();
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();

$data = $definitions[$rootDefinitionKey]['properties']['data'];
$this->assertSame(['type', 'id'], $data['required']);
}
}
1 change: 1 addition & 0 deletions src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,7 @@ public function register(): void
$this->app->singleton(JsonApiItemNormalizer::class, static function (Application $app) {
$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);
$useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', true);

return new JsonApiItemNormalizer(
Expand Down
5 changes: 5 additions & 0 deletions src/Laravel/config/api-platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@
// and a `data.links.self` IRI is added. When true (default), `data.id`
// is the resource IRI.
'use_iri_as_id' => true,

// Allow client-generated IDs on JSON:API POST per
// https://jsonapi.org/format/#crud-creating-client-ids. Off by default
// to avoid id spoofing on public endpoints.
'allow_client_generated_id' => false,
],

'graphql' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\AsOperationMutator;
use ApiPlatform\Metadata\AsResourceMutator;
Expand Down Expand Up @@ -705,8 +706,9 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array
$loader->load('jsonapi.php');
$loader->load('state/jsonapi.php');

$container->getDefinition('api_platform.jsonapi.normalizer.item')
->addArgument($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->addArgument($config['jsonapi']['use_iri_as_id']);
}

private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void
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 @@ -108,6 +108,10 @@ public function getConfigTreeBuilder(): TreeBuilder
->defaultTrue()
->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.')
->end()
->booleanNode('allow_client_generated_id')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to change ConfigurationTest::testDefaultConfig

->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()
->end()
->end()
->arrayNode('eager_loading')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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: 'JsonApiClientGeneratedId',
formats: ['jsonapi' => ['application/vnd.api+json']],
operations: [
new Get(
uriTemplate: '/jsonapi_client_generated_ids/{id}',
uriVariables: ['id'],
provider: [self::class, 'provide'],
),
new Post(
uriTemplate: '/jsonapi_client_generated_ids_opt_in',
denormalizationContext: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true],
processor: [self::class, 'process'],
),
new Post(
uriTemplate: '/jsonapi_client_generated_ids',
processor: [self::class, 'process'],
),
],
)]
class ClientGeneratedId
{
#[ApiProperty(identifier: true)]
public ?string $id = null;

public ?string $name = null;

public static function provide(): self
{
$resource = new self();
$resource->id = '1';
$resource->name = 'existing';

return $resource;
}

public static function process(self $data): self
{
$data->id ??= 'server-generated';

return $data;
}
}
95 changes: 95 additions & 0 deletions tests/Functional/JsonApi/ClientGeneratedIdTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?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\Functional\JsonApi;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ClientGeneratedId;
use ApiPlatform\Tests\SetupClassResourcesTrait;

final class ClientGeneratedIdTest extends ApiTestCase
{
use SetupClassResourcesTrait;

protected static ?bool $alwaysBootKernel = false;

/**
* @return class-string[]
*/
public static function getResources(): array
{
return [ClientGeneratedId::class];
}

public function testPostWithClientIdSucceedsWhenOptedIn(): void
{
$response = self::createClient()->request('POST', '/jsonapi_client_generated_ids_opt_in', [
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
'json' => [
'data' => [
'type' => 'JsonApiClientGeneratedId',
'id' => 'client-uuid-42',
'attributes' => ['name' => 'created with client id'],
],
],
]);

$this->assertResponseStatusCodeSame(201);
$body = $response->toArray();
// Default identifier mode emits the IRI as `data.id`. The IRI must reflect the client-supplied id.
$this->assertSame('/jsonapi_client_generated_ids/client-uuid-42', $body['data']['id']);
$this->assertSame('created with client id', $body['data']['attributes']['name']);
}

public function testPostWithClientIdRejectedByDefault(): void
{
self::createClient()->request('POST', '/jsonapi_client_generated_ids', [
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
'json' => [
'data' => [
'type' => 'JsonApiClientGeneratedId',
'id' => 'client-uuid-43',
'attributes' => ['name' => 'should fail'],
],
],
]);

$this->assertResponseStatusCodeSame(400);
}

public function testPostWithoutClientIdStillSucceeds(): void
{
$response = self::createClient()->request('POST', '/jsonapi_client_generated_ids', [
'headers' => [
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
],
'json' => [
'data' => [
'type' => 'JsonApiClientGeneratedId',
'attributes' => ['name' => 'server-side id'],
],
],
]);

$this->assertResponseStatusCodeSame(201);
$body = $response->toArray();
$this->assertSame('server-side id', $body['data']['attributes']['name']);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm
],
'jsonapi' => [
'use_iri_as_id' => true,
'allow_client_generated_id' => false,
],
'enable_scalar' => true,
], $config);
Expand Down
Loading