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

api-platform / core / 9692621821

27 Jun 2024 07:41AM UTC coverage: 66.526% (+0.002%) from 66.524%
9692621821

push

github

web-flow
fix(metadata): wrong schema generated if openapicontext set on array (#6431)

1 of 1 new or added line in 1 file covered. (100.0%)

21 existing lines in 2 files now uncovered.

16420 of 24682 relevant lines covered (66.53%)

40.32 hits per line

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

94.41
/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\Exception\OperationNotFoundException;
20
use ApiPlatform\Metadata\HttpOperation;
21
use ApiPlatform\Metadata\Operation;
22
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
23
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
24
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
25
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
26
use ApiPlatform\Metadata\ResourceClassResolverInterface;
27
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
28
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
29
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
30

31
/**
32
 * {@inheritdoc}
33
 *
34
 * @author Kévin Dunglas <dunglas@gmail.com>
35
 */
36
final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface
37
{
38
    use ResourceClassInfoTrait;
39
    private ?TypeFactoryInterface $typeFactory = null;
40
    private ?SchemaFactoryInterface $schemaFactory = null;
41
    // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
42
    public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
43
    public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
44

45
    public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null)
46
    {
47
        if ($typeFactory) {
224✔
48
            $this->typeFactory = $typeFactory;
188✔
49
        }
50

51
        $this->resourceMetadataFactory = $resourceMetadataFactory;
224✔
52
        $this->resourceClassResolver = $resourceClassResolver;
224✔
53
    }
54

55
    /**
56
     * {@inheritdoc}
57
     */
58
    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
59
    {
60
        $schema = $schema ? clone $schema : new Schema();
156✔
61

62
        if (null === $metadata = $this->getMetadata($className, $type, $operation, $serializerContext)) {
156✔
63
            return $schema;
20✔
64
        }
65

66
        [$operation, $serializerContext, $validationGroups, $inputOrOutputClass] = $metadata;
156✔
67

68
        $version = $schema->getVersion();
156✔
69
        $definitionName = $this->buildDefinitionName($className, $format, $inputOrOutputClass, $operation, $serializerContext);
156✔
70

71
        $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
156✔
72
        if (!$operation) {
156✔
73
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
44✔
74
        }
75

76
        // In case of FORCE_SUBSCHEMA an object can be writable through another class eventhough it has no POST operation
77
        if (!($serializerContext[self::FORCE_SUBSCHEMA] ?? false) && Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
156✔
78
            return $schema;
×
79
        }
80

81
        if (!isset($schema['$ref']) && !isset($schema['type'])) {
156✔
82
            $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
156✔
83
            if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) {
156✔
84
                $schema['type'] = 'array';
64✔
85
                $schema['items'] = ['$ref' => $ref];
64✔
86
            } else {
87
                $schema['$ref'] = $ref;
132✔
88
            }
89
        }
90

91
        $definitions = $schema->getDefinitions();
156✔
92
        if (isset($definitions[$definitionName])) {
156✔
93
            // Already computed
94
            return $schema;
20✔
95
        }
96

97
        /** @var \ArrayObject<string, mixed> $definition */
98
        $definition = new \ArrayObject(['type' => 'object']);
156✔
99
        $definitions[$definitionName] = $definition;
156✔
100
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
156✔
101

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

108
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
109
        if (Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason()) {
156✔
110
            $definition['deprecated'] = true;
20✔
111
        } else {
112
            $definition['deprecated'] = false;
156✔
113
        }
114

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

121
        $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
156✔
122
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
156✔
123
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
120✔
124
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
120✔
125
                continue;
24✔
126
            }
127

128
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
120✔
129
            if ($propertyMetadata->isRequired()) {
120✔
130
                $definition['required'][] = $normalizedPropertyName;
44✔
131
            }
132

133
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
120✔
134
        }
135

136
        return $schema;
156✔
137
    }
138

139
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
140
    {
141
        $version = $schema->getVersion();
120✔
142
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
120✔
143
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
20✔
144
        } else {
145
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
100✔
146
        }
147

