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

api-platform / core / 14635100171

24 Apr 2025 06:39AM UTC coverage: 8.271% (+0.02%) from 8.252%
14635100171

Pull #6904

github

web-flow
Merge c9cefd82e into a3e5e53ea
Pull Request #6904: feat(graphql): added support for graphql subscriptions to work for actions

0 of 73 new or added lines in 3 files covered. (0.0%)

1999 existing lines in 144 files now uncovered.

13129 of 158728 relevant lines covered (8.27%)

13.6 hits per line

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

89.87
/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\DefinitionNameFactoryInterface;
17
use ApiPlatform\JsonSchema\ResourceMetadataTrait;
18
use ApiPlatform\JsonSchema\Schema;
19
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
20
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
21
use ApiPlatform\Metadata\Operation;
22
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24
use ApiPlatform\Metadata\ResourceClassResolverInterface;
25
use ApiPlatform\State\ApiResource\Error;
26
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
27
use Symfony\Component\TypeInfo\Type;
28
use Symfony\Component\TypeInfo\Type\CollectionType;
29
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
30
use Symfony\Component\TypeInfo\Type\ObjectType;
31
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
32

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

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

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

123
    public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null)
124
    {
125
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
1,054✔
126
            $this->schemaFactory->setSchemaFactory($this);
1,054✔
127
        }
128
        $this->resourceClassResolver = $resourceClassResolver;
1,054✔
129
        $this->resourceMetadataFactory = $resourceMetadataFactory;
1,054✔
130
    }
131

132
    /**
133
     * {@inheritdoc}
134
     */
135
    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
136
    {
137
        if ('jsonapi' !== $format) {
110✔
138
            return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
94✔
139
        }
140
        // We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources.
141
        // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes
142
        $serializerContext ??= $this->getSerializerContext($operation ?? $this->findOperation($className, $type, $operation, $serializerContext, $format), $type);
48✔
143
        $jsonApiSerializerContext = !($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) ? $serializerContext : [];
48✔
144
        $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection);
48✔
145

146
        if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) {
48✔
147
            $definitions = $schema->getDefinitions();
48✔
148
            $properties = $definitions[$key]['properties'] ?? [];
48✔
149

150
            if (Error::class === $className && !isset($properties['errors'])) {
48✔
151
                $definitions[$key]['properties'] = [
30✔
152
                    'errors' => [
30✔
153
                        'type' => 'object',
30✔
154
                        'properties' => $properties,
30✔
155
                    ],
30✔
156
                ];
30✔
157

158
                return $schema;
30✔
159
            }
160

161
            // Prevent reapplying
162
            if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) {
48✔
163
                return $schema;
32✔
164
            }
165

166
            $definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []);
48✔
167

168
            if ($schema->getRootDefinitionKey()) {
48✔
169
                return $schema;
44✔
170
            }
171
        }
172

173
        if (($schema['type'] ?? '') === 'array') {
32✔
174
            // data
175
            $items = $schema['items'];
32✔
176
            unset($schema['items']);
32✔
177

178
            $schema['type'] = 'object';
32✔
179
            $schema['properties'] = [
32✔
180
                'links' => self::LINKS_PROPS,
32✔
181
                'meta' => self::META_PROPS,
32✔
182
                'data' => [
32✔
183
                    'type' => 'array',
32✔
184
                    'items' => $items,
32✔
185
                ],
32✔
186
            ];
32✔
187
            $schema['required'] = [
32✔
188
                'data',
32✔
189
            ];
32✔
190

191
            return $schema;
32✔
192
        }
193

UNCOV
194
        return $schema;
12✔
195
    }
196

197
    public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
198
    {
199
        if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) {
1,054✔
200
            $this->schemaFactory->setSchemaFactory($schemaFactory);
1,054✔
201
        }
202
    }
203

204
    private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
205
    {
206
        $definitions = $schema->getDefinitions();
48✔
207
        $properties = $definitions[$key]['properties'] ?? [];
48✔
208

209
        $attributes = [];
48✔
210
        $relationships = [];
48✔
211
        $relatedDefinitions = [];
48✔
212
        foreach ($properties as $propertyName => $property) {
48✔
213
            if ($relation = $this->getRelationship($className, $propertyName, $serializerContext)) {
48✔
214
                [$isOne, $relatedClasses] = $relation;
22✔
215
                $refs = [];
22✔
216
                foreach ($relatedClasses as $relatedClassName => $hasOperations) {
22✔
217
                    if (false === $hasOperations) {
22✔
UNCOV
218
                        continue;
12✔
219
                    }
220

221
                    $operation = $this->findOperation($relatedClassName, $type, $operation, $serializerContext);
22✔
222
                    $inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext);
22✔
223
                    $serializerContext ??= $this->getSerializerContext($operation, $type);
22✔
224
                    $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext);
22✔
225
                    $ref = Schema::VERSION_OPENAPI === $schema->getVersion() ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
22✔
226
                    $refs[$ref] = '$ref';
22✔
227
                }
228
                $relatedDefinitions[$propertyName] = array_flip($refs);
22✔
229
                if ($isOne) {
22✔
230
                    $relationships[$propertyName]['properties']['data'] = self::RELATION_PROPS;
20✔
231
                    continue;
20✔
232
                }
233
                $relationships[$propertyName]['properties']['data'] = [
22✔
234
                    'type' => 'array',
22✔
235
                    'items' => self::RELATION_PROPS,
22✔
236
                ];
22✔
237
                continue;
22✔
238
            }
