Passed
Push — master ( 9897fc...f87053 )
by Pieter
02:43
created

SchemaGenerator::runCallbacks()   A

Complexity

Conditions 6
Paths 10

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 14
c 0
b 0
f 0
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\Discriminator;
6
use erasys\OpenApi\Spec\v3\Schema;
7
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
8
use Symfony\Component\PropertyInfo\Type;
9
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
10
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
11
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
12
use W2w\Lib\Apie\Core\ClassResourceConverter;
13
14
/**
15
 * Class that uses symfony/property-info and reflection to create a Schema instance of a class.
16
 */
17
class SchemaGenerator
18
{
19
    private const MAX_RECURSION = 2;
20
21
    /**
22
     * @var ClassMetadataFactory
23
     */
24
    private $classMetadataFactory;
25
26
    /**
27
     * @var PropertyInfoExtractor
28
     */
29
    private $propertyInfoExtractor;
30
31
    /**
32
     * @var ClassResourceConverter
33
     */
34
    private $converter;
35
36
    /**
37
     * @var NameConverterInterface
38
     */
39
    private $nameConverter;
40
41
    /**
42
     * @var Schema[]
43
     */
44
    private $alreadyDefined = [];
45
46
    /**
47
     * @var Schema[]
48
     */
49
    private $predefined = [];
50
51
    /**
52
     * @var callable[]
53
     */
54
    private $schemaCallbacks = [];
55
56
    /**
57
     * @var bool[]
58
     */
59
    private $building = [];
60
61
    /**
62
     * @param ClassMetadataFactory $classMetadataFactory
63
     * @param PropertyInfoExtractor $propertyInfoExtractor
64
     * @param ClassResourceConverter $converter
65
     * @param NameConverterInterface $nameConverter
66
     * @param callable[] $schemaCallbacks
67
     */
68
    public function __construct(
69
        ClassMetadataFactory $classMetadataFactory,
70
        PropertyInfoExtractor $propertyInfoExtractor,
71
        ClassResourceConverter $converter,
72
        NameConverterInterface $nameConverter,
73
        array $schemaCallbacks = []
74
    ) {
75
        $this->classMetadataFactory = $classMetadataFactory;
76
        $this->propertyInfoExtractor = $propertyInfoExtractor;
77
        $this->converter = $converter;
78
        $this->nameConverter = $nameConverter;
79
        $this->schemaCallbacks = $schemaCallbacks;
80
    }
81
82
    /**
83
     * Define a resource class and Schema manually.
84
     * @param string $resourceClass
85
     * @param Schema $schema
86
     * @return SchemaGenerator
87
     */
88
    public function defineSchemaForResource(string $resourceClass, Schema $schema): self
89
    {
90
        $this->predefined[$resourceClass] = $schema;
91
        $this->alreadyDefined = [];
92
93
        return $this;
94
    }
95
96
    /**
97
     * Define an OpenAPI discriminator spec for an interface or base class that have a discriminator column.
98
     *
99
     * @param string $resourceInterface
100
     * @param string $discriminatorColumn
101
     * @param array $subclasses
102
     * @param string $operation
103
     * @param string[] $groups
104
     * @return Schema
105
     */
106
    public function defineSchemaForPolymorphicObject(
107
        string $resourceInterface,
108
        string $discriminatorColumn,
109
        array $subclasses,
110
        string $operation = 'get',
111
        array $groups = ['get', 'read']
112
    ): Schema {
113
        $cacheKey = $this->getCacheKey($resourceInterface, $operation, $groups);
114
        /** @var Schema[] $subschemas */
115
        $subschemas = [];
116
        $discriminatorMapping = [];
117
        foreach ($subclasses as $keyValue => $subclass) {
118
            $subschemas[$subclass] = $discriminatorMapping[$keyValue] = $this->createSchema($subclass, $operation, $groups);
119
            $properties = $subschemas[$subclass]->properties;
120
            if (isset($properties[$discriminatorColumn])) {
121
                $properties[$discriminatorColumn]->default = $keyValue;
122
                $properties[$discriminatorColumn]->example = $keyValue;
123
            } else {
124
                $properties[$discriminatorColumn] = new Schema([
125
                    'type' => 'string',
126
                    'default' => $keyValue,
127
                    'example' => $keyValue
128
                ]);
129
            }
130
            $subschemas[$subclass]->properties = $properties;
131
        }
132
        $this->alreadyDefined[$cacheKey . ',0'] = new Schema([
133
            'type' => 'object',
134
            'properties' => [
135
                $discriminatorColumn => new Schema(['type' => 'string']),
136
            ],
137
            'oneOf' => array_values($subschemas),
138
            'discriminator' => new Discriminator($discriminatorColumn, $discriminatorMapping)
139
        ]);
140
        for ($i = 1; $i < self::MAX_RECURSION; $i++) {
141
            $this->alreadyDefined[$cacheKey . ',' . $i] = $this->alreadyDefined[$cacheKey . ',0'];
142
        }
143
        return $this->alreadyDefined[$cacheKey . ',0'];
144
    }
145
146
    /**
147
     * Creates a schema recursively.
148
     *
149
     * @param string $resourceClass
150
     * @param string $operation
151
     * @param string[] $groups
152
     * @param int $recursion
153
     * @return Schema
154
     */
155
    private function createSchemaRecursive(string $resourceClass, string $operation, array $groups, int $recursion = 0): Schema
156
    {
157
        $metaData = $this->classMetadataFactory->getMetadataFor($resourceClass);
158
        $cacheKey = $this->getCacheKey($resourceClass, $operation, $groups) . ',' . $recursion;
159
        if (isset($this->alreadyDefined[$cacheKey])) {
160
            return $this->alreadyDefined[$cacheKey];
161
        }
162
163
        foreach ($this->predefined as $className => $schema) {
164
            if (is_a($resourceClass, $className, true)) {
165
                $this->alreadyDefined[$cacheKey] = $schema;
166
167
                return $this->alreadyDefined[$cacheKey];
168
            }
169
        }
170
171
        if ($predefinedSchema = $this->runCallbacks($cacheKey, $resourceClass, $operation, $groups, $recursion)) {
172
            return $this->alreadyDefined[$cacheKey] = $predefinedSchema;
173
        }
174
175
        $name = $this->converter->normalize($resourceClass);
176
        $this->alreadyDefined[$cacheKey] = $schema = new Schema([
177
            'title'       => $name,
178
            'description' => $name . ' ' . $operation,
179
            'type'        => 'object',
180
        ]);
181
        if ($groups) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $groups of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
182
            $schema->description .= ' for groups ' . implode(', ', $groups);
183
        }
184
        $properties = [];
185
        foreach ($metaData->getAttributesMetadata() as $attributeMetadata) {
186
            $name = $attributeMetadata->getSerializedName() ?? $this->nameConverter->normalize($attributeMetadata->getName());
187
            if (!$this->isPropertyApplicable($resourceClass, $attributeMetadata, $operation, $groups)) {
188
                continue;
189
            }
190
            $properties[$name] = new Schema([
191
                'type'        => 'string',
192
                'nullable'    => true,
193
            ]);
194
            $types = $this->propertyInfoExtractor->getTypes($resourceClass, $attributeMetadata->getName()) ?? [];
195
            $type = reset($types);
196
            if ($type instanceof Type && ($recursion < (1 + self::MAX_RECURSION))) {
197
                $properties[$name] = $this->convertTypeToSchema($type, $operation, $groups, $recursion);
198
            }
199
            if (!$properties[$name]->description) {
200
                $properties[$name]->description = $this->propertyInfoExtractor->getShortDescription(
201
                    $resourceClass,
202
                    $attributeMetadata->getName()
203
                );
204
            }
205
        }
206
        $schema->properties = $properties;
207
        $this->alreadyDefined[$cacheKey] = $schema;
208
209
        return $schema;
210
    }
