SchemaFactory::buildSchema()   F
last analyzed

Complexity

Conditions 35
Paths 4952

Size

Total Lines 81
Code Lines 47

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 35
eloc 47
c 2
b 0
f 0
nc 4952
nop 8
dl 0
loc 81
rs 0

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[email protected]>
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\Core\JsonSchema;
15
16
use ApiPlatform\Core\Api\OperationType;
17
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
18
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
19
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
20
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
21
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
22
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
23
use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer;
24
use ApiPlatform\Core\Util\ResourceClassInfoTrait;
25
use Symfony\Component\PropertyInfo\Type;
26
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
27
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
28
29
/**
30
 * {@inheritdoc}
31
 *
32
 * @experimental
33
 *
34
 * @author Kévin Dunglas <[email protected]>
35
 */
36
final class SchemaFactory implements SchemaFactoryInterface
37
{
38
    use ResourceClassInfoTrait;
39
40
    private $typeFactory;
41
    private $propertyNameCollectionFactory;
42
    private $propertyMetadataFactory;
43
    private $nameConverter;
44
    private $distinctFormats = [];
45
46
    public function __construct(TypeFactoryInterface $typeFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
47
    {
48
        $this->typeFactory = $typeFactory;
49
        $this->resourceMetadataFactory = $resourceMetadataFactory;
50
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
51
        $this->propertyMetadataFactory = $propertyMetadataFactory;
52
        $this->nameConverter = $nameConverter;
53
        $this->resourceClassResolver = $resourceClassResolver;
54
    }
55
56
    /**
57
     * When added to the list, the given format will lead to the creation of a new definition.
58
     *
59
     * @internal
60
     */
61
    public function addDistinctFormat(string $format): void
62
    {
63
        $this->distinctFormats[$format] = true;
64
    }
65
66
    /**
67
     * {@inheritdoc}
68
     */
69
    public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
70
    {
71
        $schema = $schema ?? new Schema();
72
        if (null === $metadata = $this->getMetadata($className, $type, $operationType, $operationName, $serializerContext)) {
73
            return $schema;
74
        }
75
        [$resourceMetadata, $serializerContext, $inputOrOutputClass] = $metadata;
76
77
        if (null === $resourceMetadata && (null !== $operationType || null !== $operationName)) {
78
            throw new \LogicException('The $operationType and $operationName arguments must be null for non-resource class.');
79
        }
80
81
        $version = $schema->getVersion();
82
        $definitionName = $this->buildDefinitionName($className, $format, $type, $operationType, $operationName, $serializerContext);
83
84
        if (null === $operationType || null === $operationName) {
85
            $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET';
86
        } else {
87
            $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method');
88
        }
89
90
        if (Schema::TYPE_OUTPUT !== $type && !\in_array($method, ['POST', 'PATCH', 'PUT'], true)) {
91
            return $schema;
92
        }
93
94
        if (!isset($schema['$ref']) && !isset($schema['type'])) {
95
            $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName;
96
97
            $method = null !== $operationType && null !== $operationName ? $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET') : 'GET';
98
            if ($forceCollection || (OperationType::COLLECTION === $operationType && 'POST' !== $method)) {
99
                $schema['type'] = 'array';
100
                $schema['items'] = ['$ref' => $ref];
101
            } else {
102
                $schema['$ref'] = $ref;
103
            }
104
        }
105
106
        $definitions = $schema->getDefinitions();
107
        if (isset($definitions[$definitionName])) {
108
            // Already computed
109
            return $schema;
110
        }
111
112
        $definition = new \ArrayObject(['type' => 'object']);
113
        $definitions[$definitionName] = $definition;
114
        if (null !== $resourceMetadata && null !== $description = $resourceMetadata->getDescription()) {
115
            $definition['description'] = $description;
116
        }
117
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
118
        if (
119
            Schema::VERSION_SWAGGER !== $version &&
120
            null !== $resourceMetadata &&
121
            (
122
                (null !== $operationType && null !== $operationName && null !== $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', null, true)) ||
123
                null !== $resourceMetadata->getAttribute('deprecation_reason', null)
124
            )
125
        ) {
126
            $definition['deprecated'] = true;
127
        }
128
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
129
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
130
        if (null !== $resourceMetadata && null !== $iri = $resourceMetadata->getIri()) {
131
            $definition['externalDocs'] = ['url' => $iri];
132
        }
133
134
        $options = isset($serializerContext[AbstractNormalizer::GROUPS]) ? ['serializer_groups' => (array) $serializerContext[AbstractNormalizer::GROUPS]] : [];
135
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
136
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName);
137
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isWritable() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
Bug Best Practice introduced by
The expression $propertyMetadata->isReadable() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
138
                continue;
139
            }
140
141
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $inputOrOutputClass. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

141
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->/** @scrutinizer ignore-call */ normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName;

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
142
            if ($propertyMetadata->isRequired()) {
143
                $definition['required'][] = $normalizedPropertyName;
144
            }
145
146
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format);
147
        }
148
149
        return $schema;
150
    }
