Skip to content

Commit e427bba

Browse files
feat(jsonschema): JSON:API schema factory (#6250)
1 parent f3eb62a commit e427bba

File tree

4 files changed

+455
-1
lines changed

4 files changed

+455
-1
lines changed
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\JsonApi\JsonSchema;
15+
16+
use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface;
17+
use ApiPlatform\JsonSchema\Schema;
18+
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
19+
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
20+
use ApiPlatform\Metadata\Operation;
21+
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22+
use ApiPlatform\Metadata\ResourceClassResolverInterface;
23+
24+
/**
25+
* Decorator factory which adds JSON:API properties to the JSON Schema document.
26+
*
27+
* @author Gwendolen Lynch <[email protected]>
28+
*/
29+
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
30+
{
31+
private const LINKS_PROPS = [
32+
'type' => 'object',
33+
'properties' => [
34+
'self' => [
35+
'type' => 'string',
36+
'format' => 'iri-reference',
37+
],
38+
'first' => [
39+
'type' => 'string',
40+
'format' => 'iri-reference',
41+
],
42+
'prev' => [
43+
'type' => 'string',
44+
'format' => 'iri-reference',
45+
],
46+
'next' => [
47+
'type' => 'string',
48+
'format' => 'iri-reference',
49+
],
50+
'last' => [
51+
'type' => 'string',
52+
'format' => 'iri-reference',
53+
],
54+
],
55+
'example' => [
56+
'self' => 'string',
57+
'first' => 'string',
58+
'prev' => 'string',
59+
'next' => 'string',
60+
'last' => 'string',
61+
],
62+
];
63+
private const META_PROPS = [
64+
'type' => 'object',
65+
'properties' => [
66+
'totalItems' => [
67+
'type' => 'integer',
68+
'minimum' => 0,
69+
],
70+
'itemsPerPage' => [
71+
'type' => 'integer',
72+
'minimum' => 0,
73+
],
74+
'currentPage' => [
75+
'type' => 'integer',
76+
'minimum' => 0,
77+
],
78+
],
79+
];
80+
private const RELATION_PROPS = [
81+
'type' => 'object',
82+
'properties' => [
83+
'type' => [
84+
'type' => 'string',
85+
],
86+
'id' => [
87+
'type' => 'string',
88+
'format' => 'iri-reference',
89+
],
90+
],
91+
];
92+
private const PROPERTY_PROPS = [
93+
'id' => [
94+
'type' => 'string',
95+
],
96+
'type' => [
97+
'type' => 'string',
98+
],
99+
'attributes' => [
100+
'type' => 'object',
101+
'properties' => [],
102+
],
103+
];
104+
105+
public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver)
106+
{
107+
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
108+
$this->schemaFactory->setSchemaFactory($this);
109+
}
110+
}
111+
112+
/**
113+
* {@inheritdoc}
114+
*/
115+
public function buildSchema(string $className, string $format = 'jsonapi', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
116+
{
117+
$schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
118+
if ('jsonapi' !== $format) {
119+
return $schema;
120+
}
121+
122+
if ('input' === $type) {
123+
return $schema;
124+
}
125+
126+
if ($key = $schema->getRootDefinitionKey()) {
127+
$definitions = $schema->getDefinitions();
128+
$properties = $definitions[$key]['properties'] ?? [];
129+
130+
// Prevent reapplying
131+
if (isset($properties['id'], $properties['type']) || isset($properties['data'])) {
132+
return $schema;
133+
}
134+
135+
$definitions[$key]['properties'] = [
136+
'data' => [
137+
'type' => 'object',
138+
'properties' => $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext),
139+
'required' => ['type', 'id'],
140+
],
141+
];
142+
143+
return $schema;
144+
}
145+
146+
if ($key = $schema->getItemsDefinitionKey()) {
147+
$definitions = $schema->getDefinitions();
148+
$properties = $definitions[$key]['properties'] ?? [];
149+
150+
// Prevent reapplying
151+
if (isset($properties['id'], $properties['type']) || isset($properties['data'])) {
152+
return $schema;
153+
}
154+
155+
$definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $schema, $serializerContext);
156+
$definitions[$key]['required'] = ['type', 'id'];
157+
}
158+
159+
if (($schema['type'] ?? '') === 'array') {
160+
// data
161+
$items = $schema['items'];
162+
unset($schema['items']);
163+
164+
$schema['type'] = 'object';
165+
$schema['properties'] = [
166+
'links' => self::LINKS_PROPS,
167+
'meta' => self::META_PROPS,
168+
'data' => [
169+
'type' => 'array',
170+
'items' => $items,
171+
],
172+
];
173+
$schema['required'] = [
174+
'data',
175+
];
176+
177+
return $schema;
178+
}
179+
180+
return $schema;
181+
}
182+
183+
public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
184+
{
185+
if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
186+
$this->schemaFactory->setSchemaFactory($schemaFactory);
187+
}
188+
}
189+
190+
private function buildDefinitionPropertiesSchema(string $key, string $className, Schema $schema, ?array $serializerContext): array
191+
{
192+
$definitions = $schema->getDefinitions();
193+
$properties = $definitions[$key]['properties'] ?? [];
194+
195+
$attributes = [];
196+
$relationships = [];
197+
foreach ($properties as $propertyName => $property) {
198+
if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
199+
[$isOne, $isMany] = $relation;
200+
201+
if ($isOne) {
202+
$relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
203+
continue;
204+
}
205+
$relationships[$propertyName]['properties']['data'] = [
206+
'type' => 'array',
207+
'items' => self::RELATION_PROPS,
208+
];
209+
continue;
210+
}
211+
if ('id' === $propertyName) {
212+
$attributes['_id'] = $property;
213+
continue;
214+
}
215+
$attributes[$propertyName] = $property;
216+
}
217+
218+
$replacement = self::PROPERTY_PROPS;
219+
$replacement['attributes']['properties'] = $attributes;
220+
221+
if (\count($relationships) > 0) {
222+
$replacement['relationships'] = [
223+
'type' => 'object',
224+
'properties' => $relationships,
225+
];
226+
}
227+
228+
if ($required = $definitions[$key]['required'] ?? null) {
229+
foreach ($required as $require) {
230+
if (isset($replacement['attributes']['properties'][$require])) {
231+
$replacement['attributes']['required'][] = $require;
232+
continue;
233+
}
234+
if (isset($relationships[$require])) {
235+
$replacement['relationships']['required'][] = $require;
236+
}
237+
}
238+
unset($definitions[$key]['required']);
239+
}
240+
241+
return $replacement;
242+
}
243+
244+
private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
245+
{
246+
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
247+
$types = $propertyMetadata->getBuiltinTypes() ?? [];
248+
$isRelationship = false;
249+
$isOne = $isMany = false;
250+
251+
foreach ($types as $type) {
252+
if ($type->isCollection()) {
253+
$collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
254+
$isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
255+
} else {
256+
$isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
257+
}
258+
if (!isset($className) || (!$isOne && !$isMany)) {
259+
continue;
260+
}
261+
$isRelationship = true;
262+
}
263+
264+
return $isRelationship ? [$isOne, $isMany] : null;
265+
}
266+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
66

77
<services>
8+
<service id="api_platform.jsonapi.json_schema.schema_factory" class="ApiPlatform\JsonApi\JsonSchema\SchemaFactory" decorates="api_platform.json_schema.schema_factory">
9+
<argument type="service" id="api_platform.jsonapi.json_schema.schema_factory.inner" />
10+
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
11+
<argument type="service" id="api_platform.resource_class_resolver" />
12+
</service>
13+
814
<service id="api_platform.jsonapi.encoder" class="ApiPlatform\Serializer\JsonEncoder" public="false">
915
<argument>jsonapi</argument>
1016

0 commit comments

Comments
 (0)