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) { |
|
|
|
|
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
|
|
|
|
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.