• 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

65.84
/src/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\JsonSchema;
15

16
use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory;
17
use ApiPlatform\Metadata\ApiProperty;
18
use ApiPlatform\Metadata\CollectionOperationInterface;
19
use ApiPlatform\Metadata\HttpOperation;
20
use ApiPlatform\Metadata\Operation;
21
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
22
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
23
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24
use ApiPlatform\Metadata\ResourceClassResolverInterface;
25
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
26
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
27
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
28
use Symfony\Component\TypeInfo\Type\BuiltinType;
29
use Symfony\Component\TypeInfo\Type\CollectionType;
30
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
31
use Symfony\Component\TypeInfo\Type\ObjectType;
32
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
33
use Symfony\Component\TypeInfo\TypeIdentifier;
34

35
/**
36
 * {@inheritdoc}
37
 *
38
 * @author Kévin Dunglas <dunglas@gmail.com>
39
 */
40
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
41
{
42
    use ResourceMetadataTrait;
43

44
    private ?SchemaFactoryInterface $schemaFactory = null;
45
    // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
46
    public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
47
    public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
48

49
    public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null)
50
    {
51
        if (!$definitionNameFactory) {
1,062✔
52
            $this->definitionNameFactory = new DefinitionNameFactory($this->distinctFormats);
×
53
        }
54

55
        $this->resourceMetadataFactory = $resourceMetadataFactory;
1,062✔
56
        $this->resourceClassResolver = $resourceClassResolver;
1,062✔
57
    }
58

59
    /**
60
     * {@inheritdoc}
61
     */
62
    public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
63
    {
64
        $schema = $schema ? clone $schema : new Schema();
118✔
65

66
        if (!$this->isResourceClass($className)) {
118✔
67
            $operation = null;
62✔
68
            $inputOrOutputClass = $className;
62✔
69
            $serializerContext ??= [];
62✔
70
        } else {
71
            $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format);
110✔
72
            $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext);
110✔
73
            $serializerContext ??= $this->getSerializerContext($operation, $type);
110✔
74
        }
75

76
        if (null === $inputOrOutputClass) {
118✔
77
            // input or output disabled
UNCOV
78
            return $schema;
12✔
79
        }
80

81
        $validationGroups = $operation ? $this->getValidationGroups($operation) : [];
118✔
82
        $version = $schema->getVersion();
118✔
83
        $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext);
118✔
84
        $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
118✔
85
        if (!$operation) {
118✔
86
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
62✔
87
        }
88

89
        // In case of FORCE_SUBSCHEMA an object can be writable through another class even though it has no POST operation
90
        if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
118✔
91
            return $schema;
×
92
        }
93

94
        if (!isset($schema['$ref']) && !isset($schema['type'])) {
118✔
95
            $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
118✔
96
            if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) {
118✔
97
                $schema['type'] = 'array';
60✔
98
                $schema['items'] = ['$ref' => $ref];
60✔
99
            } else {
100
                $schema['$ref'] = $ref;
106✔
101
            }
102
        }
103

104
        $definitions = $schema->getDefinitions();
118✔
105
        if (isset($definitions[$definitionName])) {
118✔
106
            // Already computed
107
            return $schema;
44✔
108
        }
109

110
        /** @var \ArrayObject<string, mixed> $definition */
111
        $definition = new \ArrayObject(['type' => 'object']);
118✔
112
        $definitions[$definitionName] = $definition;
118✔
113
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
118✔
114

115
        // additionalProperties are allowed by default, so it does not need to be set explicitly, unless allow_extra_attributes is false
116
        // See https://json-schema.org/understanding-json-schema/reference/object.html#properties
117
        if (false === ($serializerContext[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES] ?? true)) {
118✔
118
            $definition['additionalProperties'] = false;
×
119
        }
120

121
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
122
        if (Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason()) {
118✔
UNCOV
123
            $definition['deprecated'] = true;
12✔
124
        } else {
125
            $definition['deprecated'] = false;
118✔
126
        }
127

128
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
129
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
130
        if ($operation instanceof HttpOperation && ($operation->getTypes()[0] ?? null)) {
118✔
131
            $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
20✔
132
        }
133

134
        $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
118✔
135
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
118✔
136
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
110✔
137
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
110✔
138
                continue;
38✔
139
            }
140

141
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
110✔
142
            if ($propertyMetadata->isRequired()) {
110✔
143
                $definition['required'][] = $normalizedPropertyName;
36✔
144
            }
145

146
            if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
110✔
147
                $this->buildLegacyPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
×
148
            } else {
149
                $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
110✔
150
            }
151
        }
152

153
        return $schema;
118✔
154
    }
155

