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

api-platform / core / 7047458607

30 Nov 2023 01:53PM UTC coverage: 37.263% (+0.002%) from 37.261%
7047458607

push

github

soyuka
Merge 3.2

20 of 35 new or added lines in 12 files covered. (57.14%)

10 existing lines in 3 files now uncovered.

10295 of 27628 relevant lines covered (37.26%)

21.04 hits per line

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

94.67
/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
37
{
38
    use ResourceClassInfoTrait;
39
    private array $distinctFormats = [];
40
    private ?TypeFactoryInterface $typeFactory = 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)
46
    {
47
        if ($typeFactory) {
128✔
48
            $this->typeFactory = $typeFactory;
101✔
49
        }
50

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

55
    /**
56
     * When added to the list, the given format will lead to the creation of a new definition.
57
     *
58
     * @internal
59
     */
60
    public function addDistinctFormat(string $format): void
61
    {
62
        $this->distinctFormats[$format] = true;
128✔
63
    }
64

65
    /**
66
     * {@inheritdoc}
67
     */
68
    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
69
    {
70
        $schema = $schema ? clone $schema : new Schema();
84✔
71

72
        if (null === $metadata = $this->getMetadata($className, $type, $operation, $serializerContext)) {
84✔
73
            return $schema;
15✔
74
        }
75

76
        [$operation, $serializerContext, $validationGroups, $inputOrOutputClass] = $metadata;
84✔
77

78
        $version = $schema->getVersion();
84✔
79
        $definitionName = $this->buildDefinitionName($className, $format, $inputOrOutputClass, $operation, $serializerContext);
84✔
80

81
        $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
84✔
82
        if (!$operation) {
84✔
83
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
24✔
84
        }
85

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

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

101
        $definitions = $schema->getDefinitions();
84✔
102
        if (isset($definitions[$definitionName])) {
84✔
103
            // Already computed
104
            return $schema;
15✔
105
        }
106

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

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

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

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

131
        $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
84✔
132
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
84✔
133
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
57✔
134
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
57✔
135
                continue;
18✔
136
            }
137

138
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
57✔
139
            if ($propertyMetadata->isRequired()) {
57✔
140
                $definition['required'][] = $normalizedPropertyName;
30✔
141
            }
142

143
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type);
57✔
144
        }
145

146
        return $schema;
84✔
147
    }
148

149
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void
150
    {
151
        $version = $schema->getVersion();
57✔
152
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
57✔
153
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
15✔
154
        } else {
155
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
42✔
156
        }
157

158
        $propertySchema = array_merge(
57✔
159
            $propertyMetadata->getSchema() ?? [],
57✔
160
            $additionalPropertySchema ?? []
57✔
161
        );
57✔
162

163
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
57✔
164
        // see AttributePropertyMetadataFactory
165
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
57✔
166
            // schema seems to have been declared by the user: do not override nor complete user value
167
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
18✔
168

169
            return;
18✔
170
        }
171

172
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
57✔
173

174
        // never override the following keys if at least one is already set
175
        // or if property has no type(s) defined
176
        // or if property schema is already fully defined (type=string + format || enum)
177
        $propertySchemaType = $propertySchema['type'] ?? false;
57✔
178

179
        $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType
57✔
180
            || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null));
57✔
181

182
        if (
183
            !$isUnknown && (
57✔
184
                [] === $types
57✔
185
                || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
57✔
186
                || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
57✔
187
                || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
57✔
188
            )
189
        ) {
190
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
42✔
191

192
            return;
42✔
193
        }
194

195
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
196
        // complete property schema with resource reference ($ref) only if it's related to an object
197
        $version = $schema->getVersion();
54✔
198
        $subSchema = new Schema($version);
54✔
199
        $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
54✔
200

201
        foreach ($types as $type) {
54✔
202
            // 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
203
            if ($typeFromFactory = $this->typeFactory?->getType($type, 'jsonschema', $propertyMetadata->isReadableLink(), $serializerContext)) {
54✔
204
                $propertySchema = $typeFromFactory;
18✔
205
                break;
18✔
206
            }
207

208
            $isCollection = $type->isCollection();
51✔
209
            if ($isCollection) {
51✔
210
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
42✔
211
            } else {
212
                $valueType = $type;
51✔
213
            }
214

215
            $className = $valueType?->getClassName();
51✔
216
            if (null === $className) {
51✔
217
                continue;
51✔
218
            }
219

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

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

231
            if ($type->isNullable()) {
21✔
232
                $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']];
15✔
233
            } else {
234
                $propertySchema['$ref'] = $subSchema['$ref'];
21✔
235
            }
236

237
            unset($propertySchema['type']);
21✔
238
            break;
21✔
239
        }
