From 555c8581c8c709476c736a074d1752b7a3bfb70e Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 6 Oct 2026 17:29:31 +0200 Subject: [PATCH] fix(json-schema): nested gen_id follows the property The first relation's gen_id stuck to every deeper embedded definition, while the serializer lets each property's genId win and only inherits when it is null. Schemas then advertised `@id` (or not) differently from the payload. --- src/JsonSchema/SchemaFactory.php | 2 +- .../GenIdChainInverseMiddle.php | 42 ++++++++ .../GenIdChainInverseRoot.php | 42 ++++++++ .../JsonSchemaGenIdChain/GenIdChainLeaf.php | 41 ++++++++ .../JsonSchemaGenIdChain/GenIdChainMiddle.php | 42 ++++++++ .../GenIdChainNullMiddle.php | 41 ++++++++ .../GenIdChainNullRoot.php | 42 ++++++++ .../JsonSchemaGenIdChain/GenIdChainRoot.php | 42 ++++++++ .../JsonSchema/JsonSchemaGenIdChainTest.php | 95 +++++++++++++++++++ 9 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseMiddle.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseRoot.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainLeaf.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainMiddle.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullMiddle.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullRoot.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainRoot.php create mode 100644 tests/Functional/JsonSchema/JsonSchemaGenIdChainTest.php diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index e1d31d3055..1864311c6d 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -311,7 +311,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str continue; } - $childSerializerContext = $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true]; + $childSerializerContext = [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? ($serializerContext['gen_id'] ?? true)] + $serializerContext; if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES]; if (\is_array($attributes) && \array_key_exists($normalizedPropertyName, $attributes) && \is_array($attributes[$normalizedPropertyName])) { diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseMiddle.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseMiddle.php new file mode 100644 index 0000000000..0400199f9d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseMiddle.php @@ -0,0 +1,42 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/inverse_middles/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainInverseMiddle +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[ApiProperty(genId: false)] + #[Groups(['chain:read'])] + public ?GenIdChainLeaf $second = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseRoot.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseRoot.php new file mode 100644 index 0000000000..325f4ad3a8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainInverseRoot.php @@ -0,0 +1,42 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/inverse_roots/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainInverseRoot +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[ApiProperty(genId: true)] + #[Groups(['chain:read'])] + public ?GenIdChainInverseMiddle $first = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainLeaf.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainLeaf.php new file mode 100644 index 0000000000..229dc88548 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainLeaf.php @@ -0,0 +1,41 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/leaves/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainLeaf +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[Groups(['chain:read'])] + public ?string $label = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainMiddle.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainMiddle.php new file mode 100644 index 0000000000..636f2d2d17 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainMiddle.php @@ -0,0 +1,42 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/middles/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainMiddle +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[ApiProperty(genId: true)] + #[Groups(['chain:read'])] + public ?GenIdChainLeaf $second = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullMiddle.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullMiddle.php new file mode 100644 index 0000000000..62c0420118 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullMiddle.php @@ -0,0 +1,41 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/null_middles/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainNullMiddle +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[Groups(['chain:read'])] + public ?GenIdChainLeaf $second = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullRoot.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullRoot.php new file mode 100644 index 0000000000..dd2686b8a6 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainNullRoot.php @@ -0,0 +1,42 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/null_roots/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainNullRoot +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[ApiProperty(genId: false)] + #[Groups(['chain:read'])] + public ?GenIdChainNullMiddle $first = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainRoot.php b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainRoot.php new file mode 100644 index 0000000000..0145713f8c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonSchemaGenIdChain/GenIdChainRoot.php @@ -0,0 +1,42 @@ + + * + * 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\JsonSchemaGenIdChain; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [ + new Get( + uriTemplate: '/json_schema_gen_id_chain/roots/{id}', + normalizationContext: ['groups' => ['chain:read']], + provider: [self::class, 'provide'], + ), +])] +class GenIdChainRoot +{ + #[ApiProperty(identifier: true)] + #[Groups(['chain:read'])] + public ?int $id = null; + + #[ApiProperty(genId: false)] + #[Groups(['chain:read'])] + public ?GenIdChainMiddle $first = null; + + public static function provide(): ?self + { + return null; + } +} diff --git a/tests/Functional/JsonSchema/JsonSchemaGenIdChainTest.php b/tests/Functional/JsonSchema/JsonSchemaGenIdChainTest.php new file mode 100644 index 0000000000..0d25a1800a --- /dev/null +++ b/tests/Functional/JsonSchema/JsonSchemaGenIdChainTest.php @@ -0,0 +1,95 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainInverseMiddle; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainInverseRoot; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainLeaf; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainMiddle; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainNullMiddle; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainNullRoot; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonSchemaGenIdChain\GenIdChainRoot; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class JsonSchemaGenIdChainTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected SchemaFactoryInterface $schemaFactory; + + protected static ?bool $alwaysBootKernel = false; + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + GenIdChainRoot::class, + GenIdChainMiddle::class, + GenIdChainLeaf::class, + GenIdChainInverseRoot::class, + GenIdChainInverseMiddle::class, + GenIdChainNullRoot::class, + GenIdChainNullMiddle::class, + ]; + } + + public function testExplicitTrueGenIdBelowFalseRelationKeepsId(): void + { + [$name, $definition] = $this->findLeafDefinition(GenIdChainRoot::class); + + $this->assertStringEndsNotWith('_noid', $name); + $this->assertSame('#/definitions/HydraItemBaseSchema', $definition['allOf'][0]['$ref']); + } + + public function testFalseGenIdBelowTrueRelationDropsId(): void + { + [$name, $definition] = $this->findLeafDefinition(GenIdChainInverseRoot::class); + + $this->assertStringEndsWith('_noid', $name); + $this->assertSame('#/definitions/HydraItemBaseSchemaWithoutId', $definition['allOf'][0]['$ref']); + } + + public function testNullGenIdInheritsFromParent(): void + { + [$name, $definition] = $this->findLeafDefinition(GenIdChainNullRoot::class); + + $this->assertStringEndsWith('_noid', $name); + $this->assertSame('#/definitions/HydraItemBaseSchemaWithoutId', $definition['allOf'][0]['$ref']); + } + + /** + * @return array{string, array} + */ + private function findLeafDefinition(string $class): array + { + $schema = $this->schemaFactory->buildSchema($class, 'jsonld'); + $definitions = $schema->getDefinitions(); + + $names = array_filter(array_keys($definitions->getArrayCopy()), static fn (string $name): bool => str_starts_with($name, 'GenIdChainLeaf')); + $this->assertCount(1, $names, json_encode(array_keys($definitions->getArrayCopy()))); + $name = reset($names); + + return [$name, $definitions[$name]->getArrayCopy()]; + } +}