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

api-platform / core / 7008679441

27 Nov 2023 05:33PM UTC coverage: 36.957% (-0.4%) from 37.348%
7008679441

push

github

web-flow
fix(jsonschema): indirect resource input schema (#6001)

Fixes #5998

A resource embedded in another class can be writable without having a
write operation (POST, PUT, PATCH).

2 of 3 new or added lines in 1 file covered. (66.67%)

1 existing line in 1 file now uncovered.

10178 of 27540 relevant lines covered (36.96%)

14.57 hits per line

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

94.55
/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();
80✔
71

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

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

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

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

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

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

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

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

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

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

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

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

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

169
            return;
14✔
170
        }
171

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

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

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

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

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

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

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

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

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

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

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

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

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

254
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
80✔
255
            $parts = explode('\\', $inputOrOutputClass);
14✔
256
            $shortName = end($parts);
14✔
257
            $prefix .= '.'.$shortName;
14✔
258
        }
259

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

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

273
        return $this->encodeDefinitionName($name);
80✔
274
    }
275

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

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

292
        if (null === $operation) {
80✔
293
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
53✔
294
            try {
295
                $operation = $resourceMetadataCollection->getOperation();
53✔
296
            } catch (OperationNotFoundException $e) {
×
297
                $operation = new HttpOperation();
×
298
            }
299

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

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

314
        $inputOrOutput = ['class' => $className];
80✔
315
        $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
80✔
316
        $outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
80✔
317

318
        if (null === $outputClass) {
80✔
319
            // input or output disabled
320
            return null;
11✔
321
        }
322

323
        return [
80✔
324
            $operation,
80✔
325
            $serializerContext ?? $this->getSerializerContext($operation, $type),
80✔
326
            $this->getValidationGroups($operation),
80✔
327
            $outputClass,
80✔
328
        ];
80✔
329
    }
330

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

341
                if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
56✔
342
                    $operation = $op;
17✔
343
                    break 2;
17✔
344
                }
345
            }
346
        }
347

348
        return $operation;
56✔
349
    }
350

351
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
352
    {
353
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
74✔
354
    }
355

356
    private function getValidationGroups(Operation $operation): array
357
    {
358
        $groups = $operation->getValidationContext()['groups'] ?? [];
80✔
359

360
        return \is_array($groups) ? $groups : [$groups];
80✔
361
    }
362

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

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

378
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
80✔
379
            $options['normalization_groups'] = $normalizationGroups;
23✔
380
        }
381

382
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
80✔
383
            $options['denormalization_groups'] = $denormalizationGroups;
17✔
384
        }
385

386
        if ($validationGroups) {
80✔
387
            $options['validation_groups'] = $validationGroups;
×
388
        }
389

390
        return $options;
80✔
391
    }
392
}
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