156
    /**
157
     * Builds the JSON Schema for a property using the legacy PropertyInfo component.
158
     */
159
    private function buildLegacyPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
160
    {
161
        $version = $schema->getVersion();
×
162
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
×
163
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
×
164
        } else {
165
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
×
166
        }
167

168
        $propertySchema = array_merge(
×
169
            $propertyMetadata->getSchema() ?? [],
×
170
            $additionalPropertySchema ?? []
×
171
        );
×
172

173
        // @see https://github.com/api-platform/core/issues/6299
174
        if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) {
×
175
            unset($propertySchema['type']);
×
176
        }
177

178
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
×
179
        // see AttributePropertyMetadataFactory
180
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
×
181
            // schema seems to have been declared by the user: do not override nor complete user value
182
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
×
183

184
            return;
×
185
        }
186

187
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
×
188

189
        // never override the following keys if at least one is already set
190
        // or if property has no type(s) defined
191
        // or if property schema is already fully defined (type=string + format || enum)
192
        $propertySchemaType = $propertySchema['type'] ?? false;
×
193

194
        $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
×
195
            || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null))
×
196
            || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null));
×
197

198
        if (
199
            !$isUnknown && (
×
200
                [] === $types
×
201
                || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
×
202
                || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
×
203
                || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
×
204
            )
205
        ) {
206
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
×
207

208
            return;
×
209
        }
210

211
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
212
        // complete property schema with resource reference ($ref) only if it's related to an object
213
        $version = $schema->getVersion();
×
214
        $refs = [];
×
215
        $isNullable = null;
×
216

217
        foreach ($types as $type) {
×
218
            $subSchema = new Schema($version);
×
219
            $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
×
220

221
            $isCollection = $type->isCollection();
×
222
            if ($isCollection) {
×
223
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
×
224
            } else {
225
                $valueType = $type;
×
226
            }
227

228
            $className = $valueType?->getClassName();
×
229
            if (null === $className) {
×
230
                continue;
×
231
            }
232

233
            $subSchemaFactory = $this->schemaFactory ?: $this;
×
234
            $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
×
235
            if (!isset($subSchema['$ref'])) {
×
236
                continue;
×
237
            }
238

239
            if (false === $propertyMetadata->getGenId()) {
×
240
                $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext);
×
241

242
                if (isset($subSchema->getDefinitions()[$subDefinitionName])) {
×
243
                    unset($subSchema->getDefinitions()[$subDefinitionName]['properties']['@id']);
×
244
                }
245
            }
246

247
            if ($isCollection) {
×
248
                $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items';
×
249
                $propertySchema[$key]['$ref'] = $subSchema['$ref'];
×
250
                unset($propertySchema[$key]['type']);
×
251
                break;
×
252
            }
253

254
            $refs[] = ['$ref' => $subSchema['$ref']];
×
255
            $isNullable = $isNullable ?? $type->isNullable();
×
256
        }
257

258
        if ($isNullable) {
×
259
            $refs[] = ['type' => 'null'];
×
260
        }
261

262
        if (($c = \count($refs)) > 1) {
×
263
            $propertySchema['anyOf'] = $refs;
×
264
            unset($propertySchema['type']);
×
265
        } elseif (1 === $c) {
×
266
            $propertySchema['$ref'] = $refs[0]['$ref'];
×
267
            unset($propertySchema['type']);
×
268
        }
269

270
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
×
271
    }
272

273
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
274
    {
275
        $version = $schema->getVersion();
110✔
276
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
110✔
277
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
32✔
278
        } else {
279
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
78✔
280
        }
281

282
        $propertySchema = array_merge(
110✔
283
            $propertyMetadata->getSchema() ?? [],
110✔
284
            $additionalPropertySchema ?? []
110✔
285
        );
110✔
286

287
        // @see https://github.com/api-platform/core/issues/6299
288
        if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) {
110✔
289
            unset($propertySchema['type']);
14✔
290
        }
291

292
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
110✔
293
        // see AttributePropertyMetadataFactory
294
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
110✔
295
            // schema seems to have been declared by the user: do not override nor complete user value
296
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
32✔
297

298
            return;
32✔
299
        }
300

301
        $type = $propertyMetadata->getNativeType();
110✔
302
        $propertySchemaType = $propertySchema['type'] ?? false;
110✔
303
        $isSchemaDefined = ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
110✔
304
            || ($propertySchemaType && 'string' !== $propertySchemaType && !(\is_array($propertySchemaType) && !\in_array('string', $propertySchemaType, true)))
110✔
305
            || (($propertySchema['format'] ?? $propertySchema['enum'] ?? false) && $propertySchemaType);
110✔
306

307
        // Check if the type is considered "unknown" by SchemaPropertyMetadataFactory
