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

api-platform / core / 14726067612

29 Apr 2025 07:47AM UTC coverage: 23.443% (+15.2%) from 8.252%
14726067612

push

github

web-flow
feat(symfony): Autoconfigure classes using `#[ApiResource]` attribute (#6943)

0 of 12 new or added lines in 4 files covered. (0.0%)

3578 existing lines in 159 files now uncovered.

11517 of 49127 relevant lines covered (23.44%)

54.29 hits per line

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

89.33
/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\Metadata\Util\TypeHelper;
26
use ApiPlatform\State\ApiResource\Error;
27
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
28
use Symfony\Component\TypeInfo\Type;
29
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
30
use Symfony\Component\TypeInfo\Type\ObjectType;
31

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

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

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

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

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

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

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

157
                return $schema;
27✔
158
            }
159

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

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

167
            if ($schema->getRootDefinitionKey()) {
27✔
168
                return $schema;
27✔
169
            }
170
        }
171

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

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

190
            return $schema;
27✔
191
        }
192

193
        return $schema;
27✔
194
    }
195

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

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

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

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

245
        $replacement = self::PROPERTY_PROPS;
27✔
246
        $replacement['attributes']['properties'] = $attributes;
27✔
247

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

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

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

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

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

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

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

322
        if (null === $type = $propertyMetadata->getNativeType()) {
27✔
323
            return null;
27✔
324
        }
325

326
        $isRelationship = false;
27✔
327
        $isOne = $isMany = false;
27✔
328
        $relatedClasses = [];
27✔
329

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

333
        $typeIsResourceClass = function (Type $type) use (&$className): bool {
27✔
334
            return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName());
27✔
335
        };
27✔
336

337
        foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
27✔
338
            if (TypeHelper::getCollectionValueType($t)?->isSatisfiedBy($typeIsResourceClass)) {
27✔
339
                $isMany = true;
27✔
340
            } elseif ($t->isSatisfiedBy($typeIsResourceClass)) {
27✔
341
                $isOne = true;
27✔
342
            }
343

344
            if (!$className || (!$isOne && !$isMany)) {
27✔
345
                continue;
27✔
346
            }
347

348
            $isRelationship = true;
27✔
349
            $resourceMetadata = $this->resourceMetadataFactory->create($className);
27✔
350
            $operation = $resourceMetadata->getOperation();
27✔
351
            // @see https://github.com/api-platform/core/issues/5501
352
            // @see https://github.com/api-platform/core/pull/5722
353
            $relatedClasses[$className] = $operation->canRead();
27✔
354
        }
355

356
        return $isRelationship ? [$isOne, $relatedClasses] : null;
27✔
357
    }
358
}
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

© 2026 Coveralls, Inc