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

api-platform / core / 6067528200

04 Sep 2023 12:12AM UTC coverage: 36.875% (-21.9%) from 58.794%
6067528200

Pull #5791

github

web-flow
Merge 64157e578 into d09cfc9d2
Pull Request #5791: fix: strip down any sql function name

3096 of 3096 new or added lines in 205 files covered. (100.0%)

9926 of 26918 relevant lines covered (36.87%)

6.5 hits per line

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

89.04
/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) {
117✔
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;
117✔
51
        $this->resourceClassResolver = $resourceClassResolver;
117✔
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;
117✔
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();
69✔
70

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

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

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

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

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

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

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

105
        /** @var \ArrayObject<string, mixed> $definition */
106
        $definition = new \ArrayObject(['type' => 'object']);
69✔
107
        $definitions[$definitionName] = $definition;
69✔
108
        $definition['description'] = $operation ? ($operation->getDescription() ?? '') : '';
69✔
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)) {
69✔
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()) {
69✔
118
            $definition['deprecated'] = true;
9✔
119
        } else {
120
            $definition['deprecated'] = false;
69✔
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)) {
69✔
126
            $definition['externalDocs'] = ['url' => $operation->getTypes()[0]];
9✔
127
        }
128

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

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

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

144
        return $schema;
69✔
145
    }
146

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

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

161
        $types = $propertyMetadata->getBuiltinTypes() ?? [];
42✔
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;
42✔
167
        if ([] === $types
42✔
168
            || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false)
42✔
169
            || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType)
42✔
170
            || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false)
42✔
171
        ) {
172
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
30✔
173

174
            return;
30✔
175
        }
176

177
        // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
178
        // complete property schema with resource reference ($ref) only if it's related to an object
179

180
        $version = $schema->getVersion();
39✔
181
        $subSchema = new Schema($version);
39✔
182
        $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema
39✔
183

184
        foreach ($types as $type) {
39✔
185
            if ($type->isCollection()) {
39✔
186
                $valueType = $type->getCollectionValueTypes()[0] ?? null;
27✔
187
            } else {
188
                $valueType = $type;
39✔
189
            }
190

191
            $className = $valueType?->getClassName();
39✔
192
            if (null === $className || !$this->isResourceClass($className)) {
39✔
193
                continue;
39✔
194
            }
195

196
            $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false);
12✔
197
            $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']];
12✔
198
            // prevent "type" and "anyOf" conflict
199
            unset($propertySchema['type']);
12✔
200
            break;
12✔
201
        }
202

203
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema);
39✔
204
    }
205

206
    private function buildDefinitionName(string $className, string $format = 'json', string $inputOrOutputClass = null, Operation $operation = null, array $serializerContext = null): string
207
    {
208
        if ($operation) {
69✔
209
            $prefix = $operation->getShortName();
69✔
210
        }
211

212
        if (!isset($prefix)) {
69✔
213
            $prefix = (new \ReflectionClass($className))->getShortName();
27✔
214
        }
215

216
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
69✔
217
            $parts = explode('\\', $inputOrOutputClass);
12✔
218
            $shortName = end($parts);
12✔
219
            $prefix .= '.'.$shortName;
12✔
220
        }
221

222
        if (isset($this->distinctFormats[$format])) {
69✔
223
            // JSON is the default, and so isn't included in the definition name
224
            $prefix .= '.'.$format;
63✔
225
        }
226

227
        $definitionName = $serializerContext[self::OPENAPI_DEFINITION_NAME] ?? null;
69✔
228
        if ($definitionName) {
69✔
229
            $name = sprintf('%s-%s', $prefix, $definitionName);
×
230
        } else {
231
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
69✔
232
            $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
69✔
233
        }
234

235
        return $this->encodeDefinitionName($name);
69✔
236
    }
237

238
    private function encodeDefinitionName(string $name): string
239
    {
240
        return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name);