239
            if ('id' === $propertyName) {
48✔
240
                $attributes['_id'] = $property;
42✔
241
                continue;
42✔
242
            }
243
            $attributes[$propertyName] = $property;
44✔
244
        }
245

246
        $replacement = self::PROPERTY_PROPS;
48✔
247
        $replacement['attributes']['properties'] = $attributes;
48✔
248

249
        $included = [];
48✔
250
        if (\count($relationships) > 0) {
48✔
251
            $replacement['relationships'] = [
22✔
252
                'type' => 'object',
22✔
253
                'properties' => $relationships,
22✔
254
            ];
22✔
255
            $included = [
22✔
256
                'included' => [
22✔
257
                    'description' => 'Related resources requested via the "include" query parameter.',
22✔
258
                    'type' => 'array',
22✔
259
                    'items' => [
22✔
260
                        'anyOf' => array_values($relatedDefinitions),
22✔
261
                    ],
22✔
262
                    'readOnly' => true,
22✔
263
                    'externalDocs' => [
22✔
264
                        'url' => 'https://jsonapi.org/format/#fetching-includes',
22✔
265
                    ],
22✔
266
                ],
22✔
267
            ];
22✔
268
        }
269

270
        if ($required = $definitions[$key]['required'] ?? null) {
48✔
271
            foreach ($required as $require) {
20✔
272
                if (isset($replacement['attributes']['properties'][$require])) {
20✔
273
                    $replacement['attributes']['required'][] = $require;
18✔
274
                    continue;
18✔
275
                }
276
                if (isset($relationships[$require])) {
20✔
277
                    $replacement['relationships']['required'][] = $require;
20✔
278
                }
279
            }
280
            unset($definitions[$key]['required']);
20✔
281
        }
282

283
        return [
48✔
284
            'data' => [
48✔
285
                'type' => 'object',
48✔
286
                'properties' => $replacement,
48✔
287
                'required' => ['type', 'id'],
48✔
288
            ],
48✔
289
        ] + $included;
48✔
290
    }
291

292
    private function getRelationship(string $resourceClass, string $property, ?array $serializerContext): ?array
293
    {
294
        $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $serializerContext ?? []);
48✔
295

296
        if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
48✔
297
            $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
298
            $isRelationship = false;
×
299
            $isOne = $isMany = false;
×
300
            $relatedClasses = [];
×
301

302
            foreach ($types as $type) {
×
303
                if ($type->isCollection()) {
×
304
                    $collectionValueType = $type->getCollectionValueTypes()[0] ?? null;
×
305
                    $isMany = $collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
×
306
                } else {
307
                    $isOne = ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className);
×
308
                }
309
                if (!isset($className) || (!$isOne && !$isMany)) {
×
310
                    continue;
×
311
                }
312
                $isRelationship = true;
×
313
                $resourceMetadata = $this->resourceMetadataFactory->create($className);
×
314
                $operation = $resourceMetadata->getOperation();
×
315
                // @see https://github.com/api-platform/core/issues/5501
316
                // @see https://github.com/api-platform/core/pull/5722
317
                $relatedClasses[$className] = $operation->canRead();
×
318
            }
319

320
            return $isRelationship ? [$isOne, $relatedClasses] : null;
×
321
        }
322

323
        if (null === $type = $propertyMetadata->getNativeType()) {
48✔
324
            return null;
26✔
325
        }
326

327
        $isRelationship = false;
48✔
328
        $isOne = $isMany = false;
48✔
329
        $relatedClasses = [];
48✔
330

331
        /** @var class-string|null $className */
332
        $className = null;
48✔
333

334
        $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool {
48✔
335
            return match (true) {
336
                $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
48✔
337
                $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
48✔
338
                default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()),
48✔
339
            };
340
        };
48✔
341

342
        $collectionValueIsResourceClass = function (Type $type) use (&$typeIsResourceClass): bool {
48✔
343
            return match (true) {
344
                $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass),
48✔
345
                $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass),
48✔
346
                $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass),
48✔
347
                default => false,
48✔
348
            };
349
        };
48✔
350

351
        foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
48✔
352
            if ($t->isSatisfiedBy($collectionValueIsResourceClass)) {
48✔
353
                $isMany = true;
22✔
354
            } elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
48✔
355
                $isOne = true;
20✔
356
            }
357

358
            if (!$className || (!$isOne && !$isMany)) {
48✔
359
                continue;
48✔
360
            }
361

362
            $isRelationship = true;
22✔
363
            $resourceMetadata = $this->resourceMetadataFactory->create($className);
22✔
364
            $operation = $resourceMetadata->getOperation();
22✔
365
            // @see https://github.com/api-platform/core/issues/5501
366
            // @see https://github.com/api-platform/core/pull/5722
367
            $relatedClasses[$className] = $operation->canRead();
22✔
368
        }
369

370
        return $isRelationship ? [$isOne, $relatedClasses] : null;
48✔
371
    }
372
}
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