148
        $propertySchema = array_merge(
120✔
149
            $propertyMetadata->getSchema() ?? [],
120✔
150
            $additionalPropertySchema ?? []
120✔
151
        );
120✔
152

153
        // @see https://github.com/api-platform/core/issues/6299
154
        if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) {
120✔
UNCOV
155
            unset($propertySchema['type']);
×
156
        }
157

158
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
120✔
159
        // see AttributePropertyMetadataFactory
160
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
120✔
161
            // schema seems to have been declared by the user: do not override nor complete user value
162
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
24✔
163

164
            return;
24✔
165
        }
166

167
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
120✔
168

169
        // never override the following keys if at least one is already set
170
        // or if property has no type(s) defined
171
        // or if property schema is already fully defined (type=string + format || enum)
172
        $propertySchemaType = $propertySchema['type'] ?? false;
120✔
173

174
        $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
120✔
175
            || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null));
120✔
176

177
        if (
178
            !$isUnknown && (
120✔
179
                [] === $types
120✔
180
                || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
120✔
181
                || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
120✔
182
                || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
120✔
183
            )
184
        ) {
185
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
80✔
186

187
            return;
80✔
188
        }
189

190
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
191
        // complete property schema with resource reference ($ref) only if it's related to an object
192
        $version = $schema->getVersion();
104✔
193
        $refs = [];
104✔
194
        $isNullable = null;
104✔
195

196
        foreach ($types as $type) {
104✔
197
            $subSchema = new Schema($version);
104✔
198
            $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
104✔
199

200
            // TODO: in 3.3 add trigger_deprecation() as type factories are not used anymore, we moved this logic to SchemaPropertyMetadataFactory so that it gets cached
201
            if ($typeFromFactory = $this->typeFactory?->getType($type, 'jsonschema', $propertyMetadata->isReadableLink(), $serializerContext)) {
104✔
202
                $propertySchema = $typeFromFactory;
24✔
203
                break;
24✔
204
            }
205

206
            $isCollection = $type->isCollection();
100✔
207
            if ($isCollection) {
100✔
208
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
72✔
209
            } else {
210
                $valueType = $type;
100✔
211
            }
212

213
            $className = $valueType?->getClassName();
100✔
214
            if (null === $className) {
100✔
215
                continue;
100✔
216
            }
217

218
            $subSchemaFactory = $this->schemaFactory ?: $this;
56✔
219
            $subSchema = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
56✔
220
            if (!isset($subSchema['$ref'])) {
56✔
221
                continue;
×
222
            }
223

224
            if ($isCollection) {
56✔
225
                $propertySchema['items']['$ref'] = $subSchema['$ref'];
52✔
226
                unset($propertySchema['items']['type']);
52✔
227
                break;
52✔
228
            }
229

230
            $refs[] = ['$ref' => $subSchema['$ref']];
40✔
231
            $isNullable = $isNullable ?? $type->isNullable();
40✔
232
        }
233

234
        if ($isNullable) {
104✔
235
            $refs[] = ['type' => 'null'];
28✔
236
        }
237

238
        if (($c = \count($refs)) > 1) {
104✔
239
            $propertySchema['anyOf'] = $refs;
28✔
240
            unset($propertySchema['type']);
28✔
241
        } elseif (1 === $c) {
104✔
242
            $propertySchema['$ref'] = $refs[0]['$ref'];
36✔
243
            unset($propertySchema['type']);
36✔
244
        }
245

246
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
104✔
247
    }
248

249
    private function buildDefinitionName(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, ?array $serializerContext = null): string
250
    {
251
        if ($operation) {
156✔
252
            $prefix = $operation->getShortName();
156✔
253
        }
254

255
        if (!isset($prefix)) {
156✔
256
            $prefix = (new \ReflectionClass($className))->getShortName();
92✔
257
        }
258

259
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
156✔
260
            $shortName = $this->getShortClassName($inputOrOutputClass);
32✔
261
            $prefix .= '.'.$shortName;
32✔
262
        }
263

264
        if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) {
156✔
265
            // JSON is the default, and so isn't included in the definition name
266
            $prefix .= '.'.$format;
148✔
267
        }
268

