Passed
Branch v4 (c30cb0)
by Pieter
03:55
created

defineSchemaForPolymorphicObject()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 38
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 24
c 0
b 0
f 0
nc 6
nop 5
dl 0
loc 38
rs 9.536
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 ReflectionClass;
8
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
9
use Symfony\Component\PropertyInfo\Type;
10
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
11
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
12
use W2w\Lib\Apie\Core\ClassResourceConverter;
13
use W2w\Lib\Apie\PluginInterfaces\DynamicSchemaInterface;
14
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\FilteredObjectAccess;
15
use W2w\Lib\ApieObjectAccessNormalizer\ObjectAccess\ObjectAccessInterface;
16
17
class OpenApiSchemaGenerator
18
{
19
    private const MAX_RECURSION = 2;
20
21
    /**
22
     * @var DynamicSchemaInterface[]
23
     */
24
    private $schemaGenerators;
25
26
    /**
27
     * @var Schema[]
28
     */
29
    private $predefined = [];
30
31
    /**
32
     * @var bool[]
33
     */
34
    private $building = [];
35
    /**
36
     * @var ObjectAccessInterface
37
     */
38
    private $objectAccess;
39
40
    /**
41
     * @var NameConverterInterface
42
     */
43
    private $nameConverter;
44
45
    /**
46
     * @var ClassMetadataFactoryInterface
47
     */
48
    private $classMetadataFactory;
49
50
    /**
51
     * @var Schema[]
52
     */
53
    protected $alreadyDefined = [];
54
55
    /**
56
     * @var int
57
     */
58
    protected $oldRecursion = -1;
59
60
    /**
61
     * @param DynamicSchemaInterface[] $schemaGenerators
62
     * @param ObjectAccessInterface $objectAccess
63
     * @param ClassMetadataFactoryInterface $classMetadataFactory
64
     * @param NameConverterInterface $nameConverter
65
     */
66
    public function __construct(
67
        array $schemaGenerators,
68
        ObjectAccessInterface $objectAccess,
69
        ClassMetadataFactoryInterface $classMetadataFactory,
70
        NameConverterInterface $nameConverter
71
    ) {
72
        $this->schemaGenerators = $schemaGenerators;
73
        $this->objectAccess = $objectAccess;
74
        $this->nameConverter = $nameConverter;
75
        $this->classMetadataFactory = $classMetadataFactory;
76
    }
77
78
    /**
79
     * Define a resource class and Schema manually.
80
     * @param string $resourceClass
81
     * @param Schema $schema
82
     * @return OpenApiSchemaGenerator
83
     */
84
    public function defineSchemaForResource(string $resourceClass, Schema $schema)
85
    {
86
        $this->predefined[$resourceClass] = $schema;
87
        $this->alreadyDefined = [];
88
89
        return $this;
90
    }
91
92
    /**
93
     * Creates a Schema for  specific resource class.
94
     *
95
     * @param string $resourceClass
96
     * @param string $operation
97
     * @param array $groups
98
     * @return Schema
99
     */
100
    public function createSchema(string $resourceClass, string $operation, array $groups): Schema
101
    {
102
        return unserialize(serialize($this->createSchemaRecursive($resourceClass, $operation, $groups, $this->oldRecursion + 1)));
103
    }
104
105
    /**
106
     * Creates a unique cache key to be used for already defined schemas for performance reasons.
107
     *
108
     * @param string $resourceClass
109
     * @param string $operation
110
     * @param string[] $groups
111
     * @return string
112
     */
113
    private function getCacheKey(string $resourceClass, string $operation, array $groups)
114
    {
115
        return $resourceClass . ',' . $operation . ',' . implode(', ', $groups);
116
    }
117
118
    /**
119
     * Iterate over a list of callbacks to see if they provide a schema for this resource class.
120
     *
121
     * @param string $cacheKey
122
     * @param string $resourceClass
123
     * @param string $operation
124
     * @param array $groups
125
     * @param int $recursion
126
     *
127
     * @return Schema|null
128
     */
129
    private function runCallbacks(string $cacheKey, string $resourceClass, string $operation, array $groups, int $recursion): ?Schema
130
    {
131
        if (!empty($this->building[$cacheKey])) {
132
            return null;
133
        }
134
        $this->building[$cacheKey] = true;
135
        $oldValue = $this->oldRecursion;
136
        try {
137
            // specifically defined: just call it.
138
            if (isset($this->schemaGenerators[$resourceClass])) {
139
                return $this->schemaGenerators[$resourceClass]($resourceClass, $operation, $groups, $recursion, $this);
140
            }
141
            foreach ($this->schemaGenerators as $classDeclaration => $callable) {
142
                if (is_a($resourceClass, $classDeclaration, true)) {
143
                    $res = $callable($resourceClass, $operation, $groups, $recursion, $this);
144
                    if ($res instanceof Schema) {
145
                        return $res;
146
                    }
147
                }
148
            }
149
            return null;
150
        } finally {
151
            $this->oldRecursion = $oldValue;
152
            unset($this->building[$cacheKey]);
153
        }
154
    }
155
156
    private function createSchemaRecursive(string $resourceClass, string $operation, array $groups, int $recursion = 0): Schema
157
    {
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
        $refl = new ReflectionClass($resourceClass);
175
        $schema = new Schema([
176
            'type' => 'object',
177
            'properties' => [],
178
            'title' => $refl->getShortName(),
179
            'description' => $refl->getShortName() . ' ' . $operation . ' for groups ' . implode(', ', $groups),
180
        ]);
181
        // if definition is an interface or abstract base class it is possible that it has additional properties.
182
        if ($refl->isAbstract() || $refl->isInterface()) {
183
            $schema->additionalProperties = true;
184
        }
185
        if ($recursion > self::MAX_RECURSION) {
186
            $schema->properties = null;
187
            $schema->additionalProperties = true;
188
            return $this->alreadyDefined[$cacheKey] = $schema;
189
        }
190
        $objectAccess = $this->filterObjectAccess($this->objectAccess, $resourceClass, $groups);
191
        switch ($operation) {
192
            case 'post':
193
                $constructorArgs = $objectAccess->getConstructorArguments($refl);
194
                foreach ($constructorArgs as $key => $type) {
195
                    /** @scrutinizer ignore-call */
196
                    $fieldName = $this->nameConverter->normalize($key, $resourceClass);
197
                    $schema->properties[$fieldName] = $this->convertTypeToSchema($type, $operation, $groups, $recursion);
198
                    $description = $objectAccess->getDescription($refl, $key, false);
199
                    if ($description) {
200
                        $schema->properties[$fieldName]->description = $description;
201
                    }
202
                }
203
                // FALLTHROUGH
204
            case 'put':
205
                $setterFields = $objectAccess->getSetterFields($refl);
206
                foreach ($setterFields as $setterField) {
207
                    /** @scrutinizer ignore-call */
208
                    $fieldName = $this->nameConverter->normalize($setterField, $resourceClass);
209
                    $schema->properties[$fieldName] = $this->convertTypesToSchema($objectAccess->getSetterTypes($refl, $setterField), $operation, $groups, $recursion);
210
                    $description = $objectAccess->getDescription($refl, $setterField, false);
211
                    if ($description) {
212
                        $schema->properties[$fieldName]->description = $description;
213
                    }
214
                }
215
                break;
216
            case 'get':
217
                $getterFields = $objectAccess->getGetterFields($refl);
218
                foreach ($getterFields as $getterField) {
219
                    /** @scrutinizer ignore-call */
220
                    $fieldName = $this->nameConverter->normalize($getterField, $resourceClass);
221
                    $schema->properties[$fieldName] = $this->convertTypesToSchema($objectAccess->getGetterTypes($refl, $getterField), $operation, $groups, $recursion);
222
                    $description = $objectAccess->getDescription($refl, $getterField, true);
223
                    if ($description) {
224
                        $schema->properties[$fieldName]->description = $description;
225
                    }
226
                }
227
                break;
228
        }
229
        if (is_array($schema->properties) && empty($schema->properties)) {
230
            $schema->properties = null;
231
        }
232
        return $this->alreadyDefined[$cacheKey] = $schema;
233
    }
234
235
    private function filterObjectAccess(ObjectAccessInterface $objectAccess, string $className, array $groups): ObjectAccessInterface
236
    {
237
        $allowedAttributes = [];
238
        foreach ($this->classMetadataFactory->getMetadataFor($className)->getAttributesMetadata() as $attributeMetadata) {
239
            $name = $attributeMetadata->getName();
240
            if (array_intersect($attributeMetadata->getGroups(), $groups)) {
241
                $allowedAttributes[] = $name;
242
            }
243
        }
244
245
        return new FilteredObjectAccess($objectAccess, $allowedAttributes);
246
    }
247
248
    private function convertTypesToSchema(array $types, string $operation, array $groups, int $recursion = 0): Schema
249
    {
250
        if (empty($types)) {
251
            return new Schema([]);
252
        }
253
        $type = reset($types);
254
        // this is only because this serializer does not do a deep populate.
255
        if ($operation === 'put') {
256
            $operation = 'post';
257
        }
258
        return $this->convertTypeToSchema($type, $operation, $groups, $recursion);
259
    }
260
261
    /**
262
     * Returns OpenApi property type for scalars.
263
     *
264
     * @param string $type
265
     * @return string
266
     */
267
    private function translateType(string $type): string
268
    {
269
        switch ($type) {
270
            case 'int': return 'integer';
271
            case 'bool': return 'boolean';
272
            case 'float': return 'number';
273
            case 'double': return 'number';
274
        }
275
276
        return $type;
277
    }
278
279
    /**
280
     * Convert Type into Schema.
281
     *
282
     * @param Type $type
283
     * @param string $operation
284
     * @param string[] $groups
285
     * @param int $recursion
286
     * @internal
287
     *
288
     * @return Schema
289
     */
290
    public function convertTypeToSchema(?Type $type, string $operation, array $groups, int $recursion): Schema
291
    {
292
        if ($type === null) {
293
            return new Schema([]);
294
        }
295
        if ($type && $type->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT && $type->getClassName() && !$type->isCollection()) {
296
            $this->oldRecursion++;
297
            try {
298
                return $this->createSchemaRecursive($type->getClassName(), $operation, $groups, $recursion + 1);
299
            } finally {
300
                $this->oldRecursion--;
301
            }
302
303
        }
304
        $propertySchema = new Schema([
305
            'type'        => 'string',
306
            'nullable'    => true,
307
        ]);
308
        $propertySchema->type = $this->translateType($type->getBuiltinType());
309
        if ($propertySchema->type === 'array') {
310
            $propertySchema->items = new Schema([]);
311
        }
312
        if (!$type->isNullable()) {
313
            $propertySchema->nullable = false;
314
        }
315
        if ($type->isCollection()) {
316
            $propertySchema->type = 'array';
317
            $propertySchema->items = new Schema([]);
318
            $arrayType = $type->getCollectionValueType();
319
            if ($arrayType) {
320
                if ($arrayType->getClassName()) {
321
                    $this->oldRecursion++;
322
                    try {
323
                        $propertySchema->items = $this->createSchemaRecursive(
324
                            $arrayType->getClassName(),
325
                            $operation,
326
                            $groups,
327
                            $recursion + 1
328
                        );
329
                    } finally {
330
                        $this->oldRecursion--;
331
                    }
332
                } elseif ($arrayType->getBuiltinType()) {
333
                    $schemaType = $this->translateType($arrayType->getBuiltinType());
334
                    $propertySchema->items = new Schema([
335
                        'type' => $schemaType,
336
                        'format' => ($schemaType === 'number') ? $arrayType->getBuiltinType() : null,
337
                    ]);
338
                    //array[] typehint...
339
                    if ($schemaType === 'array') {
340
                        $propertySchema->items->items = new Schema([]);
341
                    }
342
                }
343
            }
344
            return $propertySchema;
345
        }
346
        if ($propertySchema->type === 'number') {
347
            $propertySchema->format = $type->getBuiltinType();
348
        }
349
        $className = $type->getClassName();
350
        if (Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() && $recursion < self::MAX_RECURSION && !is_null($className)) {
351
            return $this->createSchemaRecursive($className, $operation, $groups, $recursion + 1);
352
        }
353
        return $propertySchema;
354
    }
355
356
    /**
357
     * Define an OpenAPI discriminator spec for an interface or base class that have a discriminator column.
358
     *
359
     * @param string $resourceInterface
360
     * @param string $discriminatorColumn
361
     * @param array $subclasses
362
     * @param string $operation
363
     * @param string[] $groups
364
     * @return Schema
365
     */
366
    public function defineSchemaForPolymorphicObject(
367
        string $resourceInterface,
368
        string $discriminatorColumn,
369
        array $subclasses,
370
        string $operation = 'get',
371
        array $groups = ['get', 'read']
372
    ): Schema {
373
        $cacheKey = $this->getCacheKey($resourceInterface, $operation, $groups);
374
        /** @var Schema[] $subschemas */
375
        $subschemas = [];
376
        $discriminatorMapping = [];
377
        foreach ($subclasses as $keyValue => $subclass) {
378
            $subschemas[$subclass] = $discriminatorMapping[$keyValue] = $this->createSchema($subclass, $operation, $groups);
379
            $properties = $subschemas[$subclass]->properties;
380
            if (isset($properties[$discriminatorColumn])) {
381
                $properties[$discriminatorColumn]->default = $keyValue;
382
                $properties[$discriminatorColumn]->example = $keyValue;
383
            } else {
384
                $properties[$discriminatorColumn] = new Schema([
385
                    'type' => 'string',
386
                    'default' => $keyValue,
387
                    'example' => $keyValue
388
                ]);
389
            }
390
            $subschemas[$subclass]->properties = $properties;
391
        }
392
        $this->alreadyDefined[$cacheKey . ',0'] = new Schema([
393
            'type' => 'object',
394
            'properties' => [
395
                $discriminatorColumn => new Schema(['type' => 'string']),
396
            ],
397
            'oneOf' => array_values($subschemas),
398
            'discriminator' => new Discriminator($discriminatorColumn, $discriminatorMapping)
399
        ]);
400
        for ($i = 1; $i < self::MAX_RECURSION; $i++) {
401
            $this->alreadyDefined[$cacheKey . ',' . $i] = $this->alreadyDefined[$cacheKey . ',0'];
402
        }
403
        return $this->alreadyDefined[$cacheKey . ',0'];
404
    }
405
}
406