• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 15069419398

16 May 2025 01:19PM UTC coverage: 21.832%. First build
15069419398

Pull #6960

github

web-flow
Merge 88ff9fb4b into b6080d419
Pull Request #6960: feat(json-schema): mutualize json schema between formats

0 of 470 new or added lines in 25 files covered. (0.0%)

11116 of 50915 relevant lines covered (21.83%)

29.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.37
/src/JsonApi/JsonSchema/SchemaFactory.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
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\JsonSchema\DefinitionNameFactory;
17
use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface;
18
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
19
use ApiPlatform\JsonSchema\Schema;
20
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
21
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
22
use ApiPlatform\JsonSchema\SchemaUriPrefixTrait;
23
use ApiPlatform\Metadata\Operation;
24
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
25
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
26
use ApiPlatform\Metadata\ResourceClassResolverInterface;
27
use ApiPlatform\Metadata\Util\TypeHelper;
28
use ApiPlatform\State\ApiResource\Error;
29
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
30
use Symfony\Component\TypeInfo\Type;
31
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
32
use Symfony\Component\TypeInfo\Type\ObjectType;
33

34
/**
35
 * Decorator factory which adds JSON:API properties to the JSON Schema document.
36
 *
37
 * @author Gwendolen Lynch <gwendolen.lynch@gmail.com>
38
 */
39
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
40
{
41
    use ResourceMetadataTrait;
42
    use SchemaUriPrefixTrait;
43

44
    /**
45
     * As JSON:API recommends using [includes](https://jsonapi.org/format/#fetching-includes) instead of groups
46
     * this flag allows to force using groups to generate the JSON:API JSONSchema. Defaults to true, use it in
47
     * a serializer context.
48
     */
49
    public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups';
50

51
    private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema';
52

53
    private const LINKS_PROPS = [
54
        'type' => 'object',
55
        'properties' => [
56
            'self' => [
57
                'type' => 'string',
58
                'format' => 'iri-reference',
59
            ],
60
            'first' => [
61
                'type' => 'string',
62
                'format' => 'iri-reference',
63
            ],
64
            'prev' => [
65
                'type' => 'string',
66
                'format' => 'iri-reference',
67
            ],
68
            'next' => [
69
                'type' => 'string',
70
                'format' => 'iri-reference',
71
            ],
72
            'last' => [
73
                'type' => 'string',
74
                'format' => 'iri-reference',
75
            ],
76
        ],
77
        'example' => [
78
            'self' => 'string',
79
            'first' => 'string',
80
            'prev' => 'string',
81
            'next' => 'string',
82
            'last' => 'string',
83
        ],
84
    ];
85
    private const META_PROPS = [
86
        'type' => 'object',
87
        'properties' => [
88
            'totalItems' => [
89
                'type' => 'integer',
90
                'minimum' => 0,
91
            ],
92
            'itemsPerPage' => [
93
                'type' => 'integer',
94
                'minimum' => 0,
95
            ],
96
            'currentPage' => [
97
                'type' => 'integer',
98
                'minimum' => 0,
99
            ],
100
        ],
101
    ];
102
    private const RELATION_PROPS = [
103
        'type' => 'object',
104
        'properties' => [
105
            'type' => [
106
                'type' => 'string',
107
            ],
108
            'id' => [
109
                'type' => 'string',
110
                'format' => 'iri-reference',
111
            ],
112
        ],
113
    ];
114
    private const PROPERTY_PROPS = [
115
        'id' => [
116
            'type' => 'string',
117
        ],
118
        'type' => [
119
            'type' => 'string',
120
        ],
121
        'attributes' => [
122
            'type' => 'object',
123
            'properties' => [],
124
        ],
125
    ];
126

127
    /**
128
     * @var array<string, bool>
129
     */
130
    private $builtSchema = [];
131

132
    public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
133
    {
NEW
134
        if (!$definitionNameFactory) {
710✔
NEW
135
            $this->definitionNameFactory = new DefinitionNameFactory();
×
136
        }
137
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
710✔
138
            $this->schemaFactory->setSchemaFactory($this);
710✔
139
        }
140
        $this->resourceClassResolver = $resourceClassResolver;
710✔
141
        $this->resourceMetadataFactory = $resourceMetadataFactory;
710✔
142
    }
143

144
    /**
145
     * {@inheritdoc}
146
     */
147
    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
148
    {
149
        if ('jsonapi' !== $format) {
15✔
150
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
15✔
151
        }
152

NEW
153
        if (!$this->isResourceClass($className)) {
15✔
NEW
154
            $operation = null;
15✔
NEW
155
            $inputOrOutputClass = null;
15✔
NEW
156
            $serializerContext ??= [];
15✔
157
        } else {
NEW
158
            $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format);
15✔
NEW
159
            $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext);
15✔
NEW
160
            $serializerContext ??= $this->getSerializerContext($operation, $type);
15✔
161
        }
162

NEW
163
        if (null === $inputOrOutputClass) {
15✔
164
            // input or output disabled
NEW
165
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
15✔
166
        }
