Passed
Pull Request — master (#2983)
by Kévin
04:12
created

SchemaBuilder::buildSchema()   F

Complexity

Conditions 18
Paths 389

Size

Total Lines 61
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 35
c 0
b 0
f 0
nc 389
nop 7
dl 0
loc 61
rs 1.6708

How to fix   Long Method    Complexity   

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:

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\Exception\ResourceClassNotFoundException;
17
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
18
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
19
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
20
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
21
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
22
use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer;
23
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
24
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
25
26
/**
27
 * Builds an OpenAPI Schema, an extended subset of the JSON Schema Specification.
28
 *
29
 * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schema-object
30
 * @see https://tools.ietf.org/html/draft-wright-json-schema-01
31
 *
32
 * @author Kévin Dunglas <[email protected]>
33
 */
34
final class SchemaBuilder
35
{
36
    use TypeResolverTrait;
37
38
    private $resourceMetadataFactory;
39
    private $propertyNameCollectionFactory;
40
    private $propertyMetadataFactory;
41
    private $nameConverter;
42
43
    public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, NameConverterInterface $nameConverter = null)
44
    {
45
        $this->resourceMetadataFactory = $resourceMetadataFactory;
46
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
47
        $this->propertyMetadataFactory = $propertyMetadataFactory;
48
        $this->nameConverter = $nameConverter;
49
    }
50
51
    /**
52
     * @throws ResourceClassNotFoundException
53
     */
54
    public function buildSchema(string $resourceClass, bool $output = true, ?string $operationType = null, ?string $operationName = null, ?string $format = null, ?Schema $baseSchema = null, ?array $serializerContext = null): Schema
55
    {
56
        if (null === $metadata = $this->getMetadata($resourceClass, $output, $operationType, $operationName, $serializerContext)) {
57
            return $baseSchema ?? new Schema();
58
        }
59
60
        [$resourceMetadata, $serializerContext, $inputOrOutputClass] = $metadata;
61
62
        $definitionName = $this->buildDefinitionName($resourceClass, $output, $operationType, $operationName, $format, $serializerContext);
63
        if (null === $baseSchema || null === $rootDefinitionName = $baseSchema->getRootDefinitionId()) {
64
            $rootDefinitionName = $definitionName;
65
        }
66
67
        if ($baseSchema) {
68
            $version = $baseSchema->getVersion();
69
            $schema = new Schema($version, $baseSchema->getSchema(), $rootDefinitionName, $baseSchema->getDefinitions());
70
        } else {
71
            $version = Schema::VERSION_JSON_SCHEMA;
72
            $schema = new Schema($version, null, $rootDefinitionName);
73
        }
74
75
        $definitions = $schema->getDefinitions();
76
        if (isset($definitions[$definitionName])) {
77
            // Already computed
78
            return $schema;
79
        }
80
81
        $definition = new \ArrayObject(['type' => 'object']);
82
        $definitions[$definitionName] = $definition;
83
        if (null !== $description = $resourceMetadata->getDescription()) {
84
            $definition['description'] = $description;
85
        }
86
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
87
        if (
88
            (null !== $operationType && null !== $operationName && null !== $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', null, true)) ||
89
            null !== $resourceMetadata->getAttribute('deprecation_reason', null)
90
        ) {
91
            $definition['deprecated'] = true;
92
        }
93
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
94
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
95
        if (null !== $iri = $resourceMetadata->getIri()) {
96
            $definition['externalDocs'] = ['url' => $iri];
97
        }
98
99
        $options = isset($serializerContext[AbstractNormalizer::GROUPS]) ? ['serializer_groups' => $serializerContext[AbstractNormalizer::GROUPS]] : [];
100
        foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) {
101
            $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName);
102
            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...
103
                continue;
104
            }
105
106
            $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

106
            $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...
107
            if ($propertyMetadata->isRequired()) {
108
                $definition['required'][] = $normalizedPropertyName;
109
            }
110
111
            $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $version);
0 ignored issues
show
Unused Code introduced by
The call to ApiPlatform\Core\JsonSch...::buildPropertySchema() has too many arguments starting with $version. ( Ignorable by Annotation )

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

111
            $this->/** @scrutinizer ignore-call */ 
112
                   buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $version);

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...
112
        }
113
114
        return $schema;
115
    }
116
117
    private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, PropertyMetadata $propertyMetadata, array $serializerContext): void
118
    {
119
        $version = $schema->getVersion();
120
        switch ($version) {
121
            case Schema::VERSION_SWAGGER:
122
                $basePropertySchemaAttribute = 'swagger_context';
123
                break;
124
            case Schema::VERSION_OPENAPI:
125
                $basePropertySchemaAttribute = 'openapi_context';
126
                break;
127
            default:
128
                $basePropertySchemaAttribute = 'json_schema_context';
129
        }
130
131
        $propertySchema = $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? [];
132
        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...
133
            $propertySchema['readOnly'] = true;
134
        }
