Skip to content
14 changes: 13 additions & 1 deletion src/JsonApi/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\JsonSchema\SchemaUriPrefixTrait;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
Expand Down Expand Up @@ -282,6 +283,10 @@ 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 operation for the resource being built; the loop below
// reassigns $operation while resolving relationships.
$resourceOperation = $operation;

$definitions = $schema->getDefinitions();
$properties = $definitions[$key]['properties'] ?? [];

Expand Down Expand Up @@ -369,11 +374,18 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
];
}

// Per JSON:API spec, `id` is optional in the request body of a creation:
// https://jsonapi.org/format/#crud-creating
$required = ['type', 'id'];
if (Schema::TYPE_INPUT === $type && $resourceOperation instanceof Post) {
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.

we prefer $resourceOperation->getMethod()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated, I switched to 'POST' === $resourceOperation->getMethod() and dropped the now-unused Post import. Added a null guard since $resourceOperation is typed ?Operation. Thanks! 🙂

$required = ['type'];
}

return [
'data' => [
'type' => 'object',
'properties' => $replacement,
'required' => ['type', 'id'],
'required' => $required,
],
] + $included;
}
Expand Down
40 changes: 29 additions & 11 deletions src/JsonApi/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
Expand Down Expand Up @@ -61,6 +62,13 @@ final class ItemNormalizer extends AbstractItemNormalizer

public const FORMAT = 'jsonapi';

/**
* Opt-in flag enabling client-generated IDs on a POST request, per
* https://jsonapi.org/format/#crud-creating-client-ids
* Off by default to prevent ID spoofing on public endpoints.
*/
public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id';

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

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

$operation = $context['operation'] ?? null;
$allowClientGeneratedId = true === ($context[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 ($operation instanceof Post) {
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.

same lets prefer the getMethod approach

if (!$allowClientGeneratedId) {
throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Pass "%s" => true in the denormalization context to enable.', self::ALLOW_CLIENT_GENERATED_ID));
}
// Fall through: id flows 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 @@ -234,6 +247,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 ($operation instanceof Post && $allowClientGeneratedId && isset($data['data']['id'])) {
$dataToDenormalize['id'] = $data['data']['id'];
}

return parent::denormalize(
$dataToDenormalize,
$type,
Expand Down
20 changes: 20 additions & 0 deletions src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
Expand Down Expand Up @@ -49,6 +50,7 @@ protected function setUp(): void
);
$propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection());
$propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection());
$propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class);

$definitionNameFactory = new DefinitionNameFactory(null);
Expand Down Expand Up @@ -164,4 +166,22 @@ public function testSchemaTypeBuildSchema(): void
$forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true);
$this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']);
}

public function testBuildSchemaForPostInputDoesNotRequireId(): void
{
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Post());
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
$properties = $resultSchema['definitions'][$rootDefinitionKey]['properties'];

$this->assertSame(['type'], $properties['data']['required']);
}

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

$this->assertSame(['type', 'id'], $properties['data']['required']);
}
}
89 changes: 89 additions & 0 deletions src/JsonApi/Tests/Serializer/ItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Metadata\Property\PropertyNameCollection;
Expand Down Expand Up @@ -989,4 +990,92 @@ public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): vo
$this->assertSame('Hello', $result->title);
$this->assertSame('World', $result->body);
}

public function testDenormalizePostWithIdThrowsWithoutOptIn(): void
{
$this->expectException(NotNormalizableValueException::class);
$this->expectExceptionMessage('Client-generated IDs are not allowed on this operation.');

$normalizer = new ItemNormalizer(
$this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(),
$this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(),
$this->prophesize(IriConverterInterface::class)->reveal(),
$this->prophesize(ResourceClassResolverInterface::class)->reveal(),
);

$normalizer->denormalize(
[
'data' => [
'id' => 'b1f3e6a4-1234-4abc-9def-0123456789ab',
'type' => 'dummy',
],
],
Dummy::class,
ItemNormalizer::FORMAT,
[
'operation' => new Post(),
]
);
}

public function testDenormalizePostWithIdSucceedsWithOptIn(): void
{
$clientId = 'b1f3e6a4-1234-4abc-9def-0123456789ab';
$data = [
'data' => [
'type' => 'dummy',
'id' => $clientId,
'attributes' => [
'name' => 'foo',
],
],
];

$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name']));

$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)->withIdentifier(true));
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true));

// The IRI converter MUST NOT be queried for an existing resource on POST with a client-generated id.
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
$iriConverterProphecy->getResourceFromIri(Argument::cetera())->shouldNotBeCalled();

$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
$propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->shouldBeCalled();
$propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'id', $clientId)->shouldBeCalled();

$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
$resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class);
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);

$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [
(new ApiResource())->withOperations(new Operations([new Post(name: 'post')])),
]));

$serializerProphecy = $this->prophesize(SerializerInterface::class);
$serializerProphecy->willImplement(NormalizerInterface::class);

$normalizer = new ItemNormalizer(
$propertyNameCollectionFactoryProphecy->reveal(),
$propertyMetadataFactoryProphecy->reveal(),
$iriConverterProphecy->reveal(),
$resourceClassResolverProphecy->reveal(),
$propertyAccessorProphecy->reveal(),
new ReservedAttributeNameConverter(),
null,
[],
$resourceMetadataCollectionFactory->reveal(),
);
$normalizer->setSerializer($serializerProphecy->reveal());

$result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [
'operation' => new Post(),
ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true,
]);

$this->assertInstanceOf(Dummy::class, $result);
}
}
Loading