diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 17f3e172304..3e3b40a5a82 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -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'] ?? []; @@ -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 HttpOperation && 'POST' === $resourceOperation->getMethod()) { + $required = ['type']; + } + return [ 'data' => [ 'type' => 'object', 'properties' => $replacement, - 'required' => ['type', 'id'], + 'required' => $required, ], ] + $included; } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 4d9d6e225cf..536abb43a1e 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -61,6 +61,18 @@ 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. + * + * Can be enabled either via the denormalization context, or declaratively + * on the operation via `extraProperties`: + * + * #[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])] + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + private array $componentsCache = []; private bool $useIriAsId; @@ -207,21 +219,32 @@ 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 = $context[self::ALLOW_CLIENT_GENERATED_ID] ?? null; + if (null === $allowClientGeneratedId && $isPostOperation) { + $allowClientGeneratedId = $operation->getExtraProperties()[self::ALLOW_CLIENT_GENERATED_ID] ?? false; + } + $allowClientGeneratedId = true === $allowClientGeneratedId; + // 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. Pass "%s" => true in the denormalization context (or set it via the operation\'s extraProperties) 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); } @@ -234,6 +257,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, diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 4adcb6480ce..e8ab12e9eea 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -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; @@ -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); @@ -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']); + } } diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index 683ed89cd31..0c7f9ab830b 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -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; @@ -989,4 +990,151 @@ 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); + } + + public function testDenormalizePostWithIdSucceedsWhenEnabledViaExtraProperties(): 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)); + + $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(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true]), + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } }