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

api-platform / core / 7142557150

08 Dec 2023 02:28PM UTC coverage: 36.003% (-1.4%) from 37.36%
7142557150

push

github

web-flow
fix(jsonld): remove link to ApiDocumentation when doc is disabled (#6029)

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

2297 existing lines in 182 files now uncovered.

9992 of 27753 relevant lines covered (36.0%)

147.09 hits per line

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

91.72
/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) {
2,052✔
48
            $this->typeFactory = $typeFactory;
2,052✔
49
        }
50

51
        $this->resourceMetadataFactory = $resourceMetadataFactory;
2,052✔
52
        $this->resourceClassResolver = $resourceClassResolver;
2,052✔
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;
2,052✔
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();
36✔
71

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

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

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

81
        $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET';
36✔
82
        if (!$operation) {
36✔
83
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
36✔
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)) {
36✔
88
            return $schema;
×
89
        }
90

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

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

107
        /** @var \ArrayObject<string, mixed> $definition */
108
        $definition = new \ArrayObject(['type' => 'object']);
36✔
109
        $definitions[$definitionName] = $definition;
36✔
110
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
36✔
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)) {
36✔
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()) {
36✔
120
            $definition['deprecated'] = true;
36✔
121
        } else {
122
            $definition['deprecated'] = false;
36✔
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)) {
36✔
128
            $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
36✔
129
        }
130

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

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

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

146
        return $schema;
36✔
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();
36✔
152
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
36✔
153
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
36✔
154
        } else {
UNCOV
155
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
×
156
        }
157

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

163
        $extraProperties = $propertyMetadata->getExtraProperties() ?? [];
36✔
164
        // see AttributePropertyMetadataFactory
165
        if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) {
36✔
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);
36✔
168

169
            return;
36✔
170
        }
171

172
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
36✔
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;
36✔
178

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

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

192
            return;
36✔
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();
36✔
198
        $subSchema = new Schema($version);
36✔
199
        $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
36✔
200

201
        foreach ($types as $type) {
36✔
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)) {
36✔
204
                $propertySchema = $typeFromFactory;
36✔
205
                break;
36✔
206
            }
207

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

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

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

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

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

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

241
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
36✔
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) {
36✔
247
            $prefix = $operation->getShortName();
36✔
248
        }
249

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

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

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

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

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

275
    private function encodeDefinitionName(string $name): string
276
    {
277
        return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
36✔
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)) {
36✔
283
            return [
36✔
284
                null,
36✔
285
                $serializerContext ?? [],
36✔
286
                [],
36✔
287
                $className,
36✔
288
            ];
36✔
289
        }
290

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

303
            $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
36✔
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()) {
36✔
UNCOV
307
                $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
×
308

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

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

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

326
        return [
36✔
327
            $operation,
36✔
328
            $serializerContext ?? $this->getSerializerContext($operation, $type),
36✔
329
            $this->getValidationGroups($operation),
36✔
330
            $outputClass,
36✔
331
        ];
36✔
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) {
36✔
338
            foreach ($resourceMetadata->getOperations() ?? [] as $op) {
36✔
339
                if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
36✔
340
                    $operation = $op;
×
341
                    break 2;
×
342
                }
343

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

351
        return $operation;
36✔
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() ?? []);
36✔
357
    }
358

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

363
        return \is_array($groups) ? $groups : [$groups];
36✔
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 = [
36✔
372
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
373
            'enable_getter_setter_extraction' => true,
36✔
374
        ];
36✔
375

376
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
36✔
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];
36✔
379
        }
380

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

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

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

393
        return $options;
36✔
394
    }
395

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

400
        return end($parts);
36✔
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