Skip to content

Commit 4b70b74

Browse files
fix(jsonschema): generation of non-LD+JSON distinct schema formats (#6236)
* fix(jsonschema): generation of non-LD+JSON distinct schema formats * fix(jsonschema): correct _embedded property schema output generation * fix (jsonschema): Chain schema factory decorators * fix(jsonschema): embedded json schema --------- Co-authored-by: soyuka <[email protected]>
1 parent 874e4d6 commit 4b70b74

File tree

10 files changed

+100
-52
lines changed

10 files changed

+100
-52
lines changed

src/Hal/JsonSchema/SchemaFactory.php

+16-7
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* @author Kévin Dunglas <[email protected]>
2525
* @author Jachim Coudenys <[email protected]>
2626
*/
27-
final class SchemaFactory implements SchemaFactoryInterface
27+
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
2828
{
2929
private const HREF_PROP = [
3030
'href' => [
@@ -46,7 +46,6 @@ final class SchemaFactory implements SchemaFactoryInterface
4646

4747
public function __construct(private readonly SchemaFactoryInterface $schemaFactory)
4848
{
49-
$this->addDistinctFormat('jsonhal');
5049
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
5150
$this->schemaFactory->setSchemaFactory($this);
5251
}
@@ -79,8 +78,18 @@ public function buildSchema(string $className, string $format = 'jsonhal', strin
7978
$schema['type'] = 'object';
8079
$schema['properties'] = [
8180
'_embedded' => [
82-
'type' => 'array',
83-
'items' => $items,
81+
'anyOf' => [
82+
[
83+
'type' => 'object',
84+
'properties' => [
85+
'item' => [
86+
'type' => 'array',
87+
'items' => $items,
88+
],
89+
],
90+
],
91+
['type' => 'object'],
92+
],
8493
],
8594
'totalItems' => [
8695
'type' => 'integer',
@@ -127,10 +136,10 @@ public function buildSchema(string $className, string $format = 'jsonhal', strin
127136
return $schema;
128137
}
129138

130-
public function addDistinctFormat(string $format): void
139+
public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
131140
{
132-
if (method_exists($this->schemaFactory, 'addDistinctFormat')) {
133-
$this->schemaFactory->addDistinctFormat($format);
141+
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
142+
$this->schemaFactory->setSchemaFactory($schemaFactory);
134143
}
135144
}
136145
}

src/Hydra/JsonSchema/SchemaFactory.php

+4-6
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
use ApiPlatform\JsonLd\ContextBuilder;
1717
use ApiPlatform\JsonSchema\Schema;
18-
use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory;
1918
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
2019
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
2120
use ApiPlatform\Metadata\Operation;
@@ -25,7 +24,7 @@
2524
*
2625
* @author Kévin Dunglas <[email protected]>
2726
*/
28-
final class SchemaFactory implements SchemaFactoryInterface
27+
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
2928
{
3029
private const BASE_PROP = [
3130
'readOnly' => true,
@@ -60,7 +59,6 @@ final class SchemaFactory implements SchemaFactoryInterface
6059

6160
public function __construct(private readonly SchemaFactoryInterface $schemaFactory)
6261
{
63-
$this->addDistinctFormat('jsonld');
6462
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
6563
$this->schemaFactory->setSchemaFactory($this);
6664
}
@@ -184,10 +182,10 @@ public function buildSchema(string $className, string $format = 'jsonld', string
184182
return $schema;
185183
}
186184

187-
public function addDistinctFormat(string $format): void
185+
public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
188186
{
189-
if ($this->schemaFactory instanceof BaseSchemaFactory) {
190-
$this->schemaFactory->addDistinctFormat($format);
187+
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
188+
$this->schemaFactory->setSchemaFactory($schemaFactory);
191189
}
192190
}
193191
}

src/JsonSchema/SchemaFactory.php

+2-13
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,13 @@
3636
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
3737
{
3838
use ResourceClassInfoTrait;
39-
private array $distinctFormats = [];
4039
private ?TypeFactoryInterface $typeFactory = null;
4140
private ?SchemaFactoryInterface $schemaFactory = null;
4241
// Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
4342
public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
4443
public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
4544

46-
public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null)
45+
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)
4746
{
4847
if ($typeFactory) {
4948
$this->typeFactory = $typeFactory;
@@ -53,16 +52,6 @@ public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadata
5352
$this->resourceClassResolver = $resourceClassResolver;
5453
}
5554

56-
/**
57-
* When added to the list, the given format will lead to the creation of a new definition.
58-
*
59-
* @internal
60-
*/
61-
public function addDistinctFormat(string $format): void
62-
{
63-
$this->distinctFormats[$format] = true;
64-
}
65-
6655
/**
6756
* {@inheritdoc}
6857
*/
@@ -267,7 +256,7 @@ private function buildDefinitionName(string $className, string $format = 'json',
267256
$prefix .= '.'.$shortName;
268257
}
269258

270-
if (isset($this->distinctFormats[$format])) {
259+
if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) {
271260
// JSON is the default, and so isn't included in the definition name
272261
$prefix .= '.'.$format;
273262
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

+13-2
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ public function load(array $configs, ContainerBuilder $container): void
120120
$patchFormats = $this->getFormats($config['patch_formats']);
121121
$errorFormats = $this->getFormats($config['error_formats']);
122122
$docsFormats = $this->getFormats($config['docs_formats']);
123+
$jsonSchemaFormats = $config['jsonschema_formats'];
124+
125+
if (!$jsonSchemaFormats) {
126+
foreach (array_keys($formats) as $f) {
127+
// Distinct JSON-based formats must have names that start with 'json'
128+
if (str_starts_with($f, 'json')) {
129+
$jsonSchemaFormats[$f] = true;
130+
}
131+
}
132+
}
123133

124134
if (!isset($errorFormats['json'])) {
125135
$errorFormats['json'] = ['application/problem+json', 'application/json'];
@@ -144,7 +154,7 @@ public function load(array $configs, ContainerBuilder $container): void
144154
$docsFormats['jsonopenapi'] = ['application/vnd.openapi+json'];
145155
}
146156

147-
$this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats);
157+
$this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats, $jsonSchemaFormats);
148158
$this->registerMetadataConfiguration($container, $config, $loader);
149159
$this->registerOAuthConfiguration($container, $config);
150160
$this->registerOpenApiConfiguration($container, $config, $loader);
@@ -185,7 +195,7 @@ public function load(array $configs, ContainerBuilder $container): void
185195
$this->registerInflectorConfiguration($config);
186196
}
187197

188-
private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats): void
198+
private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats, array $jsonSchemaFormats): void
189199
{
190200
$loader->load('symfony/events.xml');
191201
$loader->load('symfony/controller.xml');
@@ -218,6 +228,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array
218228
$container->setParameter('api_platform.patch_formats', $patchFormats);
219229
$container->setParameter('api_platform.error_formats', $errorFormats);
220230
$container->setParameter('api_platform.docs_formats', $docsFormats);
231+
$container->setParameter('api_platform.jsonschema_formats', $jsonSchemaFormats);
221232
$container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading']));
222233
$container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']);
223234
$container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']);

src/Symfony/Bundle/DependencyInjection/Configuration.php

+8
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ public function getConfigTreeBuilder(): TreeBuilder
174174
'jsonproblem' => ['mime_types' => ['application/problem+json']],
175175
'json' => ['mime_types' => ['application/problem+json', 'application/json']],
176176
]);
177+
$rootNode
178+
->children()
179+
->arrayNode('jsonschema_formats')
180+
->scalarPrototype()->end()
181+
->defaultValue([])
182+
->info('The JSON formats to compute the JSON Schemas for.')
183+
->end()
184+
->end();
177185

178186
$this->addDefaultsSection($rootNode);
179187

src/Symfony/Bundle/Resources/config/json_schema.xml

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
2323
<argument type="service" id="api_platform.name_converter" on-invalid="ignore" />
2424
<argument type="service" id="api_platform.resource_class_resolver" />
25+
<argument on-invalid="ignore">%api_platform.jsonschema_formats%</argument>
2526
</service>
2627
<service id="ApiPlatform\JsonSchema\SchemaFactoryInterface" alias="api_platform.json_schema.schema_factory" />
2728

tests/Hal/JsonSchema/SchemaFactoryTest.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ protected function setUp(): void
5353
null,
5454
$resourceMetadataFactory->reveal(),
5555
$propertyNameCollectionFactory->reveal(),
56-
$propertyMetadataFactory->reveal()
56+
$propertyMetadataFactory->reveal(),
57+
null,
58+
null,
59+
['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true],
5760
);
5861

5962
$hydraSchemaFactory = new HydraSchemaFactory($baseSchemaFactory);

tests/Hydra/JsonSchema/SchemaFactoryTest.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ protected function setUp(): void
5454
null,
5555
$resourceMetadataFactoryCollection->reveal(),
5656
$propertyNameCollectionFactory->reveal(),
57-
$propertyMetadataFactory->reveal()
57+
$propertyMetadataFactory->reveal(),
58+
null,
59+
null,
60+
['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true],
5861
);
5962

6063
$this->schemaFactory = new SchemaFactory($baseSchemaFactory);

tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php

+1
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm
9898
'jsonld' => ['mime_types' => ['application/ld+json']],
9999
'json' => ['mime_types' => ['application/problem+json', 'application/json']],
100100
],
101+
'jsonschema_formats' => [],
101102
'exception_to_status' => [
102103
ExceptionInterface::class => Response::HTTP_BAD_REQUEST,
103104
InvalidArgumentException::class => Response::HTTP_BAD_REQUEST,

tests/Symfony/Bundle/Test/ApiTestCaseTest.php

+47-22
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ class ApiTestCaseTest extends ApiTestCase
3333
{
3434
use ExpectDeprecationTrait;
3535

36+
public static function providerFormats(): iterable
37+
{
38+
// yield 'jsonapi' => ['jsonapi', 'application/vnd.api+json'];
39+
yield 'jsonhal' => ['jsonhal', 'application/hal+json'];
40+
yield 'jsonld' => ['jsonld', 'application/ld+json'];
41+
}
42+
3643
public function testAssertJsonContains(): void
3744
{
3845
self::createClient()->request('GET', '/');
@@ -122,13 +129,19 @@ public function testAssertMatchesJsonSchema(): void
122129
$this->assertMatchesJsonSchema(json_decode($jsonSchema, true));
123130
}
124131

125-
public function testAssertMatchesResourceCollectionJsonSchema(): void
132+
/**
133+
* @dataProvider providerFormats
134+
*/
135+
public function testAssertMatchesResourceCollectionJsonSchema(string $format, string $mimeType): void
126136
{
127-
self::createClient()->request('GET', '/resource_interfaces');
128-
$this->assertMatchesResourceCollectionJsonSchema(ResourceInterface::class);
137+
self::createClient()->request('GET', '/resource_interfaces', ['headers' => ['Accept' => $mimeType]]);
138+
$this->assertMatchesResourceCollectionJsonSchema(ResourceInterface::class, format: $format);
129139
}
130140

131-
public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationContext(): void
141+
/**
142+
* @dataProvider providerFormats
143+
*/
144+
public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationContext(string $format, string $mimeType): void
132145
{
133146
$this->recreateSchema();
134147

@@ -146,20 +159,26 @@ public function testAssertMatchesResourceCollectionJsonSchemaKeepSerializationCo
146159
$manager->persist($child);
147160
$manager->flush();
148161

149-
self::createClient()->request('GET', "issue-6146-parents/{$parent->getId()}");
150-
$this->assertMatchesResourceItemJsonSchema(Issue6146Parent::class);
162+
self::createClient()->request('GET', "issue-6146-parents/{$parent->getId()}", ['headers' => ['Accept' => $mimeType]]);
163+
$this->assertMatchesResourceItemJsonSchema(Issue6146Parent::class, format: $format);
151164

152-
self::createClient()->request('GET', '/issue-6146-parents');
153-
$this->assertMatchesResourceCollectionJsonSchema(Issue6146Parent::class);
165+
self::createClient()->request('GET', '/issue-6146-parents', ['headers' => ['Accept' => $mimeType]]);
166+
$this->assertMatchesResourceCollectionJsonSchema(Issue6146Parent::class, format: $format);
154167
}
155168

156-
public function testAssertMatchesResourceItemJsonSchema(): void
169+
/**
170+
* @dataProvider providerFormats
171+
*/
172+
public function testAssertMatchesResourceItemJsonSchema(string $format, string $mimeType): void
157173
{
158-
self::createClient()->request('GET', '/resource_interfaces/some-id');
159-
$this->assertMatchesResourceItemJsonSchema(ResourceInterface::class);
174+
self::createClient()->request('GET', '/resource_interfaces/some-id', ['headers' => ['Accept' => $mimeType]]);
175+
$this->assertMatchesResourceItemJsonSchema(ResourceInterface::class, format: $format);
160176
}
161177

162-
public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(): void
178+
/**
179+
* @dataProvider providerFormats
180+
*/
181+
public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(string $format, string $mimeType): void
163182
{
164183
$this->recreateSchema();
165184

@@ -169,11 +188,14 @@ public function testAssertMatchesResourceItemJsonSchemaWithCustomJson(): void
169188
$manager->persist($jsonSchemaContextDummy);
170189
$manager->flush();
171190

172-
self::createClient()->request('GET', '/json_schema_context_dummies/1');
173-
$this->assertMatchesResourceItemJsonSchema(JsonSchemaContextDummy::class);
191+
self::createClient()->request('GET', '/json_schema_context_dummies/1', ['headers' => ['Accept' => $mimeType]]);
192+
$this->assertMatchesResourceItemJsonSchema(JsonSchemaContextDummy::class, format: $format);
174193
}
175194

176-
public function testAssertMatchesResourceItemJsonSchemaOutput(): void
195+
/**
196+
* @dataProvider providerFormats
197+
*/
198+
public function testAssertMatchesResourceItemJsonSchemaOutput(string $format, string $mimeType): void
177199
{
178200
$this->recreateSchema();
179201

@@ -184,11 +206,14 @@ public function testAssertMatchesResourceItemJsonSchemaOutput(): void
184206
$dummyDtoInputOutput->num = 54;
185207
$manager->persist($dummyDtoInputOutput);
186208
$manager->flush();
187-
self::createClient()->request('GET', '/dummy_dto_input_outputs/1');
188-
$this->assertMatchesResourceItemJsonSchema(DummyDtoInputOutput::class);
209+
self::createClient()->request('GET', '/dummy_dto_input_outputs/1', ['headers' => ['Accept' => $mimeType]]);
210+
$this->assertMatchesResourceItemJsonSchema(DummyDtoInputOutput::class, format: $format);
189211
}
190212

191-
public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithContext(): void
213+
/**
214+
* @dataProvider providerFormats
215+
*/
216+
public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithContext(string $format, string $mimeType): void
192217
{
193218
$this->recreateSchema();
194219

@@ -201,11 +226,11 @@ public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithCo
201226
$manager->persist($user);
202227
$manager->flush();
203228

204-
self::createClient()->request('GET', "/users-with-groups/{$user->getId()}");
205-
$this->assertMatchesResourceItemJsonSchema(User::class, null, 'jsonld', ['groups' => ['api-test-case-group']]);
229+
self::createClient()->request('GET', "/users-with-groups/{$user->getId()}", ['headers' => ['Accept' => $mimeType]]);
230+
$this->assertMatchesResourceItemJsonSchema(User::class, null, $format, ['groups' => ['api-test-case-group']]);
206231

207-
self::createClient()->request('GET', '/users-with-groups');
208-
$this->assertMatchesResourceCollectionJsonSchema(User::class, null, 'jsonld', ['groups' => ['api-test-case-group']]);
232+
self::createClient()->request('GET', '/users-with-groups', ['headers' => ['Accept' => $mimeType]]);
233+
$this->assertMatchesResourceCollectionJsonSchema(User::class, null, $format, ['groups' => ['api-test-case-group']]);
209234
}
210235

211236
public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithRangeAssertions(): void

0 commit comments

Comments
 (0)