308
        $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
110✔
309
            || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null))
110✔
310
            || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null));
110✔
311

312
        // If schema is defined and not marked as unknown, or if no type info exists, return early
313
        if (!$isUnknown && (null === $type || $isSchemaDefined)) {
110✔
314
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
92✔
315

316
            return;
92✔
317
        }
318

319
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
320
        // complete property schema with resource reference ($ref) if it's related to an object/resource
321
        $refs = [];
92✔
322
        $isNullable = $type?->isNullable() ?? false;
92✔
323

324
        if ($type) {
92✔
325
            foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) {
92✔
326
                if ($t instanceof BuiltinType && TypeIdentifier::NULL === $t->getTypeIdentifier()) {
92✔
327
                    continue;
52✔
328
                }
329

330
                $valueType = $t;
92✔
331
                $isCollection = $t instanceof CollectionType;
92✔
332

333
                if ($isCollection) {
92✔
334
                    $valueType = $t->getCollectionValueType();
60✔
335
                }
336

337
                while ($valueType instanceof WrappingTypeInterface) {
92✔
338
                    $valueType = $valueType->getWrappedType();
×
339
                }
340

341
                if (!$valueType instanceof ObjectType) {
92✔
342
                    continue;
90✔
343
                }
344

345
                $className = $valueType->getClassName();
66✔
346
                $subSchemaInstance = new Schema($version);
66✔
347
                $subSchemaInstance->setDefinitions($schema->getDefinitions());
66✔
348
                $subSchemaFactory = $this->schemaFactory ?: $this;
66✔
349
                $subSchemaResult = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchemaInstance, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
66✔
350
                if (!isset($subSchemaResult['$ref'])) {
66✔
UNCOV
351
                    continue;
12✔
352
                }
353

354
                if (false === $propertyMetadata->getGenId()) {
66✔
355
                    $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext);
14✔
356
                    if (isset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id'])) {
14✔
357
                        unset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id']);
14✔
358
                    }
359
                }
360

361
                if ($isCollection) {
66✔
362
                    $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items';
42✔
363
                    if (!isset($propertySchema[$key]) || !\is_array($propertySchema[$key])) {
42✔
UNCOV
364
                        $propertySchema[$key] = [];
12✔
365
                    }
366
                    $propertySchema[$key]['$ref'] = $subSchemaResult['$ref'];
42✔
367
                    unset($propertySchema[$key]['type']);
42✔
368
                    $refs = [];
42✔
369
                    break;
42✔
370
                }
371

372
                $refs[] = ['$ref' => $subSchemaResult['$ref']];
50✔
373
            }
374
        }
375

376
        if (!empty($refs)) {
92✔
377
            if ($isNullable) {
50✔
378
                $refs[] = ['type' => 'null'];
40✔
379
            }
380

381
            if (($c = \count($refs)) > 1) {
50✔
382
                $propertySchema['anyOf'] = $refs;
40✔
383
                unset($propertySchema['type'], $propertySchema['$ref']);
40✔
384
            } elseif (1 === $c) {
34✔
385
                $propertySchema['$ref'] = $refs[0]['$ref'];
34✔
386
                unset($propertySchema['type']);
34✔
387
            }
388
        }
389

390
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
92✔
391
    }
392

393
    private function getValidationGroups(Operation $operation): array
394
    {
395
        $groups = $operation->getValidationContext()['groups'] ?? [];
110✔
396

397
        return \is_array($groups) ? $groups : [$groups];
110✔
398
    }
399

400
    /**
401
     * Gets the options for the property name collection / property metadata factories.
402
     */
403
    private function getFactoryOptions(array $serializerContext, array $validationGroups, ?HttpOperation $operation = null): array
404
    {
405
        $options = [
118✔
406
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
407
            'enable_getter_setter_extraction' => true,
118✔
408
        ];
118✔
409

410
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
118✔
411
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
412
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
44✔
413
        }
414

415
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
118✔
416
            $options['normalization_groups'] = $normalizationGroups;
50✔
417
        }
418

419
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
118✔
420
            $options['denormalization_groups'] = $denormalizationGroups;
20✔
421
        }
422

423
        if ($validationGroups) {
118✔
424
            $options['validation_groups'] = $validationGroups;
×
425
        }
426

427
        if ($operation && ($ignoredAttributes = $operation->getNormalizationContext()['ignored_attributes'] ?? null)) {
118✔
428
            $options['ignored_attributes'] = $ignoredAttributes;
40✔
429
        }
430

431
        return $options;
118✔
432
    }
433

434
    public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
435
    {
436
        $this->schemaFactory = $schemaFactory;
1,062✔
437
    }
438
}
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