From 6aa622f8e06f2c927c142fb425aea75cef394991 Mon Sep 17 00:00:00 2001 From: Olivier Massot Date: Tue, 17 Feb 2026 17:02:40 +0100 Subject: [PATCH 1/4] feat: add global defaults.normalization_context.gen_id configuration option --- src/Serializer/OperationContextTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Serializer/OperationContextTrait.php b/src/Serializer/OperationContextTrait.php index 515d2a0ded8..1ea99dd1b7e 100644 --- a/src/Serializer/OperationContextTrait.php +++ b/src/Serializer/OperationContextTrait.php @@ -39,7 +39,7 @@ protected function createOperationContext(array $context, ?string $resourceClass // At some point we should merge the jsonld context here, there's a TODO to simplify this somewhere else if ($propertyMetadata) { $context['output'] ??= []; - $context['output']['gen_id'] = $propertyMetadata->getGenId() ?? true; + $context['output']['gen_id'] = $propertyMetadata->getGenId() ?? ($context['gen_id'] ?? true); } if (!$resourceClass) { From bc1c5d307143ce7c7cadc1b92599b69ad358d9d1 Mon Sep 17 00:00:00 2001 From: Olivier Massot Date: Wed, 18 Feb 2026 16:58:06 +0100 Subject: [PATCH 2/4] Adds global genId option support Allows configuring the `genId` option globally via `defaults.normalization_context`. Adds new functional tests to verify that `genId` works as expected when configured globally, and that the `genId` option on `ApiProperty` takes precedence over the global option. --- .../ApiResource/GenIdFalse/GenIdDefault.php | 44 ++++++++ .../ApiResource/GenIdFalse/GenIdTrue.php | 44 ++++++++ .../ApiResource/GenIdFalse/Subresource.php | 25 +++++ tests/Fixtures/app/AppKernel.php | 16 ++- tests/Functional/GenIdGlobalOptionTest.php | 101 ++++++++++++++++++ 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php create mode 100644 tests/Functional/GenIdGlobalOptionTest.php diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php new file mode 100644 index 00000000000..75743cd8b58 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php @@ -0,0 +1,44 @@ + + * + * 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\GenIdFalse; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Attribute\Ignore; + +#[Get(uriTemplate: '/gen_id_default', provider: [self::class, 'getData'], normalizationContext: ['hydra_prefix' => false])] +class GenIdDefault +{ + public function __construct( + public string $id, + #[ApiProperty] public Collection $subresources + ) { + } + + public static function getData(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self( + '1', + new ArrayCollection( + [ + new Subresource('foo'), + new Subresource('bar') + ] + ) + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php new file mode 100644 index 00000000000..e6951387a80 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php @@ -0,0 +1,44 @@ + + * + * 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\GenIdFalse; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Serializer\Attribute\Ignore; + +#[Get(uriTemplate: '/gen_id_truthy', provider: [self::class, 'getData'], normalizationContext: ['hydra_prefix' => false])] +class GenIdTrue +{ + public function __construct( + public string $id, + #[ApiProperty(genId: true)] public Collection $subresources + ) { + } + + public static function getData(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self( + '1', + new ArrayCollection( + [ + new Subresource('foo'), + new Subresource('bar') + ] + ) + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php new file mode 100644 index 00000000000..be789482b96 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php @@ -0,0 +1,25 @@ + + * + * 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\GenIdFalse; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +class Subresource +{ + public function __construct( + #[ApiProperty] public string $title + ) { + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 139b87aee8d..4374f32ddab 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -57,12 +57,15 @@ class AppKernel extends Kernel { use MicroKernelTrait; - public function __construct(string $environment, bool $debug) + private $genIdDefault; + + public function __construct(string $environment, bool $debug, $genIdDefault = null) { parent::__construct($environment, $debug); // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; + $this->genIdDefault = $genIdDefault ?? $_SERVER['GEN_ID_DEFAULT'] ?? null; } public function registerBundles(): array @@ -106,6 +109,11 @@ public function getProjectDir(): string return __DIR__; } + public function getCacheDir(): string + { + return parent::getCacheDir().(null !== $this->genIdDefault ? '_gen_id_'.$this->genIdDefault : ''); + } + protected function configureRoutes($routes): void { $routes->import(__DIR__."/config/routing_{$this->getEnvironment()}.yml"); @@ -114,6 +122,7 @@ protected function configureRoutes($routes): void protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void { $c->setParameter('kernel.project_dir', __DIR__); + $c->setParameter('app.gen_id_default', $this->genIdDefault); $loader->load(__DIR__."/config/config_{$this->getEnvironment()}.yml"); @@ -274,7 +283,10 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ 'vary' => ['Accept', 'Cookie'], 'public' => true, ], - 'normalization_context' => ['skip_null_values' => false], + 'normalization_context' => [ + 'skip_null_values' => false, + 'gen_id' => null !== $this->genIdDefault ? (bool) $this->genIdDefault : true, + ], 'operations' => [ Get::class, GetCollection::class, diff --git a/tests/Functional/GenIdGlobalOptionTest.php b/tests/Functional/GenIdGlobalOptionTest.php new file mode 100644 index 00000000000..729a1f9d309 --- /dev/null +++ b/tests/Functional/GenIdGlobalOptionTest.php @@ -0,0 +1,101 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\AggregateRating; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdDefault; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdTrue; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class GenIdGlobalOptionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = true; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + AggregateRating::class, + GenIdDefault::class, + GenIdTrue::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + unset($_SERVER['GEN_ID_DEFAULT']); + } + + protected function tearDown(): void + { + unset($_SERVER['GEN_ID_DEFAULT']); + parent::tearDown(); + } + + /** + * When gen_id is globally false and no #[ApiProperty(genId: ...)] on the property, + * the nested object must not expose an @id. + */ + public function testGlobalGenIdFalseDisablesSkolemIdByDefaultOnProperties(): void + { + $_SERVER['GEN_ID_DEFAULT'] = 0; // simulate global defaults.normalization_context.gen_id: false + + $response = self::createClient()->request( + 'GET', + '/gen_id_default' + ); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayNotHasKey('@id', $data['subresources'][0]); + } + + /** + * #[ApiProperty(genId: true)] on the property must take precedence. + */ + public function testApiPropertyGenIdTrueTakesPrecedenceOverGlobalFalse(): void + { + $_SERVER['GEN_ID_DEFAULT'] = 0; // simulate global defaults.normalization_context.gen_id: false + + $response = self::createClient()->request( + 'GET', + '/gen_id_truthy' + ); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayHasKey('@id', $data['subresources'][0]); + } + + /** + * Without a global option and without an attribute, genId must be true by default. + */ + public function testWhenNoGlobalOptionAndNoAttributeGenIdIsTrueByDefault(): void + { + $response = self::createClient()->request( + 'GET', + '/gen_id_default' + ); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayHasKey('@id', $data['subresources'][0]); + } +} From 0eace2064dab82e64f2b8c3b7a5bc71933724761 Mon Sep 17 00:00:00 2001 From: Olivier Massot Date: Wed, 18 Feb 2026 17:10:48 +0100 Subject: [PATCH 3/4] lint --- .../TestBundle/ApiResource/GenIdFalse/GenIdDefault.php | 5 ++--- .../Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php | 5 ++--- .../TestBundle/ApiResource/GenIdFalse/Subresource.php | 3 +-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php index 75743cd8b58..4093f3fb44b 100644 --- a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdDefault.php @@ -18,14 +18,13 @@ use ApiPlatform\Metadata\Operation; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Symfony\Component\Serializer\Attribute\Ignore; #[Get(uriTemplate: '/gen_id_default', provider: [self::class, 'getData'], normalizationContext: ['hydra_prefix' => false])] class GenIdDefault { public function __construct( public string $id, - #[ApiProperty] public Collection $subresources + #[ApiProperty] public Collection $subresources, ) { } @@ -36,7 +35,7 @@ public static function getData(Operation $operation, array $uriVariables = [], a new ArrayCollection( [ new Subresource('foo'), - new Subresource('bar') + new Subresource('bar'), ] ) ); diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php index e6951387a80..3b89a5a8b6c 100644 --- a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/GenIdTrue.php @@ -18,14 +18,13 @@ use ApiPlatform\Metadata\Operation; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Symfony\Component\Serializer\Attribute\Ignore; #[Get(uriTemplate: '/gen_id_truthy', provider: [self::class, 'getData'], normalizationContext: ['hydra_prefix' => false])] class GenIdTrue { public function __construct( public string $id, - #[ApiProperty(genId: true)] public Collection $subresources + #[ApiProperty(genId: true)] public Collection $subresources, ) { } @@ -36,7 +35,7 @@ public static function getData(Operation $operation, array $uriVariables = [], a new ArrayCollection( [ new Subresource('foo'), - new Subresource('bar') + new Subresource('bar'), ] ) ); diff --git a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php index be789482b96..a7a3e50e513 100644 --- a/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php +++ b/tests/Fixtures/TestBundle/ApiResource/GenIdFalse/Subresource.php @@ -14,12 +14,11 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; class Subresource { public function __construct( - #[ApiProperty] public string $title + #[ApiProperty] public string $title, ) { } } From 123e9495263cb2d09cb3841941ace60b77b25baf Mon Sep 17 00:00:00 2001 From: Olivier Massot Date: Wed, 18 Feb 2026 17:23:58 +0100 Subject: [PATCH 4/4] fix unit tests --- tests/Fixtures/app/AppKernel.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 4374f32ddab..0f0a235c468 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -59,7 +59,7 @@ class AppKernel extends Kernel private $genIdDefault; - public function __construct(string $environment, bool $debug, $genIdDefault = null) + public function __construct(string $environment, bool $debug, ?bool $genIdDefault = null) { parent::__construct($environment, $debug); @@ -283,9 +283,11 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ 'vary' => ['Accept', 'Cookie'], 'public' => true, ], - 'normalization_context' => [ + 'normalization_context' => null !== $this->genIdDefault ? [ + 'skip_null_values' => false, + 'gen_id' => (bool) $this->genIdDefault, + ] : [ 'skip_null_values' => false, - 'gen_id' => null !== $this->genIdDefault ? (bool) $this->genIdDefault : true, ], 'operations' => [ Get::class,