151
152
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, PropertyMetadata $propertyMetadata, array $serializerContext, string $format): void
153
    {
154
        $version = $schema->getVersion();
155
        $swagger = false;
156
        $propertySchema = $propertyMetadata->getSchema() ?? [];
157
158
        switch ($version) {
159
            case Schema::VERSION_SWAGGER:
160
                $swagger = true;
161
                $basePropertySchemaAttribute = 'swagger_context';
162
                break;
163
            case Schema::VERSION_OPENAPI:
164
                $basePropertySchemaAttribute = 'openapi_context';
165
                break;
166
            default:
167
                $basePropertySchemaAttribute = 'json_schema_context';
168
        }
169
170
        $propertySchema = array_merge(
171
            $propertySchema,
172
            $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? []
173
        );
174
175
        if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $propertyMetadata->isInitializable() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
176
            $propertySchema['readOnly'] = true;
177
        }
178
        if (!$swagger && false === $propertyMetadata->isReadable()) {
179
            $propertySchema['writeOnly'] = true;
180
        }
181
        if (null !== $description = $propertyMetadata->getDescription()) {
182
            $propertySchema['description'] = $description;
183
        }
184
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
185
        if (!$swagger && null !== $propertyMetadata->getAttribute('deprecation_reason')) {
186
            $propertySchema['deprecated'] = true;
187
        }
188
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
189
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
190
        if (null !== $iri = $propertyMetadata->getIri()) {
191
            $propertySchema['externalDocs'] = ['url' => $iri];
192
        }
193
194
        if (!isset($propertySchema['default']) && null !== $default = $propertyMetadata->getDefault()) {
195
            $propertySchema['default'] = $default;
196
        }
197
198
        if (!isset($propertySchema['example']) && null !== $example = $propertyMetadata->getExample()) {
199
            $propertySchema['example'] = $example;
200
        }
201
202
        if (!isset($propertySchema['example']) && isset($propertySchema['default'])) {
203
            $propertySchema['example'] = $propertySchema['default'];
204
        }
205
206
        $valueSchema = [];
207
        if (null !== $type = $propertyMetadata->getType()) {
208
            $isCollection = $type->isCollection();
209
            if (null === $valueType = $isCollection ? $type->getCollectionValueType() : $type) {
210
                $builtinType = 'string';
211
                $className = null;
212
            } else {
213
                $builtinType = $valueType->getBuiltinType();
214
                $className = $valueType->getClassName();
215
            }
216
217
            $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema);
218
        }
219
220
        $propertySchema = new \ArrayObject($propertySchema + $valueSchema);
221
        if (DocumentationNormalizer::OPENAPI_VERSION === $version) {
222
            $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
223
224
            return;
225
        }
226
227
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
228
    }
229
230
    private function buildDefinitionName(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?array $serializerContext = null): string
231
    {
232
        [$resourceMetadata, $serializerContext, $inputOrOutputClass] = $this->getMetadata($className, $type, $operationType, $operationName, $serializerContext);
233
234
        $prefix = $resourceMetadata ? $resourceMetadata->getShortName() : (new \ReflectionClass($className))->getShortName();
235
        if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) {
236
            $parts = explode('\\', $inputOrOutputClass);
237
            $shortName = end($parts);
238
            $prefix .= ':'.$shortName;
239
        }
240
241
        if (isset($this->distinctFormats[$format])) {
242
            // JSON is the default, and so isn't included in the definition name
243
            $prefix .= ':'.$format;
244
        }
245
246
        if (isset($serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME])) {
247
            $name = sprintf('%s-%s', $prefix, $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME]);
248
        } else {
249
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
250
            $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix;
0 ignored issues
show
introduced by
$groups is an empty array, thus is always false.
Loading history...
251
        }
252
253
        return $name;
254
    }
255
256
    private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName, ?array $serializerContext): ?array
257
    {
258
        if (!$this->isResourceClass($className)) {
259
            return [
260
                null,
261
                $serializerContext ?? [],
262
                $className,
263
            ];
264
        }
265
266
        $resourceMetadata = $this->resourceMetadataFactory->create($className);
0 ignored issues
show
Bug introduced by
The method create() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

266
        /** @scrutinizer ignore-call */ 
267
        $resourceMetadata = $this->resourceMetadataFactory->create($className);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
267
        $attribute = Schema::TYPE_OUTPUT === $type ? 'output' : 'input';
268
        if (null === $operationType || null === $operationName) {
269
            $inputOrOutput = $resourceMetadata->getAttribute($attribute, ['class' => $className]);
270
        } else {
271
            $inputOrOutput = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, ['class' => $className], true);
272
        }
273
274
        if (null === ($inputOrOutput['class'] ?? null)) {
275
            // input or output disabled
276
            return null;
277
        }
278
279
        return [
280
            $resourceMetadata,
281
            $serializerContext ?? $this->getSerializerContext($resourceMetadata, $type, $operationType, $operationName),
282
            $inputOrOutput['class'],
283
        ];
284
    }
285
286
    private function getSerializerContext(ResourceMetadata $resourceMetadata, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName): array
287
    {
288
        $attribute = Schema::TYPE_OUTPUT === $type ? 'normalization_context' : 'denormalization_context';
289
290
        if (null === $operationType || null === $operationName) {
291
            return $resourceMetadata->getAttribute($attribute, []);
292
        }
293
294
        return $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, [], true);
295
    }
296
}
297