167

168
        // We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources.
169
        // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes
NEW
170
        $jsonApiSerializerContext = $serializerContext;
15✔
NEW
171
        if (true === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) && $inputOrOutputClass === $className) {
15✔
NEW
172
            unset($jsonApiSerializerContext['groups']);
15✔
173
        }
174

NEW
175
        $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection);
15✔
NEW
176
        $definitionName = $this->definitionNameFactory->create($inputOrOutputClass, $format, $className, $operation, $jsonApiSerializerContext);
15✔
NEW
177
        $prefix = $this->getSchemaUriPrefix($schema->getVersion());
15✔
NEW
178
        $definitions = $schema->getDefinitions();
15✔
NEW
179
        $collectionKey = $schema->getItemsDefinitionKey();
15✔
180

181
        // Already computed
NEW
182
        if (!$collectionKey && isset($definitions[$definitionName])) {
15✔
NEW
183
            $schema['$ref'] = $prefix.$definitionName;
15✔
184

NEW
185
            return $schema;
15✔
186
        }
187

NEW
188
        $key = $schema->getRootDefinitionKey() ?? $collectionKey;
15✔
NEW
189
        $properties = $definitions[$definitionName]['properties'] ?? [];
15✔
190

NEW
191
        if (Error::class === $className && !isset($properties['errors'])) {
15✔
NEW
192
            $definitions[$definitionName]['properties'] = [
15✔
NEW
193
                'errors' => [
15✔
194
                    'type' => 'array',
15✔
NEW
195
                    'items' => [
15✔
NEW
196
                        'allOf' => [
15✔
NEW
197
                            ['$ref' => $prefix.$key],
15✔
NEW
198
                            ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]],
15✔
NEW
199
                        ],
15✔
NEW
200
                    ],
15✔
201
                ],
15✔
202
            ];
15✔
203

NEW
204
            $schema['$ref'] = $prefix.$definitionName;
15✔
205

NEW
206
            return $schema;
15✔
207
        }
208

NEW
209
        if (!$collectionKey) {
15✔
NEW
210
            $definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
15✔
NEW
211
            $schema['$ref'] = $prefix.$definitionName;
15✔
212

213
            return $schema;
15✔
214
        }
215

NEW
216
        if (($schema['type'] ?? '') !== 'array') {
15✔
NEW
217
            return $schema;
×
218
        }
219

NEW
220
        if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) {
15✔
NEW
221
            $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [
15✔
NEW
222
                'type' => 'object',
15✔
NEW
223
                'properties' => [
15✔
NEW
224
                    'links' => self::LINKS_PROPS,
15✔
NEW
225
                    'meta' => self::META_PROPS,
15✔
NEW
226
                    'data' => [
15✔
NEW
227
                        'type' => 'array',
15✔
NEW
228
                    ],
15✔
NEW
229
                ],
15✔
NEW
230
                'required' => ['data'],
15✔
NEW
231
            ];
15✔
232
        }
233

NEW
234
        unset($schema['items']);
15✔
NEW
235
        unset($schema['type']);
15✔
236

NEW
237
        $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
15✔
NEW
238
        $properties['data']['properties']['attributes']['$ref'] = $prefix.$key;
15✔
239

NEW
240
        $schema['description'] = "$definitionName collection.";
15✔
NEW
241
        $schema['allOf'] = [
15✔
NEW
242
            ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME],
15✔
NEW
243
            ['type' => 'object', 'properties' => [
15✔
NEW
244
                'data' => [
15✔
NEW
245
                    'type' => 'array',
15✔
NEW
246
                    'items' => $properties['data'],
15✔
NEW
247
                ],
15✔
NEW
248
            ]],
15✔
NEW
249
        ];
15✔
250

251
        return $schema;
15✔
252
    }
253

254
    public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
255
    {
256
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
710✔
257
            $this->schemaFactory->setSchemaFactory($schemaFactory);
710✔
258
        }
259
    }
260

261
    private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
