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

api-platform / core / 16531587208

25 Jul 2025 09:05PM UTC coverage: 0.0% (-22.1%) from 22.07%
16531587208

Pull #7225

github

web-flow
Merge 23f449a58 into 02a764950
Pull Request #7225: feat: json streamer

0 of 294 new or added lines in 31 files covered. (0.0%)

11514 existing lines in 375 files now uncovered.

0 of 51976 relevant lines covered (0.0%)

0.0 hits per line

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

0.0
/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
    {
UNCOV
134
        if (!$definitionNameFactory) {
×
135
            $this->definitionNameFactory = new DefinitionNameFactory();
×
136
        }
UNCOV
137
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
×
UNCOV
138
            $this->schemaFactory->setSchemaFactory($this);
×
139
        }
UNCOV
140
        $this->resourceClassResolver = $resourceClassResolver;
×
UNCOV
141
        $this->resourceMetadataFactory = $resourceMetadataFactory;
×
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
    {
UNCOV
149
        if ('jsonapi' !== $format) {
×
UNCOV
150
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
×
151
        }
152

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

UNCOV
163
        if (null === $inputOrOutputClass) {
×
164
            // input or output disabled
165
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
×
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
UNCOV
170
        $jsonApiSerializerContext = $serializerContext;
×
UNCOV
171
        if (true === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) && $inputOrOutputClass === $className) {
×
UNCOV
172
            unset($jsonApiSerializerContext['groups']);
×
173
        }
174

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

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

UNCOV
185
            return $schema;
×
186
        }
187

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

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

UNCOV
204
            $schema['$ref'] = $prefix.$definitionName;
×
205

UNCOV
206
            return $schema;
×
207
        }
208

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

UNCOV
213
            return $schema;
×
214
        }
215

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

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

UNCOV
234
        unset($schema['items']);
×
UNCOV
235
        unset($schema['type']);
×
236

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

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

UNCOV
251
        return $schema;
×
252
    }
253

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

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

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

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

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

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

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

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

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

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

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

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

UNCOV
358
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
×
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

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

UNCOV
389
        $isRelationship = false;
×
UNCOV
390
        $isOne = $isMany = false;
×
UNCOV
391
        $relatedClasses = [];
×
392

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

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

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

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

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

UNCOV
419
        return $isRelationship ? [$isOne, $relatedClasses] : null;
×
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