diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78d1711a7b..4f667008db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1340,7 +1340,8 @@ jobs: tests/Fixtures/app/console api:openapi:export --yaml -o build/out/openapi/openapi_v3.yaml - name: Validate OpenAPI documents run: | - npx @quobix/vacuum lint -r tests/Fixtures/app/ruleset.yaml build/out/openapi/openapi_v3.yaml -d --ignore-array-circle-ref --ignore-polymorph-circle-ref -b --no-clip + npm i -g @quobix/vacuum + vacuum lint -r tests/Fixtures/app/ruleset.yaml build/out/openapi/openapi_v3.yaml -d --ignore-array-circle-ref --ignore-polymorph-circle-ref -b --no-clip laravel: name: Laravel (PHP ${{ matrix.php }}) diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index 17474ca5bd..6bf4597cf7 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -37,7 +37,9 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI use SchemaUriPrefixTrait; private const ITEM_BASE_SCHEMA_NAME = 'HydraItemBaseSchema'; + private const ITEM_WITHOUT_ID_BASE_SCHEMA_NAME = 'HydraItemBaseSchemaWithoutId'; private const COLLECTION_BASE_SCHEMA_NAME = 'HydraCollectionBaseSchema'; + private const BASE_PROP = [ 'type' => 'string', ]; @@ -68,9 +70,16 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ], ], ] + self::BASE_PROPS, + ]; + + private const ITEM_BASE_SCHEMA_WITH_ID = self::ITEM_BASE_SCHEMA + [ 'required' => ['@id', '@type'], ]; + private const ITEM_BASE_SCHEMA_WITHOUT_ID = self::ITEM_BASE_SCHEMA + [ + 'required' => ['@type'], + ]; + /** * @var array */ @@ -144,10 +153,15 @@ public function buildSchema(string $className, string $format = 'jsonld', string 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; + } + if (!isset($definitions[$baseName])) { - $definitions[$baseName] = self::ITEM_BASE_SCHEMA; + $definitions[$baseName] = $hasNoId ? self::ITEM_BASE_SCHEMA_WITHOUT_ID : self::ITEM_BASE_SCHEMA_WITH_ID; } $allOf = new \ArrayObject(['allOf' => [ diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 29838f2b3f..fe56b63e54 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -63,6 +63,10 @@ public function create(string $className, string $format = 'json', ?string $inpu $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; } + if (false === ($serializerContext['gen_id'] ?? true)) { + $name .= '_noid'; + } + return $this->encodeDefinitionName($name); } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 99bab8fd97..bfbf57b4d1 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -245,23 +245,20 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam } $subSchemaFactory = $this->schemaFactory ?: $this; - $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $subSchema = $subSchemaFactory->buildSchema( + $className, + $format, + $parentType, + null, + $subSchema, + $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true], + false, + ); + if (!isset($subSchema['$ref'])) { continue; } - if (false === $propertyMetadata->getGenId()) { - $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); - - if (isset($subSchema->getDefinitions()[$subDefinitionName])) { - // @see https://github.com/api-platform/core/issues/7162 - // Need to rebuild the definition without @id property and set it back to the sub-schema - $subSchemaDefinition = $subSchema->getDefinitions()[$subDefinitionName]->getArrayCopy(); - unset($subSchemaDefinition['properties']['@id']); - $subSchema->getDefinitions()[$subDefinitionName] = new \ArrayObject($subSchemaDefinition); - } - } - if ($isCollection) { $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; $propertySchema[$key]['$ref'] = $subSchema['$ref']; @@ -371,18 +368,19 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $subSchemaInstance = new Schema($version); $subSchemaInstance->setDefinitions($schema->getDefinitions()); $subSchemaFactory = $this->schemaFactory ?: $this; - $subSchemaResult = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchemaInstance, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + $subSchemaResult = $subSchemaFactory->buildSchema( + $className, + $format, + $parentType, + null, + $subSchemaInstance, + $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true], + false, + ); if (!isset($subSchemaResult['$ref'])) { continue; } - if (false === $propertyMetadata->getGenId()) { - $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); - if (isset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id'])) { - unset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id']); - } - } - if ($isCollection) { $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; if (!isset($propertySchema['type'])) { diff --git a/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php b/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php index 00f69b990c..d831f3b8b8 100644 --- a/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php +++ b/tests/Fixtures/TestBundle/ApiResource/AggregateRating.php @@ -15,13 +15,16 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource(types: 'https://schema.org/AggregateRating', operations: [])] final class AggregateRating { public function __construct( + #[Groups(['with_aggregate_rating'])] #[ApiProperty(iris: ['https://schema.org/ratingValue'])] public float $ratingValue, + #[Groups(['with_aggregate_rating'])] #[ApiProperty(iris: ['https://schema.org/reviewCount'])] public int $reviewCount, ) { diff --git a/tests/Fixtures/TestBundle/ApiResource/Product.php b/tests/Fixtures/TestBundle/ApiResource/Product.php index de231d0ea5..c5f7dba9ab 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Product.php +++ b/tests/Fixtures/TestBundle/ApiResource/Product.php @@ -15,6 +15,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Attribute\Groups; #[Get( types: ['https://schema.org/Product'], @@ -23,12 +25,19 @@ provider: [self::class, 'provide'], jsonStream: true )] +#[GetCollection( + types: ['https://schema.org/Product'], + uriTemplate: '/json-stream-products', + provider: [self::class, 'provide'], + normalizationContext: ['groups' => ['with_aggregate_rating'], 'hydra_prefix' => false] +)] class Product { #[ApiProperty(identifier: true)] public string $code; #[ApiProperty(genId: false, iris: ['https://schema.org/aggregateRating'])] + #[Groups(['with_aggregate_rating'])] public AggregateRating $aggregateRating; #[ApiProperty(property: 'name', iris: ['https://schema.org/name'])] diff --git a/tests/Fixtures/app/ruleset.yaml b/tests/Fixtures/app/ruleset.yaml index d0daaa288b..80f1c65a47 100644 --- a/tests/Fixtures/app/ruleset.yaml +++ b/tests/Fixtures/app/ruleset.yaml @@ -1,5 +1,6 @@ extends: [[spectral:oas, recommended]] rules: + camel-case-properties: false circular-references: false operation-success-response: false oas3-parameter-description: false diff --git a/tests/Functional/JsonSchema/JsonSchemaTest.php b/tests/Functional/JsonSchema/JsonSchemaTest.php index 63b57c9b3e..00887fd563 100644 --- a/tests/Functional/JsonSchema/JsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonSchemaTest.php @@ -17,9 +17,12 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Symfony\Bundle\Test\Constraint\MatchesJsonSchema; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AggregateRating; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\Related; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Product; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithEnumProperty; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\TestEntity; @@ -61,6 +64,8 @@ public static function getResources(): array Book::class, JsonSchemaResource::class, JsonSchemaResourceRelated::class, + Product::class, + AggregateRating::class, ]; } @@ -212,4 +217,10 @@ public function testReadOnlySchema(): void $schema = $this->schemaFactory->buildSchema(JsonSchemaResource::class, 'json', Schema::TYPE_OUTPUT); $this->assertTrue($schema['definitions']['Resource']['properties']['resourceRelated']['readOnly'] ?? false); } + + public function testGenIdFalse() + { + $schema = $this->schemaFactory->buildSchema(Product::class, 'jsonld', Schema::TYPE_OUTPUT, $this->operationMetadataFactory->create('_api_/json-stream-products_get_collection')); + $this->assertThat(['member' => [['aggregateRating' => ['ratingValue' => '1.0', 'reviewCount' => 1]]]], new MatchesJsonSchema($schema)); + } } diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 5f6dc9c90e..d369de6179 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -174,13 +174,14 @@ public function testBackedEnumExamplesAreNotLost(): void /** * Test feature #6716. + * Note: in this test the embed object is not a resource, the behavior is different from where the embeded is an ApiResource. */ public function testGenId(): void { $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\Entity\DisableIdGeneration', '--type' => 'output', '--format' => 'jsonld']); $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertArrayNotHasKey('@id', $json['definitions']['DisableIdGenerationItem.jsonld']['properties']); + $this->assertArrayNotHasKey('@id', $json['definitions']['DisableIdGenerationItem.jsonld_noid']['properties']); } #[DataProvider('arrayPropertyTypeSyntaxProvider')]