211
212
    /**
213
     * Iterate over a list of callbacks to see if they provide a schema for this resource class.
214
     *
215
     * @param string $cacheKey
216
     * @param string $resourceClass
217
     * @param string $operation
218
     * @param array $groups
219
     * @param int $recursion
220
     *
221
     * @return Schema|null
222
     */
223
    private function runCallbacks(string $cacheKey, string $resourceClass, string $operation, array $groups, int $recursion): ?Schema
224
    {
225
        if (!empty($this->building[$cacheKey])) {
226
            return null;
227
        }
228
        $this->building[$cacheKey] = true;
229
        try {
230
            // specifically defined: just call it.
231
            if (isset($this->schemaCallbacks[$resourceClass])) {
232
                return $this->schemaCallbacks[$resourceClass]($resourceClass, $operation, $groups, $recursion, $this);
233
            }
234
            foreach ($this->schemaCallbacks as $classDeclaration => $callable) {
235
                if (is_a($resourceClass, $classDeclaration, true)) {
236
                    $res = $callable($resourceClass, $operation, $groups, $recursion, $this);
237
                    if ($res instanceof Schema) {
238
                        return $res;
239
                    }
240
                }
241
            }
242
            return null;
243
        } finally {
244
            unset($this->building[$cacheKey]);
245
        }
246
    }
