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

api-platform / core / 6159399700

12 Sep 2023 12:26PM UTC coverage: 37.094% (+0.02%) from 37.077%
6159399700

push

github

soyuka
fix(jsonschema): allow embed resources

19 of 19 new or added lines in 2 files covered. (100.0%)

10098 of 27223 relevant lines covered (37.09%)

20.05 hits per line

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

89.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\Metadata\ApiProperty;
17
use ApiPlatform\Metadata\CollectionOperationInterface;
18
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
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\Resource\ResourceMetadataCollection;
25
use ApiPlatform\Metadata\ResourceClassResolverInterface;
26
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
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
    // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
41
    public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
42
    public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
43

44
    public function __construct(?TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
45
    {
46
        if ($typeFactory) {
120✔
47
            trigger_deprecation('api-platform/core', '3.2', sprintf('The "%s" is not needed anymore and will not be used anymore.', TypeFactoryInterface::class));
×
48
        }
49

50
        $this->resourceMetadataFactory = $resourceMetadataFactory;
120✔
51
        $this->resourceClassResolver = $resourceClassResolver;
120✔
52
    }
53

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

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

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

75
        [$operation, $serializerContext, $validationGroups, $inputOrOutputClass] = $metadata;
72✔
76

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

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

85
        if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
72✔
86
            return $schema;
×
87
        }
88

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

99
        $definitions = $schema->getDefinitions();
72✔
100
        if (isset($definitions[$definitionName])) {
72✔
101
            // Already computed
102
            return $schema;
9✔
103
        }
104

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

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

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

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

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

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

141
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format);
45✔
142
        }
143

144
        return $schema;
72✔
145
    }
146

147
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format): void
148
    {
149
        $version = $schema->getVersion();
45✔
150
        if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) {
45✔
151
            $additionalPropertySchema = $propertyMetadata->getOpenapiContext();
9✔
152
        } else {
153
            $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext();
36✔
154
        }
155

156
        $propertySchema = array_merge(
45✔
157
            $propertyMetadata->getSchema() ?? [],
45✔
158
            $additionalPropertySchema ?? []
45✔
159
        );
45✔
160

161
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
45✔
162

163
        // never override the following keys if at least one is already set
164
        // or if property has no type(s) defined
165
        // or if property schema is already fully defined (type=string + format || enum)
166
        $propertySchemaType = $propertySchema['type'] ?? false;
45✔
167

168
        $isUnknown = 'array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null);
45✔
169

170
        if (
171
            !$isUnknown && (
45✔
172
                [] === $types
45✔
173
                || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
45✔
174
                || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
45✔
175
                || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
45✔
176
            )
177
        ) {
178
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
33✔
179

180
            return;
33✔
181
        }
182

183
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
184
        // complete property schema with resource reference ($ref) only if it's related to an object
185
        $version = $schema->getVersion();
42✔
186
        $subSchema = new Schema($version);
42✔
187
        $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
42✔
188

189
        foreach ($types as $type) {
42✔
190
            $isCollection = $type->isCollection();
42✔
191
            if ($isCollection) {
42✔
192
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
33✔
193
            } else {
194
                $valueType = $type;
42✔
195
            }
196

197
            $className = $valueType?->getClassName();
42✔
198
            if (null === $className || !$this->isResourceClass($className)) {
42✔
199
                continue;
42✔
200
            }
201

202
            $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
18✔
203
            if ($isCollection) {
18✔
204
                $propertySchema['items']['$ref'] = $subSchema['$ref'];
18✔
205
                unset($propertySchema['items']['type']);
18✔
206
                break;
18✔
207
            }
208

209
            if ($type->isNullable()) {
9✔
210
                $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']];
9✔
211
            } else {
212
                $propertySchema['$ref'] = $subSchema['$ref'];
×
213
            }
214

215
            unset($propertySchema['type']);
9✔
216
            break;
9✔
217
        }
218

219
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
42✔
220
    }
221

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

228
        if (!isset($prefix)) {
72✔
229
            $prefix = (new \ReflectionClass($className))->getShortName();
27✔
230
        }
231

232
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
72✔
233
            $parts = explode('\\', $inputOrOutputClass);
12✔
234
            $shortName = end($parts);
12✔
235
            $prefix .= '.'.$shortName;
12✔
236
        }
237

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

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

251
        return $this->encodeDefinitionName($name);
72✔
252
    }
253

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

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

270
        if (null === $operation) {
72✔
271
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
45✔
272
            try {
273
                $operation = $resourceMetadataCollection->getOperation();
45✔
274
            } catch (OperationNotFoundException $e) {
×
275
                $operation = new HttpOperation();
×
276
            }
277

278
            $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
45✔
279
        } else {
280
            // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
281
            if (!$operation->getClass()) {
45✔
282
                $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
21✔
283

284
                if ($operation->getName()) {
21✔
285
                    $operation = $resourceMetadataCollection->getOperation($operation->getName());
12✔
286
                } else {
287
                    $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
9✔
288
                }
289
            }
290
        }
291

292
        $inputOrOutput = ['class' => $className];
72✔
293
        $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
72✔
294
        $outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
72✔
295

296
        if (null === $outputClass) {
72✔
297
            // input or output disabled
298
            return null;
9✔
299
        }
300

301
        return [
72✔
302
            $operation,
72✔
303
            $serializerContext ?? $this->getSerializerContext($operation, $type),
72✔
304
            $this->getValidationGroups($operation),
72✔
305
            $outputClass,
72✔
306
        ];
72✔
307
    }
308

309
    private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
310
    {
311
        // Find the operation and use the first one that matches criterias
312
        foreach ($resourceMetadataCollection as $resourceMetadata) {
48✔
313
            foreach ($resourceMetadata->getOperations() ?? [] as $op) {
48✔
314
                if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
48✔
315
                    $operation = $op;
×
316
                    break 2;
×
317
                }
318

319
                if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
48✔
320
                    $operation = $op;
6✔
321
                    break 2;
6✔
322
                }
323
            }
324
        }
325

326
        return $operation;
48✔
327
    }
328

329
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
330
    {
331
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
69✔
332
    }
333

334
    private function getValidationGroups(Operation $operation): array
335
    {
336
        $groups = $operation->getValidationContext()['groups'] ?? [];
72✔
337

338
        return \is_array($groups) ? $groups : [$groups];
72✔
339
    }
340

341
    /**
342
     * Gets the options for the property name collection / property metadata factories.
343
     */
344
    private function getFactoryOptions(array $serializerContext, array $validationGroups, HttpOperation $operation = null): array
345
    {
346
        $options = [
72✔
347
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
348
            'enable_getter_setter_extraction' => true,
72✔
349
        ];
72✔
350

351
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
72✔
352
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
353
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
18✔
354
        }
355

356
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
72✔
357
            $options['normalization_groups'] = $normalizationGroups;
21✔
358
        }
359

360
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
72✔
361
            $options['denormalization_groups'] = $denormalizationGroups;
15✔
362
        }
363

364
        if ($validationGroups) {
72✔
365
            $options['validation_groups'] = $validationGroups;
×
366
        }
367

368
        return $options;
72✔
369
    }
370
}
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