From 4052ce6332616ed05e48fb114f1207efcd3ba000 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 7 Oct 2025 12:24:50 +0200 Subject: [PATCH] fix(hydra): add base schema to item of a collection --- src/Hydra/JsonSchema/SchemaFactory.php | 98 ++++++++++--------- .../TestBundle/ApiResource/Issue7426/Boat.php | 31 ++++++ .../JsonSchema/JsonLdJsonSchemaTest.php | 13 ++- 3 files changed, 93 insertions(+), 49 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7426/Boat.php diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index 6bf4597cf7..cd7eed0dcd 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -117,69 +117,35 @@ public function buildSchema(string $className, string $format = 'jsonld', string $format = 'json'; } - if ('jsonld' !== $format) { + if ('jsonld' !== $format || !$this->isResourceClass($className)) { return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); } - if (!$this->isResourceClass($className)) { - $operation = null; - $inputOrOutputClass = null; - $serializerContext ??= []; - } else { - $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); - $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); - $serializerContext ??= $this->getSerializerContext($operation, $type); - } + + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); if (null === $inputOrOutputClass) { // input or output disabled return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); } - $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); - - // JSON-LD is slightly different then JSON:API or HAL - // All the references that are resources must also be in JSON-LD therefore combining - // the HydraItemBaseSchema and the JSON schema is harder (unless we loop again through all relationship) - // The less intensive path is to compute the jsonld schemas, then to combine in an allOf $schema = $this->schemaFactory->buildSchema($className, 'jsonld', $type, $operation, $schema, $serializerContext, $forceCollection); $definitions = $schema->getDefinitions(); - $prefix = $this->getSchemaUriPrefix($schema->getVersion()); $collectionKey = $schema->getItemsDefinitionKey(); - $key = $schema->getRootDefinitionKey() ?? $collectionKey; if (!$collectionKey) { - if ($this->transformed[$definitionName] ?? false) { - return $schema; - } - - $hasNoId = Schema::TYPE_OUTPUT === $type && false === ($serializerContext['gen_id'] ?? true); - $baseName = self::ITEM_BASE_SCHEMA_NAME; - - if ($hasNoId) { - $baseName = self::ITEM_WITHOUT_ID_BASE_SCHEMA_NAME; + $definitionName = $schema->getRootDefinitionKey() ?? $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); + $this->decorateItemDefinition($definitionName, $definitions, $prefix, $type, $serializerContext); + + if (isset($definitions[$definitionName])) { + $currentDefinitions = $schema->getDefinitions(); + $schema->exchangeArray([]); // Clear the schema + $schema['$ref'] = $prefix.$definitionName; + $schema->setDefinitions($currentDefinitions); } - if (!isset($definitions[$baseName])) { - $definitions[$baseName] = $hasNoId ? self::ITEM_BASE_SCHEMA_WITHOUT_ID : self::ITEM_BASE_SCHEMA_WITH_ID; - } - - $allOf = new \ArrayObject(['allOf' => [ - ['$ref' => $prefix.$baseName], - $definitions[$key], - ]]); - - if (isset($definitions[$key]['description'])) { - $allOf['description'] = $definitions[$key]['description']; - } - - $definitions[$definitionName] = $allOf; - unset($definitions[$definitionName]['allOf'][1]['description']); - - $schema['$ref'] = $prefix.$definitionName; - - $this->transformed[$definitionName] = true; - return $schema; } @@ -206,11 +172,11 @@ public function buildSchema(string $className, string $format = 'jsonld', string 'type' => 'object', 'required' => [ $hydraPrefix.'member', - 'items' => ['type' => 'object'], ], 'properties' => [ $hydraPrefix.'member' => [ 'type' => 'array', + 'items' => ['type' => 'object'], ], $hydraPrefix.'totalItems' => [ 'type' => 'integer', @@ -276,6 +242,7 @@ public function buildSchema(string $className, string $format = 'jsonld', string ]; } + $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); $schema['type'] = 'object'; $schema['description'] = "$definitionName collection."; $schema['allOf'] = [ @@ -293,6 +260,10 @@ public function buildSchema(string $className, string $format = 'jsonld', string unset($schema['items']); + if (isset($definitions[$collectionKey])) { + $this->decorateItemDefinition($collectionKey, $definitions, $prefix, $type, $serializerContext); + } + return $schema; } @@ -302,4 +273,35 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void $this->schemaFactory->setSchemaFactory($schemaFactory); } } + + private function decorateItemDefinition(string $definitionName, \ArrayObject $definitions, string $prefix, string $type, ?array $serializerContext): void + { + if (!isset($definitions[$definitionName]) || ($this->transformed[$definitionName] ?? false)) { + return; + } + + $hasNoId = Schema::TYPE_OUTPUT === $type && false === ($serializerContext['gen_id'] ?? true); + $baseName = self::ITEM_BASE_SCHEMA_NAME; + if ($hasNoId) { + $baseName = self::ITEM_WITHOUT_ID_BASE_SCHEMA_NAME; + } + + if (!isset($definitions[$baseName])) { + $definitions[$baseName] = $hasNoId ? self::ITEM_BASE_SCHEMA_WITHOUT_ID : self::ITEM_BASE_SCHEMA_WITH_ID; + } + + $allOf = new \ArrayObject(['allOf' => [ + ['$ref' => $prefix.$baseName], + $definitions[$definitionName], + ]]); + + if (isset($definitions[$definitionName]['description'])) { + $allOf['description'] = $definitions[$definitionName]['description']; + } + + $definitions[$definitionName] = $allOf; + unset($definitions[$definitionName]['allOf'][1]['description']); + + $this->transformed[$definitionName] = true; + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7426/Boat.php b/tests/Fixtures/TestBundle/ApiResource/Issue7426/Boat.php new file mode 100644 index 0000000000..97e2e00847 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7426/Boat.php @@ -0,0 +1,31 @@ + + * + * 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\Issue7426; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Attribute\Groups; + +#[GetCollection( + normalizationContext: ['groups' => ['boat:read']], +)] +class Boat +{ + #[ApiProperty(identifier: true)] + #[Groups(['boat:read'])] + public int $id; + + #[Groups(['boat:read'])] + public string $name; +} diff --git a/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php index 3590aa3ef6..b1380c4ec7 100644 --- a/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Tests\Functional\JsonSchema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7426\Boat; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6212\Nest; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; @@ -27,6 +29,7 @@ final class JsonLdJsonSchemaTest extends ApiTestCase use SetupClassResourcesTrait; protected SchemaFactoryInterface $schemaFactory; + protected OperationMetadataFactoryInterface $operationMetadataFactory; protected static ?bool $alwaysBootKernel = false; @@ -35,13 +38,14 @@ final class JsonLdJsonSchemaTest extends ApiTestCase */ public static function getResources(): array { - return [RelatedDummy::class, ThirdLevel::class, RelatedToDummyFriend::class]; + return [RelatedDummy::class, ThirdLevel::class, RelatedToDummyFriend::class, Boat::class]; } protected function setUp(): void { parent::setUp(); $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + $this->operationMetadataFactory = self::getContainer()->get('api_platform.metadata.operation.metadata_factory'); } public function testSubSchemaJsonLd(): void @@ -169,4 +173,11 @@ public function testArraySchemaWithMultipleUnionTypes(): void $this->assertArrayHasKey('Nest.jsonld', $schema['definitions']); } + + public function testSchemaWithoutGetOperation(): void + { + $schema = $this->schemaFactory->buildSchema(Boat::class, 'jsonld', 'output', $this->operationMetadataFactory->create('_api_/boats{._format}_get_collection')); + + $this->assertEquals(['$ref' => '#/definitions/HydraItemBaseSchema'], $schema->getDefinitions()['Boat.jsonld-boat.read']['allOf'][0]); + } }