135
        if (false === $propertyMetadata->isReadable()) {
136
            $propertySchema['writeOnly'] = true;
137
        }
138
        if (null !== $description = $propertyMetadata->getDescription()) {
139
            $propertySchema['description'] = $description;
140
        }
141
        // see https://github.com/json-schema-org/json-schema-spec/pull/737
142
        if (null !== $propertyMetadata->getAttribute('deprecation_reason')) {
143
            $propertySchema['deprecated'] = true;
144
        }
145
        // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it
146
        // See https://json-schema.org/latest/json-schema-core.html#rfc.section.6.4
147
        if (null !== $iri = $propertyMetadata->getIri()) {
148
            $propertySchema['externalDocs'] = ['url' => $iri];
149
        }
150
151
        $valueSchema = [];
152
        if (null !== $type = $propertyMetadata->getType()) {
153
            $isCollection = $type->isCollection();
154
            if (null === $valueType = $isCollection ? $type->getCollectionValueType() : $type) {
155
                $builtinType = 'string';
156
                $className = null;
157
            } else {
158
                $builtinType = $valueType->getBuiltinType();
159
                $className = $valueType->getClassName();
160
            }
161
162
            $valueSchema = $this->getType($builtinType, $isCollection, $className, $propertyMetadata->isReadableLink(), $serializerContext, $schema);
163
        }
164
165
        $propertySchema = new \ArrayObject($propertySchema + $valueSchema);
166
        if (DocumentationNormalizer::OPENAPI_VERSION === $version) {
167
            $schema['components']['schemas'][$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
168
169
            return;
170
        }
171
172
        $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema;
173
    }
174
175
    private function buildDefinitionName(string $resourceClass, bool $output = true, ?string $operationType = null, ?string $operationName = null, ?string $format = null, ?array $serializerContext = null, string $version = DocumentationNormalizer::OPENAPI_VERSION): ?string
0 ignored issues
show
Unused Code introduced by
The parameter $format is not used and could be removed. ( Ignorable by Annotation )

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

175
    private function buildDefinitionName(string $resourceClass, bool $output = true, ?string $operationType = null, ?string $operationName = null, /** @scrutinizer ignore-unused */ ?string $format = null, ?array $serializerContext = null, string $version = DocumentationNormalizer::OPENAPI_VERSION): ?string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $version is not used and could be removed. ( Ignorable by Annotation )

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

175
    private function buildDefinitionName(string $resourceClass, bool $output = true, ?string $operationType = null, ?string $operationName = null, ?string $format = null, ?array $serializerContext = null, /** @scrutinizer ignore-unused */ string $version = DocumentationNormalizer::OPENAPI_VERSION): ?string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
176
    {
177
        if (null === $metadata = $this->getMetadata($resourceClass, $output, $operationType, $operationName, $serializerContext)) {
178
            return null;
179
        }
180
        [$resourceMetadata, $serializerContext, $inputOrOutputClass] = $metadata;
181
182
        $prefix = $resourceMetadata->getShortName();
183
        if (null !== $inputOrOutputClass && $resourceClass !== $inputOrOutputClass) {
184
            $prefix .= ':'.md5($inputOrOutputClass);
185
        }
186
187
        /*if (null !== $format) {
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
188
            $prefix .= ':'.$format;
189
        }*/
190
191
        if (isset($serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME])) {
192
            $name = sprintf('%s-%s', $prefix, $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME]);
193
        } else {
194
            $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []);
195
            $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...
196
        }
197
198
        return $name;
199
    }
200
201
    private function getMetadata(string $resourceClass, bool $output, ?string $operationType, ?string $operationName, ?array $serializerContext): ?array
202
    {
203
        $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
204
        $attribute = $output ? 'output' : 'input';
205
        if (null === $operationType || null === $operationName) {
206
            $inputOrOutput = $resourceMetadata->getAttribute($attribute, ['class' => $resourceClass]);
207
        } else {
208
            $inputOrOutput = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, ['class' => $resourceClass], true);
209
        }
210
211
        if (null === ($inputOrOutput['class'] ?? null)) {
212
            // input or output disabled
213
            return null;
214
        }
215
216
        return [
217
            $resourceMetadata,
218
            $serializerContext ?? $this->getSerializerContext($resourceMetadata, $output, $operationType, $operationName),
219
            $inputOrOutput['class'],
220
        ];
221
    }
222
223
    private function getSerializerContext(ResourceMetadata $resourceMetadata, bool $output, ?string $operationType, ?string $operationName): array
224
    {
225
        $attribute = $output ? 'normalization_context' : 'denormalization_context';
226
227
        if (null === $operationType || null === $operationName) {
228
            return $resourceMetadata->getAttribute($attribute, []);
229
        }
230
231
        return $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, [], true);
232
    }
233
}
234