262
    {
263
        $definitions = $schema->getDefinitions();
15✔
264
        $properties = $definitions[$key]['properties'] ?? [];
15✔
265

266
        $attributes = [];
15✔
267
        $relationships = [];
15✔
268
        $relatedDefinitions = [];
15✔
269
        foreach ($properties as $propertyName => $property) {
15✔
270
            if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
15✔
271
                [$isOne, $relatedClasses] = $relation;
15✔
272
                $refs = [];
15✔
273
                foreach ($relatedClasses as $relatedClassName => $hasOperations) {
15✔
274
                    if (false === $hasOperations) {
15✔
275
                        continue;
15✔
276
                    }
277

NEW
278
                    $operation = $this->findOperation($relatedClassName, $type, null, $serializerContext);
15✔
279
                    $inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext);
15✔
280
                    $serializerContext ??= $this->getSerializerContext($operation, $type);
15✔
281
                    $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext);
15✔
282

283
                    // to avoid recursion
NEW
284
                    if ($this->builtSchema[$definitionName] ?? false) {
15✔
NEW
285
                        $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref';
15✔
NEW
286
                        continue;
15✔
287
                    }
288

NEW
289
                    if (!isset($definitions[$definitionName])) {
15✔
NEW
290
                        $this->builtSchema[$definitionName] = true;
15✔
NEW
291
                        $subSchema = new Schema($schema->getVersion());
15✔
NEW
292
                        $subSchema->setDefinitions($schema->getDefinitions());
15✔
NEW
293
                        $subSchema = $this->buildSchema($relatedClassName, $format, $type, $operation, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
15✔
NEW
294
                        $schema->setDefinitions($subSchema->getDefinitions());
15✔
NEW
295
                        $definitions = $schema->getDefinitions();
15✔
296
                    }
297

NEW
298
                    $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref';
15✔
299
                }
300
                $relatedDefinitions[$propertyName] = array_flip($refs);
15✔
301
                if ($isOne) {
15✔
302
                    $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
15✔
303
                    continue;
15✔
304
                }
305
                $relationships[$propertyName]['properties']['data'] = [
15✔
306
                    'type' => 'array',
15✔
307
                    'items' => self::RELATION_PROPS,
15✔
308
                ];
15✔
309
                continue;
15✔
310
            }
311

312
            if ('id' === $propertyName) {
15✔
313
                // should probably be renamed "lid" and moved to the above node
314
                $attributes['_id'] = $property;
15✔
315
                continue;
15✔
316
            }
317
            $attributes[$propertyName] = $property;
15✔
318
        }
319

NEW
320
        $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey();
15✔
321
        $replacement = self::PROPERTY_PROPS;
15✔
NEW
322
        $replacement['attributes'] = ['$ref' => $currentRef];
15✔
323

324
        $included = [];
15✔
325
        if (\count($relationships) > 0) {
15✔
326
            $replacement['relationships'] = [
15✔
327
                'type' => 'object',
15✔
328
                'properties' => $relationships,
15✔
329
            ];
15✔
330
            $included = [
15✔
331
                'included' => [
15✔
332
                    'description' => 'Related resources requested via the "include" query parameter.',
15✔
333
                    'type' => 'array',
15✔
334
                    'items' => [
15✔
335
                        'anyOf' => array_values($relatedDefinitions),
15✔
336
                    ],
15✔
337
                    'readOnly' => true,
15✔
338
                    'externalDocs' => [
15✔
339
                        'url' => 'https://jsonapi.org/format/#fetching-includes',
15✔
340
                    ],
15✔
341
                ],
15✔
342
            ];
15✔
343
        }
344

345
        return [
15✔
346
            'data' => [
15✔
347
                'type' => 'object',
15✔
348
                'properties' => $replacement,
15✔
349
                'required' => ['type', 'id'],
15✔
350
            ],
15✔
351
        ] + $included;
15✔
352
    }
353

354
    private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
355
    {
356
        $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
15✔
357

358
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
15✔
359
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
360
            $isRelationship = false;
×
361
            $isOne = $isMany = false;
×
362
            $relatedClasses = [];
×
363

364
            foreach ($types as $type) {
×
365
                if ($type->isCollection()) {
×
366
                    $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
×
367
                    $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
×
368
                } else {
369
                    $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
×
370
                }
371
                if (!isset($className) || (!$isOne && !$isMany)) {
×
372
                    continue;
×
373
                }
374
                $isRelationship = true;
×
375
                $resourceMetadata = $this->resourceMetadataFactory->create($className);
×
376
                $operation = $resourceMetadata->getOperation();
×
377
                // @see https://github.com/api-platform/core/issues/5501
378
                // @see https://github.com/api-platform/core/pull/5722
379
                $relatedClasses[$className] = $operation->canRead();
×
380
            }
381

382
            return $isRelationship ? [$isOne, $relatedClasses] : null;
×
383
        }
384

385
        if (null === $type = $propertyMetadata->getNativeType()) {
15✔
386
            return null;
15✔
387
        }
388

389
        $isRelationship = false;
15✔
390
        $isOne = $isMany = false;
15✔
391
        $relatedClasses = [];
15✔
392

393
        /** @var class-string|null $className */
394
        $className = null;
15✔
395

396
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
15✔
397
            return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
15✔
398
        };
15✔
399

400
        foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
15✔
401
            if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
15✔
402
                $isMany = true;
15✔
403
            } elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
15✔
404
                $isOne = true;
15✔
405
            }
406

407
            if (!$className || (!$isOne && !$isMany)) {
15✔
408
                continue;
15✔
409
            }
410

411
            $isRelationship = true;
15✔
412
            $resourceMetadata = $this->resourceMetadataFactory->create($className);
15✔
413
            $operation = $resourceMetadata->getOperation();
15✔
414
            // @see https://github.com/api-platform/core/issues/5501
415
            // @see https://github.com/api-platform/core/pull/5722
416
            $relatedClasses[$className] = $operation->canRead();
15✔
417
        }
418

419
        return $isRelationship ? [$isOne, $relatedClasses] : null;
15✔
420
    }
421
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc