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

api-platform / core / 3713134090

pending completion
3713134090

Pull #5254

github

GitHub
Merge b2ec54b3c into ac711530f
Pull Request #5254: [OpenApi] Add ApiResource::openapi and deprecate openapiContext

197 of 197 new or added lines in 5 files covered. (100.0%)

7493 of 12362 relevant lines covered (60.61%)

67.56 hits per line

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

91.03
/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\Api\ResourceClassResolverInterface;
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\OpenApi\Factory\OpenApiFactory;
25
use ApiPlatform\Util\ResourceClassInfoTrait;
26
use Symfony\Component\PropertyInfo\Type;
27
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
28
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
29

30
/**
31
 * {@inheritdoc}
32
 *
33
 * @author Kévin Dunglas <dunglas@gmail.com>
34
 */
35
final class SchemaFactory implements SchemaFactoryInterface
36
{
37
    use ResourceClassInfoTrait;
38
    private array $distinctFormats = [];
39

40
    public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
41
    {
42
        $this->resourceMetadataFactory = $resourceMetadataFactory;
8✔
43
        $this->resourceClassResolver = $resourceClassResolver;
8✔
44
    }
45

46
    /**
47
     * When added to the list, the given format will lead to the creation of a new definition.
48
     *
49
     * @internal
50
     */
51
    public function addDistinctFormat(string $format): void
52
    {
53
        $this->distinctFormats[$format] = true;
8✔
54
    }
55

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

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

67
        [$operation, $serializerContext, $validationGroups, $inputOrOutputClass] = $metadata;
4✔
68

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

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

77
        if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
4✔
78
            return $schema;
×
79
        }
80

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

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

97
        /** @var \ArrayObject<string, mixed> $definition */
98
        $definition = new \ArrayObject(['type' => 'object']);
4✔
99
        $definitions[$definitionName] = $definition;
4✔
100
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
4✔
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)) {
4✔
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()) {
4✔
110
            $definition['deprecated'] = true;
4✔
111
        } else {
112
            $definition['deprecated'] = false;
4✔
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)) {
4✔
118
            $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
4✔
119
        }
120

121
        $options = $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null);
4✔
122
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
4✔
123
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options);
4✔
124
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
4✔
125
                continue;
×
126
            }
127

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

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

136
        return $schema;
4✔
137
    }
138

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

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

154
        if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
4✔
155
            $propertySchema['readOnly'] = true;
4✔
156
        }
157
        if (!$swagger && false === $propertyMetadata->isReadable()) {
4✔
158
            $propertySchema['writeOnly'] = true;
4✔
159
        }
160
        if (null !== $description = $propertyMetadata->getDescription()) {
4✔
161
            $propertySchema['description'] = $description;
4✔
162
        }
163

164
        $deprecationReason = $propertyMetadata->getDeprecationReason();
4✔
165

166
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
167
        if (!$swagger && null !== $deprecationReason) {
4✔
168
            $propertySchema['deprecated'] = true;
4✔
169
        }
170
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
171
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
172
        $iri = $propertyMetadata->getTypes()[0] ?? null;
4✔
173
        if (null !== $iri) {
4✔
174
            $propertySchema['externalDocs'] = ['url' => $iri];
4✔
175
        }
176

177
        // TODO: 3.0 support multiple types
178
        $type = $propertyMetadata->getBuiltinTypes()[0] ?? null;
4✔
179

180
        if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) {
4✔
181
            if ($default instanceof \BackedEnum) {
4✔
182
                $default = $default->value;
4✔
183
            }
184
            $propertySchema['default'] = $default;
4✔
185
        }
186

187
        if (!isset($propertySchema['example']) && !empty($example = $propertyMetadata->getExample())) {
4✔
188
            $propertySchema['example'] = $example;
×
189
        }
190

191
        if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
4✔
192
            $propertySchema['example'] = $propertySchema['default'];
4✔
193
        }
194

195
        $valueSchema = [];
4✔
196
        if (null !== $type) {
4✔
197
            if ($isCollection = $type->isCollection()) {
4✔
198
                $keyType = $type->getCollectionKeyTypes()[0] ?? null;
4✔
199
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
4✔
200
            } else {
201
                $keyType = null;
4✔
202
                $valueType = $type;
4✔
203
            }
204

205
            if (null === $valueType) {
4✔
206
                $builtinType = 'string';
4✔
207
                $className = null;
4✔
208
            } else {
209
                $builtinType = $valueType->getBuiltinType();
4✔
210
                $className = $valueType->getClassName();
4✔
211
            }
212

213
            $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema);
4✔
214
        }
215

216
        if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) {
4✔
217
            $propertySchema = new \ArrayObject($propertySchema);
×
218
        } else {
219
            $propertySchema = new \ArrayObject($propertySchema + $valueSchema);
4✔
220
        }
221
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
4✔
222
    }
223

224
    private function buildDefinitionName(string $className, string $format = 'json', ?string $inputOrOutputClass = null, Operation $operation = null, ?array $serializerContext = null): string
225
    {
226
        if ($operation) {
4✔
227
            $prefix = $operation->getShortName();
4✔
228
        }
229

230
        if (!isset($prefix)) {
4✔
231
            $prefix = (new \ReflectionClass($className))->getShortName();
4✔
232
        }
233

234
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
4✔
235
            $parts = explode('\\', $inputOrOutputClass);
4✔
236
            $shortName = end($parts);
4✔
237
            $prefix .= '.'.$shortName;
4✔
238
        }
239

240
        if (isset($this->distinctFormats[$format])) {
4✔
241
            // JSON is the default, and so isn't included in the definition name
242
            $prefix .= '.'.$format;
4✔
243
        }
244

245
        $definitionName = $serializerContext[OpenApiFactory::OPENAPI_DEFINITION_NAME] ?? null;
4✔
246
        if ($definitionName) {
4✔
247
            $name = sprintf('%s-%s', $prefix, $definitionName);
×
248
        } else {
249
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
4✔
250
            $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
4✔
251
        }
252

253
        return $this->encodeDefinitionName($name);
4✔
254
    }
255

256
    private function encodeDefinitionName(string $name): string
257
    {
258
        return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
4✔
259
    }
260

261
    private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?array $serializerContext = null): ?array
262
    {
263
        if (!$this->isResourceClass($className)) {
4✔
264
            return [
4✔
265
                null,
4✔
266
                $serializerContext ?? [],
4✔
267
                [],
4✔
268
                $className,
4✔
269
            ];
4✔
270
        }
271

272
        // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
273
        if (!$operation || !$operation->getClass()) {
4✔
274
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
4✔
275

276
            if ($operation && $operation->getName()) {
4✔
277
                $operation = $resourceMetadataCollection->getOperation($operation->getName());
×
278
            } else {
279
                // Guess the operation and use the first one that matches criterias
280
                foreach ($resourceMetadataCollection as $resourceMetadata) {
4✔
281
                    foreach ($resourceMetadata->getOperations() ?? [] as $op) {
4✔
282
                        if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
4✔
283
                            $operation = $op;
×
284
                            break 2;
×
285
                        }
286

287
                        if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
4✔
288
                            $operation = $op;
×
289
                            break 2;
×
290
                        }
291

292
                        if (!$operation) {
4✔
293
                            $operation = new HttpOperation();
4✔
294
                        }
295
                    }
296
                }
297
            }
298
        }
299

300
        $inputOrOutput = ['class' => $className];
4✔
301

302
        if ($operation) {
4✔
303
            $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
4✔
304
        }
305

306
        if (null === ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null)) {
4✔
307
            // input or output disabled
308
            return null;
4✔
309
        }
310

311
        if (!$operation) {
4✔
312
            return [$operation, $serializerContext ?? [], [], $inputOrOutput['class'] ?? $inputOrOutput->class];
×
313
        }
314

315
        return [
4✔
316
            $operation,
4✔
317
            $serializerContext ?? $this->getSerializerContext($operation, $type),
4✔
318
            $this->getValidationGroups($operation),
4✔
319
            $inputOrOutput['class'] ?? $inputOrOutput->class,
4✔
320
        ];
4✔
321
    }
322

323
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
324
    {
325
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
4✔
326
    }
327

328
    private function getValidationGroups(Operation $operation): array
329
    {
330
        $groups = $operation->getValidationContext()['groups'] ?? [];
4✔
331

332
        return \is_array($groups) ? $groups : [$groups];
4✔
333
    }
334

335
    /**
336
     * Gets the options for the property name collection / property metadata factories.
337
     */
338
    private function getFactoryOptions(array $serializerContext, array $validationGroups, ?HttpOperation $operation = null): array
339
    {
340
        $options = [
4✔
341
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
342
            'enable_getter_setter_extraction' => true,
4✔
343
        ];
4✔
344

345
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
4✔
346
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
347
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
4✔
348
        }
349

350
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
4✔
351
            $options['normalization_groups'] = $normalizationGroups;
4✔
352
        }
353

354
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
4✔
355
            $options['denormalization_groups'] = $denormalizationGroups;
4✔
356
        }
357

358
        if ($validationGroups) {
4✔
359
            $options['validation_groups'] = $validationGroups;
×
360
        }
361

362
        return $options;
4✔
363
    }
364
}
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