diff --git a/src/Hal/JsonSchema/SchemaFactory.php b/src/Hal/JsonSchema/SchemaFactory.php index b20c855cce0..32433f3da55 100644 --- a/src/Hal/JsonSchema/SchemaFactory.php +++ b/src/Hal/JsonSchema/SchemaFactory.php @@ -24,7 +24,7 @@ * @author Kévin Dunglas * @author Jachim Coudenys */ -final class SchemaFactory implements SchemaFactoryInterface +final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { private const HREF_PROP = [ 'href' => [ @@ -46,7 +46,6 @@ final class SchemaFactory implements SchemaFactoryInterface public function __construct(private readonly SchemaFactoryInterface $schemaFactory) { - $this->addDistinctFormat('jsonhal'); if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -79,8 +78,18 @@ public function buildSchema(string $className, string $format = 'jsonhal', strin $schema['type'] = 'object'; $schema['properties'] = [ '_embedded' => [ - 'type' => 'array', - 'items' => $items, + 'anyOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + 'type' => 'array', + 'items' => $items, + ], + ], + ], + ['type' => 'object'], + ], ], 'totalItems' => [ 'type' => 'integer', @@ -127,10 +136,10 @@ public function buildSchema(string $className, string $format = 'jsonhal', strin return $schema; } - public function addDistinctFormat(string $format): void + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void { - if (method_exists($this->schemaFactory, 'addDistinctFormat')) { - $this->schemaFactory->addDistinctFormat($format); + if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { + $this->schemaFactory->setSchemaFactory($schemaFactory); } } } diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index b04ba616b80..f51af53e3d7 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -15,7 +15,6 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonSchema\Schema; -use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\Operation; @@ -25,7 +24,7 @@ * * @author Kévin Dunglas */ -final class SchemaFactory implements SchemaFactoryInterface +final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { private const BASE_PROP = [ 'readOnly' => true, @@ -60,7 +59,6 @@ final class SchemaFactory implements SchemaFactoryInterface public function __construct(private readonly SchemaFactoryInterface $schemaFactory) { - $this->addDistinctFormat('jsonld'); if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -184,10 +182,10 @@ public function buildSchema(string $className, string $format = 'jsonld', string return $schema; } - public function addDistinctFormat(string $format): void + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void { - if ($this->schemaFactory instanceof BaseSchemaFactory) { - $this->schemaFactory->addDistinctFormat($format); + if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { + $this->schemaFactory->setSchemaFactory($schemaFactory); } } } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index bfbc0777f81..0b40d42036c 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -36,14 +36,13 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceClassInfoTrait; - private array $distinctFormats = []; private ?TypeFactoryInterface $typeFactory = null; private ?SchemaFactoryInterface $schemaFactory = null; // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null) { if ($typeFactory) { $this->typeFactory = $typeFactory; @@ -53,16 +52,6 @@ public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadata $this->resourceClassResolver = $resourceClassResolver; } - /** - * When added to the list, the given format will lead to the creation of a new definition. - * - * @internal - */ - public function addDistinctFormat(string $format): void - { - $this->distinctFormats[$format] = true; - } - /** * {@inheritdoc} */ @@ -267,7 +256,7 @@ private function buildDefinitionName(string $className, string $format = 'json', $prefix .= '.'.$shortName; } - if (isset($this->distinctFormats[$format])) { + if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { // JSON is the default, and so isn't included in the definition name $prefix .= '.'.$format; } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 02a040d95f6..cd8ee370fc4 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -120,6 +120,16 @@ public function load(array $configs, ContainerBuilder $container): void $patchFormats = $this->getFormats($config['patch_formats']); $errorFormats = $this->getFormats($config['error_formats']); $docsFormats = $this->getFormats($config['docs_formats']); + $jsonSchemaFormats = $config['jsonschema_formats']; + + if (!$jsonSchemaFormats) { + foreach (array_keys($formats) as $f) { + // Distinct JSON-based formats must have names that start with 'json' + if (str_starts_with($f, 'json')) { + $jsonSchemaFormats[$f] = true; + } + } + } if (!isset($errorFormats['json'])) { $errorFormats['json'] = ['application/problem+json', 'application/json']; @@ -144,7 +154,7 @@ public function load(array $configs, ContainerBuilder $container): void $docsFormats['jsonopenapi'] = ['application/vnd.openapi+json']; } - $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats); + $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats, $jsonSchemaFormats); $this->registerMetadataConfiguration($container, $config, $loader); $this->registerOAuthConfiguration($container, $config); $this->registerOpenApiConfiguration($container, $config, $loader); @@ -185,7 +195,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerInflectorConfiguration($config); } - private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats): void + private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats, array $jsonSchemaFormats): void { $loader->load('symfony/events.xml'); $loader->load('symfony/controller.xml'); @@ -218,6 +228,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); + $container->setParameter('api_platform.jsonschema_formats', $jsonSchemaFormats); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); $container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 948cf49f156..8cc2cb94876 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -174,6 +174,14 @@ public function getConfigTreeBuilder(): TreeBuilder 'jsonproblem' => ['mime_types' => ['application/problem+json']], 'json' => ['mime_types' => ['application/problem+json', 'application/json']], ]); + $rootNode + ->children() + ->arrayNode('jsonschema_formats') + ->scalarPrototype()->end() + ->defaultValue([]) + ->info('The JSON formats to compute the JSON Schemas for.') + ->end() + ->end(); $this->addDefaultsSection($rootNode); diff --git a/src/Symfony/Bundle/Resources/config/json_schema.xml b/src/Symfony/Bundle/Resources/config/json_schema.xml index 0923e155022..1e58ff0a7e2 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.xml +++ b/src/Symfony/Bundle/Resources/config/json_schema.xml @@ -22,6 +22,7 @@ + %api_platform.jsonschema_formats% diff --git a/tests/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Hal/JsonSchema/SchemaFactoryTest.php index 8d9e39074f6..dd56b49691a 100644 --- a/tests/Hal/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hal/JsonSchema/SchemaFactoryTest.php @@ -53,7 +53,10 @@ protected function setUp(): void null, $resourceMetadataFactory->reveal(), $propertyNameCollectionFactory->reveal(), - $propertyMetadataFactory->reveal() + $propertyMetadataFactory->reveal(), + null, + null, + ['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true], ); $hydraSchemaFactory = new HydraSchemaFactory($baseSchemaFactory); diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index 709dc5d681b..ce9d03e1376 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -54,7 +54,10 @@ protected function setUp(): void null, $resourceMetadataFactoryCollection->reveal(), $propertyNameCollectionFactory->reveal(), - $propertyMetadataFactory->reveal() + $propertyMetadataFactory->reveal(), + null, + null, + ['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true], ); $this->schemaFactory = new SchemaFactory($baseSchemaFactory); diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 33eda6cf678..82e1e055254 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -98,6 +98,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'jsonld' => ['mime_types' => ['application/ld+json']], 'json' => ['mime_types' => ['application/problem+json', 'application/json']], ], + 'jsonschema_formats' => [], 'exception_to_status' => [ ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, diff --git a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php index 86f40e65a38..be1521f78fb 100644 --- a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -33,6 +33,13 @@ class ApiTestCaseTest extends ApiTestCase { use ExpectDeprecationTrait; + public static function providerFormats(): iterable + { + // yield 'jsonapi' => ['jsonapi', 'application/vnd.api+json']; + yield 'jsonhal' => ['jsonhal', 'application/hal+json']; + yield 'jsonld' => ['jsonld', 'application/ld+json']; + } + public function testAssertJsonContains(): void { self::createClient()->request('GET', '/'); @@ -122,13 +129,19 @@ public function testAssertMatchesJsonSchema(): void $this->assertMatchesJsonSchema(json_decode($jsonSchema, true)); } - public function testAssertMatchesResourceCollectionJsonSchema(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceCollectionJsonSchema(string $format, string $mimeType): void { - self::createClient()->request('GET', '/resource_interfaces'); - $this->assertMatchesResourceCollectionJsonSchema(ResourceInterface::class); + self::createClient()->request('GET', '/resource_interfaces', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceCollectionJsonSchema(ResourceInterface::class, format: $format); } - public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationContext(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationContext(string $format, string $mimeType): void { $this->recreateSchema(); @@ -146,20 +159,26 @@ public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationCo $manager->persist($child); $manager->flush(); - self::createClient()->request('GET', "issue-6146-parents/{$parent->getId()}"); - $this->assertMatchesResourceItemJsonSchema(Issue6146Parent::class); + self::createClient()->request('GET', "issue-6146-parents/{$parent->getId()}", ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceItemJsonSchema(Issue6146Parent::class, format: $format); - self::createClient()->request('GET', '/issue-6146-parents'); - $this->assertMatchesResourceCollectionJsonSchema(Issue6146Parent::class); + self::createClient()->request('GET', '/issue-6146-parents', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceCollectionJsonSchema(Issue6146Parent::class, format: $format); } - public function testAssertMatchesResourceItemJsonSchema(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceItemJsonSchema(string $format, string $mimeType): void { - self::createClient()->request('GET', '/resource_interfaces/some-id'); - $this->assertMatchesResourceItemJsonSchema(ResourceInterface::class); + self::createClient()->request('GET', '/resource_interfaces/some-id', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceItemJsonSchema(ResourceInterface::class, format: $format); } - public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(string $format, string $mimeType): void { $this->recreateSchema(); @@ -169,11 +188,14 @@ public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(): void $manager->persist($jsonSchemaContextDummy); $manager->flush(); - self::createClient()->request('GET', '/json_schema_context_dummies/1'); - $this->assertMatchesResourceItemJsonSchema(JsonSchemaContextDummy::class); + self::createClient()->request('GET', '/json_schema_context_dummies/1', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceItemJsonSchema(JsonSchemaContextDummy::class, format: $format); } - public function testAssertMatchesResourceItemJsonSchemaOutput(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceItemJsonSchemaOutput(string $format, string $mimeType): void { $this->recreateSchema(); @@ -184,11 +206,14 @@ public function testAssertMatchesResourceItemJsonSchemaOutput(): void $dummyDtoInputOutput->num = 54; $manager->persist($dummyDtoInputOutput); $manager->flush(); - self::createClient()->request('GET', '/dummy_dto_input_outputs/1'); - $this->assertMatchesResourceItemJsonSchema(DummyDtoInputOutput::class); + self::createClient()->request('GET', '/dummy_dto_input_outputs/1', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceItemJsonSchema(DummyDtoInputOutput::class, format: $format); } - public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithContext(): void + /** + * @dataProvider providerFormats + */ + public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithContext(string $format, string $mimeType): void { $this->recreateSchema(); @@ -201,11 +226,11 @@ public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithCo $manager->persist($user); $manager->flush(); - self::createClient()->request('GET', "/users-with-groups/{$user->getId()}"); - $this->assertMatchesResourceItemJsonSchema(User::class, null, 'jsonld', ['groups' => ['api-test-case-group']]); + self::createClient()->request('GET', "/users-with-groups/{$user->getId()}", ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceItemJsonSchema(User::class, null, $format, ['groups' => ['api-test-case-group']]); - self::createClient()->request('GET', '/users-with-groups'); - $this->assertMatchesResourceCollectionJsonSchema(User::class, null, 'jsonld', ['groups' => ['api-test-case-group']]); + self::createClient()->request('GET', '/users-with-groups', ['headers' => ['Accept' => $mimeType]]); + $this->assertMatchesResourceCollectionJsonSchema(User::class, null, $format, ['groups' => ['api-test-case-group']]); } public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithRangeAssertions(): void