69✔
241
    }
242

243
    private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, Operation $operation = null, array $serializerContext = null): ?array
244
    {
245
        if (!$this->isResourceClass($className)) {
69✔
246
            return [
×
247
                null,
×
248
                $serializerContext ?? [],
×
249
                [],
×
250
                $className,
×
251
            ];
×
252
        }
253

254
        if (null === $operation) {
69✔
255
            $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
39✔
256
            try {
257
                $operation = $resourceMetadataCollection->getOperation();
39✔
258
            } catch (OperationNotFoundException $e) {
×
259
                $operation = new HttpOperation();
×
260
            }
261

262
            $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
39✔
263
        } else {
264
            // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise
265
            if (!$operation->getClass()) {
45✔
266
                $resourceMetadataCollection = $this->resourceMetadataFactory->create($className);
21✔
267

268
                if ($operation->getName()) {
21✔
269
                    $operation = $resourceMetadataCollection->getOperation($operation->getName());
12✔
270
                } else {
271
                    $operation = $this->findOperationForType($resourceMetadataCollection, $type, $operation);
9✔
272
                }
273
            }
274
        }
275

276
        $inputOrOutput = ['class' => $className];
69✔
277
        $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
69✔
278
        $outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
69✔
279

280
        if (null === $outputClass) {
69✔
281
            // input or output disabled
282
            return null;
9✔
283
        }
284

285
        return [
69✔
286
            $operation,
69✔
287
            $serializerContext ?? $this->getSerializerContext($operation, $type),
69✔
288
            $this->getValidationGroups($operation),
69✔
289
            $outputClass,
69✔
290
        ];
69✔
291
    }
292

293
    private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
294
    {
295
        // Find the operation and use the first one that matches criterias
296
        foreach ($resourceMetadataCollection as $resourceMetadata) {
42✔
297
            foreach ($resourceMetadata->getOperations() ?? [] as $op) {
42✔
298
                if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) {
42✔
299
                    $operation = $op;
×
300
                    break 2;
×
301
                }
302

303
                if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) {
42✔
304
                    $operation = $op;
3✔
305
                    break 2;
3✔
306
                }
307
            }
308
        }
309

310
        return $operation;
42✔
311
    }
312

313
    private function getSerializerContext(Operation $operation, string $type = Schema::TYPE_OUTPUT): array
314
    {
315
        return Schema::TYPE_OUTPUT === $type ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
66✔
316
    }
317

318
    private function getValidationGroups(Operation $operation): array
319
    {
320
        $groups = $operation->getValidationContext()['groups'] ?? [];
69✔
321

322
        return \is_array($groups) ? $groups : [$groups];
69✔
323
    }
324

325
    /**
326
     * Gets the options for the property name collection / property metadata factories.
327
     */
328
    private function getFactoryOptions(array $serializerContext, array $validationGroups, HttpOperation $operation = null): array
329
    {
330
        $options = [
69✔
331
            /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */
332
            'enable_getter_setter_extraction' => true,
69✔
333
        ];
69✔
334

335
        if (isset($serializerContext[AbstractNormalizer::GROUPS])) {
69✔
336
            /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
337
            $options['serializer_groups'] = (array) $serializerContext[AbstractNormalizer::GROUPS];
15✔
338
        }
339

340
        if ($operation && ($normalizationGroups = $operation->getNormalizationContext()['groups'] ?? null)) {
69✔
341
            $options['normalization_groups'] = $normalizationGroups;
15✔
342
        }
343

344
        if ($operation && ($denormalizationGroups = $operation->getDenormalizationContext()['groups'] ?? null)) {
69✔
345
            $options['denormalization_groups'] = $denormalizationGroups;
12✔
346
        }
347

348
        if ($validationGroups) {
69✔
349
            $options['validation_groups'] = $validationGroups;
×
350
        }
351

352
        return $options;
69✔
353
    }
354
}
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