269
        $definitionName = $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null;
156✔
270
        if ($definitionName) {
156✔
271
            $name = sprintf('%s-%s', $prefix, $definitionName);
×
272
        } else {
273
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
156✔
274
            $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
156✔
275
        }
276

277
        return $this->encodeDefinitionName($name);
156✔
278
    }
279

280
    private function encodeDefinitionName(string $name): string
281
    {
282
        return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
156✔
283
    }
284

285
    private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?array $serializerContext = null): ?array
286
    {
287
        if (!$this->isResourceClass($className)) {
156✔
288
            return [
44✔
289
                null,
44✔
290
                $serializerContext ?? [],
44✔
291
                [],
44✔
292
                $className,
44✔
293
            ];
44✔
294
        }
295

296
        $forceSubschema = $serializerContext[self::FORCE_SUBSCHEMA] ?? false;
156✔
297
        if (null === $operation) {
156✔
298
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
100✔
299
            try {
300
                $operation = $resourceMetadataCollection->getOperation();
100✔
301
            } catch (OperationNotFoundException $e) {
×
302
                $operation = new HttpOperation();
×
303
            }
304
            if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
100✔
305
                $operation = new HttpOperation();
52✔
306
            }
307

308
            $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
100✔
309
        } else {
310
            // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
311
            if (!$operation->getClass()) {
100✔
312
                $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
28✔
313

314
                if ($operation->getName()) {
28✔
315
                    $operation = $resourceMetadataCollection->getOperation($operation->getName());
16✔
316
                } else {
317
                    $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
12✔
318
                }
319
            }
320
        }
321

322
        $inputOrOutput = ['class' => $className];
156✔
323
        $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
156✔
324
        $outputClass = $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
156✔
325

326
        if (null === $outputClass) {
156✔
327
            // input or output disabled
328
            return null;
20✔
329
        }
330

331
        return [
156✔
332
            $operation,
156✔
333
            $serializerContext ?? $this->getSerializerContext($operation, $type),
156✔
334
            $this->getValidationGroups($operation),
156✔
335
            $outputClass,
156✔
336
        ];
156✔
337
    }
338

339
    private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
340
    {
341
        // Find the operation and use the first one that matches criterias
342
        foreach ($resourceMetadataCollection as $resourceMetadata) {
104✔
343
            foreach ($resourceMetadata->getOperations() ?? [] as $op) {
104✔
344
                if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
104✔
345
                    $operation = $op;
×
346
                    break 2;
×
347
                }
348

349
                if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
104✔
350
                    $operation = $op;
28✔
351
                    break 2;
28✔
352
                }
353
            }
354
        }
355

356
        return $operation;
104✔
357
    }
358

359
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
360
    {
361
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
100✔
362
    }
363

364
    private function getValidationGroups(Operation $operation): array
365
    {
366
        $groups = $operation->getValidationContext()['groups'] ?? [];
156✔
367

368
        return \is_array($groups) ? $groups : [$groups];
156✔
369
    }
370

371
    /**
372
     * Gets the options for the property name collection / property metadata factories.
373
     */
374
    private function getFactoryOptions(array $serializerContext, array $validationGroups, ?HttpOperation $operation = null): array
375
    {
376
        $options = [
156✔
377
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
378
            'enable_getter_setter_extraction' => true,
156✔
379
        ];
156✔
380

381
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
156✔
382
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
383
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
48✔
384
        }
385

386
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
156✔
387
            $options['normalization_groups'] = $normalizationGroups;
48✔
388
        }
389

390
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
156✔
391
            $options['denormalization_groups'] = $denormalizationGroups;
32✔
392
        }
393

394
        if ($validationGroups) {
156✔
395
            $options['validation_groups'] = $validationGroups;
×
396
        }
397

398
        return $options;
156✔
399
    }
400

401
    private function getShortClassName(string $fullyQualifiedName): string
402
    {
403
        $parts = explode('\\', $fullyQualifiedName);
100✔
404

405
        return end($parts);
100✔
406
    }
407

408
    public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
409
    {
410
        $this->schemaFactory = $schemaFactory;
224✔
411
    }
412
}
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