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

api-platform / core / 15040977736

15 May 2025 09:02AM UTC coverage: 21.754% (+13.3%) from 8.423%
15040977736

Pull #6960

github

web-flow
Merge 7a7a13526 into 1862d03b7
Pull Request #6960: feat(json-schema): mutualize json schema between formats

320 of 460 new or added lines in 24 files covered. (69.57%)

1863 existing lines in 109 files now uncovered.

11069 of 50882 relevant lines covered (21.75%)

29.49 hits per line

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

90.63
/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
    public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
128
    {
129
        if (!$definitionNameFactory) {
710✔
NEW
130
            $this->definitionNameFactory = new DefinitionNameFactory();
×
131
        }
132
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
710✔
133
            $this->schemaFactory->setSchemaFactory($this);
710✔
134
        }
135
        $this->resourceClassResolver = $resourceClassResolver;
710✔
136
        $this->resourceMetadataFactory = $resourceMetadataFactory;
710✔
137
    }
138

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

148
        if (!$this->isResourceClass($className)) {
15✔
149
            $operation = null;
15✔
150
            $inputOrOutputClass = null;
15✔
151
            $serializerContext ??= [];
15✔
152
        } else {
153
            $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format);
15✔
154
            $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext);
15✔
155
            $serializerContext ??= $this->getSerializerContext($operation, $type);
15✔
156
        }
157

158
        if (null === $inputOrOutputClass) {
15✔
159
            // input or output disabled
160
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
15✔
161
        }
162

163
        // We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources.
164
        // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes
165
        $jsonApiSerializerContext = $serializerContext;
15✔
166
        if (false === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true)) {
15✔
167
            unset($jsonApiSerializerContext['groups']);
15✔
168
        }
169

170
        $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection);
15✔
171
        $definitionName = $this->definitionNameFactory->create($className, $format, $className, $operation, $serializerContext);
15✔
172
        $prefix = $this->getSchemaUriPrefix($schema->getVersion());
15✔
173
        $definitions = $schema->getDefinitions();
15✔
174
        $collectionKey = $schema->getItemsDefinitionKey();
15✔
175

176
        // Already computed
177
        if (!$collectionKey && isset($definitions[$definitionName])) {
15✔
178
            $schema['$ref'] = $prefix.$definitionName;
15✔
179

180
            return $schema;
15✔
181
        }
182

183
        $key = $schema->getRootDefinitionKey() ?? $collectionKey;
15✔
184
        $properties = $definitions[$definitionName]['properties'] ?? [];
15✔
185

186
        // Prevent reapplying
187
        if (isset($definitions[$key]['description'])) {
15✔
188
            $definitions[$definitionName]['description'] = $definitions[$key]['description'];
15✔
189
        }
190

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

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

206
            return $schema;
15✔
207
        }
208

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

213
            return $schema;
15✔
214
        }
215

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

231
            unset($schema['items']);
15✔
232
            unset($schema['type']);
15✔
233

234
            $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
15✔
235
            $properties['data']['properties']['attributes']['$ref'] = $prefix.$key;
15✔
236

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

248
            return $schema;
15✔
249
        }
250

251
        return $schema;
×
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

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
                    $ref = $this->getSchemaUriPrefix($schema->getVersion()).$definitionName;
15✔
283
                    $refs[$ref] = '$ref';
15✔
284
                }
285
                $relatedDefinitions[$propertyName] = array_flip($refs);
15✔
286
                if ($isOne) {
15✔
287
                    $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
15✔
288
                    continue;
15✔
289
                }
290
                $relationships[$propertyName]['properties']['data'] = [
15✔
291
                    'type' => 'array',
15✔
292
                    'items' => self::RELATION_PROPS,
15✔
293
                ];
15✔
294
                continue;
15✔
295
            }
296

297
            if ('id' === $propertyName) {
15✔
298
                // should probably be renamed "lid" and moved to the above node
299
                $attributes['_id'] = $property;
15✔
300
                continue;
15✔
301
            }
302
            $attributes[$propertyName] = $property;
15✔
303
        }
304

305
        $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey();
15✔
306
        $replacement = self::PROPERTY_PROPS;
15✔
307
        $replacement['attributes'] = ['$ref' => $currentRef];
15✔
308

309
        $included = [];
15✔
310
        if (\count($relationships) > 0) {
15✔
311
            $replacement['relationships'] = [
15✔
312
                'type' => 'object',
15✔
313
                'properties' => $relationships,
15✔
314
            ];
15✔
315
            $included = [
15✔
316
                'included' => [
15✔
317
                    'description' => 'Related resources requested via the "include" query parameter.',
15✔
318
                    'type' => 'array',
15✔
319
                    'items' => [
15✔
320
                        'anyOf' => array_values($relatedDefinitions),
15✔
321
                    ],
15✔
322
                    'readOnly' => true,
15✔
323
                    'externalDocs' => [
15✔
324
                        'url' => 'https://jsonapi.org/format/#fetching-includes',
15✔
325
                    ],
15✔
326
                ],
15✔
327
            ];
15✔
328
        }
329

330
        if ($required = $definitions[$key]['required'] ?? null) {
15✔
331
            foreach ($required as $i => $require) {
15✔
332
                if (isset($relationships[$require])) {
15✔
333
                    $replacement['relationships']['required'][] = $require;
15✔
334
                    unset($required[$i]);
15✔
335
                }
336
            }
337

338
            $replacement['attributes'] = [
15✔
339
                'allOf' => [
15✔
340
                    $replacement['attributes'],
15✔
341
                    ['type' => 'object', 'required' => $required],
15✔
342
                ],
15✔
343
            ];
15✔
344

345
            unset($definitions[$key]['required']);
15✔
346
        }
347

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

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

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

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

385
            return $isRelationship ? [$isOne, $relatedClasses] : null;
×
386
        }
387

388
        if (null === $type = $propertyMetadata->getNativeType()) {
15✔
389
            return null;
15✔
390
        }
391

392
        $isRelationship = false;
15✔
393
        $isOne = $isMany = false;
15✔
394
        $relatedClasses = [];
15✔
395

396
        /** @var class-string|null $className */
397
        $className = null;
15✔
398

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

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

410
            if (!$className || (!$isOne && !$isMany)) {
15✔
411
                continue;
15✔
412
            }
413

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

422
        return $isRelationship ? [$isOne, $relatedClasses] : null;
15✔
423
    }
424
}
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