240

241
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
54✔
242
    }
243

244
    private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string
245
    {
246
        if ($operation) {
84✔
247
            $prefix = $operation->getShortName();
84✔
248
        }
249

250
        if (!isset($prefix)) {
84✔
251
            $prefix = (new \ReflectionClass($className))->getShortName();
54✔
252
        }
253

254
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
84✔
255
            $shortName = $this->getShortClassName($inputOrOutputClass);
18✔
256
            $prefix .= '.'.$shortName;
18✔
257
        }
258

259
        if (isset($this->distinctFormats[$format])) {
84✔
260
            // JSON is the default, and so isn't included in the definition name
261
            $prefix .= '.'.$format;
78✔
262
        }
263

264
        $definitionName = $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null;
84✔
265
        if ($definitionName) {
84✔
266
            $name = sprintf('%s-%s', $prefix, $definitionName);
×
267
        } else {
268
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
84✔
269
            $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
84✔
270
        }
271

272
        return $this->encodeDefinitionName($name);
84✔
273
    }
274

275
    private function encodeDefinitionName(string $name): string
276
    {
277
        return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
84✔
278
    }
279

280
    private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, Operation $operation = null, array $serializerContext = null): ?array
281
    {
282
        if (!$this->isResourceClass($className)) {
84✔
283
            return [
24✔
284
                null,
24✔
285
                $serializerContext ?? [],
24✔
286
                [],
24✔
287
                $className,
24✔
288
            ];
24✔
289
        }
290

291
        $forceSubschema = $serializerContext[self::FORCE_SUBSCHEMA] ?? false;
84✔
292
        if (null === $operation) {
84✔
293
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
57✔
294
            try {
295
                $operation = $resourceMetadataCollection->getOperation();
57✔
296
            } catch (OperationNotFoundException $e) {
×
297
                $operation = new HttpOperation();
×
298
            }
299
            if ($operation->getShortName() === $this->getShortClassName($className) && $forceSubschema) {
57✔
300
                $operation = new HttpOperation();
27✔
301
            }
302

303
            $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
57✔
304
        } else {
305
            // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
306
            if (!$operation->getClass()) {
51✔
307
                $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
21✔
308

309
                if ($operation->getName()) {
21✔
310
                    $operation = $resourceMetadataCollection->getOperation($operation->getName());
12✔
311
                } else {
312
                    $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
9✔
313
                }
314
            }
315
        }
316

317
        $inputOrOutput = ['class' => $className];
84✔
318
        $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
84✔
319
        $outputClass = $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
84✔
320

321
        if (null === $outputClass) {
84✔
322
            // input or output disabled
323
            return null;
15✔
324
        }
325

326
        return [
84✔
327
            $operation,
84✔
328
            $serializerContext ?? $this->getSerializerContext($operation, $type),
84✔
329
            $this->getValidationGroups($operation),
84✔
330
            $outputClass,
84✔
331
        ];
84✔
332
    }
333

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

344
                if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
60✔
345
                    $operation = $op;
21✔
346
                    break 2;
21✔
347
                }
348
            }
349
        }
350

351
        return $operation;
60✔
352
    }
353

354
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
355
    {
356
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
78✔
357
    }
358

359
    private function getValidationGroups(Operation $operation): array
360
    {
361
        $groups = $operation->getValidationContext()['groups'] ?? [];
84✔
362

363
        return \is_array($groups) ? $groups : [$groups];
84✔
364
    }
365

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

376
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
84✔
377
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
378
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
24✔
379
        }
380

381
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
84✔
382
            $options['normalization_groups'] = $normalizationGroups;
24✔
383
        }
384

385
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
84✔
386
            $options['denormalization_groups'] = $denormalizationGroups;
21✔
387
        }
388

389
        if ($validationGroups) {
84✔
390
            $options['validation_groups'] = $validationGroups;
×
391
        }
392

393
        return $options;
84✔
394
    }
395

396
    private function getShortClassName(string $fullyQualifiedName): string
397
    {
398
        $parts = explode('\\', $fullyQualifiedName);
57✔
399

400
        return end($parts);
57✔
401
    }
402
}
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