247
248
    /**
249
     * Convert Type into Schema.
250
     *
251
     * @param Type $type
252
     * @param string $operation
253
     * @param string[] $groups
254
     * @param int $recursion
255
     *
256
     * @return Schema
257
     */
258
    private function convertTypeToSchema(Type $type, string $operation, array $groups, int $recursion): Schema
259
    {
260
        $propertySchema = new Schema([
261
            'type'        => 'string',
262
            'nullable'    => true,
263
        ]);
264
        $propertySchema->type = $this->translateType($type->getBuiltinType());
265
        if (!$type->isNullable()) {
266
            $propertySchema->nullable = false;
267
        }
268
        if ($type->isCollection()) {
269
            $propertySchema->type = 'array';
270
            $propertySchema->items = new Schema([
271
                'oneOf' => [
272
                    new Schema(['type' => 'string', 'nullable' => true]),
273
                    new Schema(['type' => 'integer']),
274
                    new Schema(['type' => 'boolean']),
275
                ],
276
            ]);
277
            $arrayType = $type->getCollectionValueType();
278
            if ($arrayType) {
279
                if ($arrayType->getClassName()) {
280
                    $propertySchema->items = $this->createSchemaRecursive($arrayType->getClassName(), $operation, $groups, $recursion + 1);
281
                } elseif ($arrayType->getBuiltinType()) {
282
                    $type = $this->translateType($arrayType->getBuiltinType());
283
                    $propertySchema->items = new Schema([
284
                        'type' => $type,
285
                        'format' => ($type === 'number') ? $arrayType->getBuiltinType() : null,
286
                    ]);
287
                }
288
            }
289
            return $propertySchema;
290
        }
291
        if ($propertySchema->type === 'number') {
292
            $propertySchema->format = $type->getBuiltinType();
293
        }
294
        $className = $type->getClassName();
295
        if ('object' === $type->getBuiltinType() && $recursion < self::MAX_RECURSION && !is_null($className)) {
296
            return $this->createSchemaRecursive($className, $operation, $groups, $recursion + 1);
297
        }
298
        return $propertySchema;
299
    }
300
301
    /**
302
     * Returns true if a property is applicable for a specific operation and a specific serialization group.
303
     *
304
     * @param string $resourceClass
305
     * @param AttributeMetadataInterface $attributeMetadata
306
     * @param string $operation
307
     * @param string[] $groups
308
     * @return bool
309
     */
310
    private function isPropertyApplicable(string $resourceClass, AttributeMetadataInterface $attributeMetadata, string $operation, array $groups): bool
311
    {
312
        if (!array_intersect($attributeMetadata->getGroups(), $groups)) {
313
            return false;
314
        }
315
        switch ($operation) {
316
            case 'put':
317
                return $this->propertyInfoExtractor->isReadable($resourceClass, $attributeMetadata->getName())
318
                    && $this->propertyInfoExtractor->isWritable($resourceClass, $attributeMetadata->getName());
319
            case 'get':
320
                return (bool) $this->propertyInfoExtractor->isReadable($resourceClass, $attributeMetadata->getName());
321
            case 'post':
322
                return $this->propertyInfoExtractor->isWritable($resourceClass, $attributeMetadata->getName())
323
                    || $this->propertyInfoExtractor->isInitializable($resourceClass, $attributeMetadata->getName());
324
        }
325
326
        // @codeCoverageIgnoreStart
327
        return true;
328
        // @codeCoverageIgnoreEnd
329
    }
330
331
    /**
332
     * Returns a Schema for a resource class, operation and serialization group tuple.
333
     *
334
     * @param string $resourceClass
335
     * @param string $operation
336
     * @param string[] $groups
337
     * @return Schema
338
     */
339
    public function createSchema(string $resourceClass, string $operation, array $groups): Schema
340
    {
341
        return $this->createSchemaRecursive($resourceClass, $operation, $groups);
342
    }
343
344
    /**
345
     * Creates a unique cache key to be used for already defined schemas for performance reasons.
346
     *
347
     * @param string $resourceClass
348
     * @param string $operation
349
     * @param string[] $groups
350
     * @return string
351
     */
352
    private function getCacheKey(string $resourceClass, string $operation, array $groups)
353
    {
354
        return $resourceClass . ',' . $operation . ',' . implode(', ', $groups);
355
    }
356
357
    /**
358
     * Returns OpenApi property type for scalars.
359
     *
360
     * @param string $type
361
     * @return string
362
     */
363
    private function translateType(string $type): string
364
    {
365
        switch ($type) {
366
            case 'int': return 'integer';
367
            case 'bool': return 'boolean';
368
            case 'float': return 'number';
369
            case 'double': return 'number';
370
        }
371
372
        return $type;
373
    }
374
}
375