Passed
Branch 4.0 (912257)
by Pieter
02:10
created

OpenApiSchemaGenerator::runCallbacks()   A

Complexity

Conditions 6
Paths 10

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 6
eloc 14
c 1
b 0
f 1
nc 10
nop 5
dl 0
loc 22
rs 9.2222
1
<?php
2
3
namespace W2w\Lib\Apie\OpenApiSchema;
4
5
use erasys\OpenApi\Spec\v3\Schema;
6
use ReflectionClass;
7
use Symfony\Component\PropertyInfo\Type;
8
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
9
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
10
use W2w\Lib\Apie\PluginInterfaces\DynamicSchemaInterface;
11
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\FilteredObjectAccess;
12
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\ObjectAccessInterface;
13
14
class OpenApiSchemaGenerator
15
{
16
    private const MAX_RECURSION = 3;
17
18
    /**
19
     * @var DynamicSchemaInterface[]
20
     */
21
    private $schemaGenerators;
22
23
    /**
24
     * @var Schema[]
25
     */
26
    private $predefined = [];
27
28
    /**
29
     * @var Schema[]
30
     */
31
    private $alreadyDefined;
32
33
    /**
34
     * @var bool[]
35
     */
36
    private $building = [];
37
    /**
38
     * @var ObjectAccessInterface
39
     */
40
    private $objectAccess;
41
42
    /**
43
     * @var NameConverterInterface
44
     */
45
    private $nameConverter;
46
47
    /**
48
     * @var ClassMetadataFactory
0 ignored issues
show
Bug introduced by
The type W2w\Lib\Apie\OpenApiSchema\ClassMetadataFactory was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
49
     */
50
    private $classMetadataFactory;
51
52
    /**
53
     * @param DynamicSchemaInterface $schemaGenerators
54
     */
55
    public function __construct(
56
        array $schemaGenerators,
57
        ObjectAccessInterface $objectAccess,
58
        ClassMetadataFactoryInterface $classMetadataFactory,
59
        NameConverterInterface $nameConverter
60
    ) {
61
        $this->schemaGenerators = $schemaGenerators;
62
        $this->objectAccess = $objectAccess;
63
        $this->nameConverter = $nameConverter;
64
        $this->classMetadataFactory = $classMetadataFactory;
0 ignored issues
show
Documentation Bug introduced by
It seems like $classMetadataFactory of type Symfony\Component\Serial...etadataFactoryInterface is incompatible with the declared type W2w\Lib\Apie\OpenApiSchema\ClassMetadataFactory of property $classMetadataFactory.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
65
    }
66
67
    /**
68
     * Define a resource class and Schema manually.
69
     * @param string $resourceClass
70
     * @param Schema $schema
71
     * @return OpenApiSchemaGenerator
72
     */
73
    public function defineSchemaForResource(string $resourceClass, Schema $schema): OpenApiSchemaGenerator
74
    {
75
        $this->predefined[$resourceClass] = $schema;
76
        $this->alreadyDefined = [];
77
78
        return $this;
79
    }
80
81
    public function createSchema(string $resourceClass, string $operation, array $groups): Schema
82
    {
83
        return $this->createSchemaRecursive($resourceClass, $operation, $groups);
84
    }
85
86
    /**
87
     * Creates a unique cache key to be used for already defined schemas for performance reasons.
88
     *
89
     * @param string $resourceClass
90
     * @param string $operation
91
     * @param string[] $groups
92
     * @return string
93
     */
94
    private function getCacheKey(string $resourceClass, string $operation, array $groups)
95
    {
96
        return $resourceClass . ',' . $operation . ',' . implode(', ', $groups);
97
    }
98
99
    /**
100
     * Iterate over a list of callbacks to see if they provide a schema for this resource class.
101
     *
102
     * @param string $cacheKey
103
     * @param string $resourceClass
104
     * @param string $operation
105
     * @param array $groups
106
     * @param int $recursion
107
     *
108
     * @return Schema|null
109
     */
110
    private function runCallbacks(string $cacheKey, string $resourceClass, string $operation, array $groups, int $recursion): ?Schema
111
    {
112
        if (!empty($this->building[$cacheKey])) {
113
            return null;
114
        }
115
        $this->building[$cacheKey] = true;
116
        try {
117
            // specifically defined: just call it.
118
            if (isset($this->schemaGenerators[$resourceClass])) {
119
                return $this->schemaGenerators[$resourceClass]($resourceClass, $operation, $groups, $recursion, $this);
120
            }
121
            foreach ($this->schemaGenerators as $classDeclaration => $callable) {
122
                if (is_a($resourceClass, $classDeclaration, true)) {
123
                    $res = $callable($resourceClass, $operation, $groups, $recursion, $this);
124
                    if ($res instanceof Schema) {
125
                        return $res;
126
                    }
127
                }
128
            }
129
            return null;
130
        } finally {
131
            unset($this->building[$cacheKey]);
132
        }
133
    }
134
135
    private function createSchemaRecursive(string $resourceClass, string $operation, array $groups, int $recursion = 0): Schema
136
    {
137
        $cacheKey = $this->getCacheKey($resourceClass, $operation, $groups) . ',' . $recursion;
138
        if (isset($this->alreadyDefined[$cacheKey])) {
139
            return $this->alreadyDefined[$cacheKey];
140
        }
141
142
        foreach ($this->predefined as $className => $schema) {
143
            if (is_a($resourceClass, $className, true)) {
144
                $this->alreadyDefined[$cacheKey] = $schema;
145
146
                return $this->alreadyDefined[$cacheKey];
147
            }
148
        }
149
150
        if ($predefinedSchema = $this->runCallbacks($cacheKey, $resourceClass, $operation, $groups, $recursion)) {
151
            return $this->alreadyDefined[$cacheKey] = $predefinedSchema;
152
        }
153
        $refl = new ReflectionClass($resourceClass);
154
        $schema = new Schema([
155
            'type' => 'object',
156
            'properties' => [],
157
            'title' => $refl->getShortName(),
158
            'description' => $refl->getShortName() . ' ' . $operation . ' for groups ' . implode(', ', $groups),
159
        ]);
160
        if ($recursion > self::MAX_RECURSION) {
161
            return $this->alreadyDefined[$cacheKey] = $schema;
162
        }
163
        $objectAccess = $this->filterObjectAccess($this->objectAccess, $resourceClass, $groups);
164
        switch ($operation) {
165
            case 'post':
166
                $constructorArgs = $objectAccess->getConstructorArguments($refl);
167
                foreach ($constructorArgs as $key => $type) {
168
                    $fieldName = $this->nameConverter->normalize($key, $resourceClass);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $resourceClass. ( Ignorable by Annotation )

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

168
                    /** @scrutinizer ignore-call */ 
169
                    $fieldName = $this->nameConverter->normalize($key, $resourceClass);

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...
169
                    $schema->properties[$fieldName] = $this->convertTypeToSchema($type, $operation, $groups, $recursion);
170
                    $description = $objectAccess->getDescription($refl, $fieldName, false);
171
                    if ($description) {
172
                        $schema->properties[$fieldName]->description = $description;
173
                    }
174
                }
175
                // FALLTHROUGH
176
            case 'put':
177
                $setterFields = $objectAccess->getSetterFields($refl);
178
                foreach ($setterFields as $setterField) {
179
                    $fieldName = $this->nameConverter->normalize($setterField, $resourceClass);
180
                    $schema->properties[$fieldName] = $this->convertTypesToSchema($objectAccess->getSetterTypes($refl, $setterField), $operation, $groups, $recursion);
181
                    $description = $objectAccess->getDescription($refl, $fieldName, false);
182
                    if ($description) {
183
                        $schema->properties[$fieldName]->description = $description;
184
                    }
185
                }
186
                break;
187
            case 'get':
188
                $getterFields = $objectAccess->getGetterFields($refl);
189
                foreach ($getterFields as $getterField) {
190
                    $fieldName = $this->nameConverter->normalize($getterField, $resourceClass);
191
                    $schema->properties[$fieldName] = $this->convertTypesToSchema($objectAccess->getGetterTypes($refl, $getterField), $operation, $groups, $recursion);
192
                    $description = $objectAccess->getDescription($refl, $fieldName, true);
193
                    if ($description) {
194
                        $schema->properties[$fieldName]->description = $description;
195
                    }
196
                }
197
                break;
198
        }
199
        return $this->alreadyDefined[$cacheKey] = $schema;
200
    }
201
202
    private function filterObjectAccess(ObjectAccessInterface $objectAccess, string $className, array $groups): ObjectAccessInterface
203
    {
204
        $allowedAttributes = [];
205
        foreach ($this->classMetadataFactory->getMetadataFor($className)->getAttributesMetadata() as $attributeMetadata) {
206
            $name = $attributeMetadata->getName();
207
            if (array_intersect($attributeMetadata->getGroups(), $groups)) {
208
                $allowedAttributes[] = $name;
209
            }
210
        }
211
212
        return new FilteredObjectAccess($objectAccess, $allowedAttributes);
213
    }
214
215
    private function convertTypesToSchema(array $types, string $operation, array $groups, int $recursion = 0): Schema
216
    {
217
        if (empty($types)) {
218
            return new Schema(['type' => 'object', 'additionalProperties' => true]);
219
        }
220
        $type = reset($types);
221
        return $this->convertTypeToSchema($type, $operation, $groups, $recursion + 1);
222
    }
223
224
    /**
225
     * Returns OpenApi property type for scalars.
226
     *
227
     * @param string $type
228
     * @return string
229
     */
230
    private function translateType(string $type): string
231
    {
232
        switch ($type) {
233
            case 'int': return 'integer';
234
            case 'bool': return 'boolean';
235
            case 'float': return 'number';
236
            case 'double': return 'number';
237
        }
238
239
        return $type;
240
    }
241
242
    protected function convertTypeToSchema(?Type $type, string $operation, array $groups, int $recursion): Schema
243
    {
244
        if ($type === null) {
245
            return new Schema(['type' => 'object', 'additionalProperties' => true]);
246
        }
247
        if ($type && $type->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT && $type->getClassName()) {
248
            return $this->createSchemaRecursive($type->getClassName(), $operation, $groups, $recursion + 1);
249
        }
250
        $propertySchema = new Schema([
251
            'type'        => 'string',
252
            'nullable'    => true,
253
        ]);
254
        $propertySchema->type = $this->translateType($type->getBuiltinType());
255
        if (!$type->isNullable()) {
256
            $propertySchema->nullable = false;
257
        }
258
        if ($type->isCollection()) {
259
            $propertySchema->type = 'array';
260
            $propertySchema->items = new Schema([
261
                'oneOf' => [
262
                    new Schema(['type' => 'string', 'nullable' => true]),
263
                    new Schema(['type' => 'integer']),
264
                    new Schema(['type' => 'boolean']),
265
                ],
266
            ]);
267
            $arrayType = $type->getCollectionValueType();
268
            if ($arrayType) {
269
                if ($arrayType->getClassName()) {
270
                    $propertySchema->items = $this->createSchemaRecursive($arrayType->getClassName(), $operation, $groups, $recursion + 1);
271
                } elseif ($arrayType->getBuiltinType()) {
272
                    $schemaType = $this->translateType($arrayType->getBuiltinType());
273
                    $propertySchema->items = new Schema([
274
                        'type' => $schemaType,
275
                        'format' => ($schemaType === 'number') ? $arrayType->getBuiltinType() : null,
276
                    ]);
277
                }
278
            }
279
            return $propertySchema;
280
        }
281
        if ($propertySchema->type === 'number') {
282
            $propertySchema->format = $type->getBuiltinType();
283
        }
284
        $className = $type->getClassName();
285
        if (Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() && $recursion < self::MAX_RECURSION && !is_null($className)) {
286
            return $this->createSchemaRecursive($className, $operation, $groups, $recursion + 1);
287
        }
288
        return $propertySchema;
289
    }
290
}
291