OpenApiSpecGenerator   A
last analyzed

Complexity

Total Complexity 36

Size/Duplication

Total Lines 506
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 234
dl 0
loc 506
rs 9.52
c 0
b 0
f 0
wmc 36

12 Methods

Rating   Name   Duplication   Size   Complexity  
A getIdentifierKey() 0 4 1
A __construct() 0 22 1
A sluggify() 0 3 1
A convertSubActionToPathItem() 0 24 2
A determineReadWrite() 0 7 3
A convertToContent() 0 16 1
A convertToPathItem() 0 67 4
B getOpenApiSpec() 0 117 6
A convertToContentArray() 0 22 1
B convertAllToPathItem() 0 63 6
A allowed() 0 17 6
A convertSubActionToRequestBody() 0 33 4
1
<?php
2
3
namespace W2w\Lib\Apie\OpenApiSchema;
4
5
use erasys\OpenApi\Spec\v3 as OASv3;
6
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
7
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
8
use W2w\Lib\Apie\Core\ApiResourceMetadataFactory;
9
use W2w\Lib\Apie\Core\ClassResourceConverter;
10
use W2w\Lib\Apie\Core\IdentifierExtractor;
11
use W2w\Lib\Apie\Core\Resources\ApiResourcesInterface;
12
use W2w\Lib\Apie\Interfaces\SearchFilterProviderInterface;
13
use W2w\Lib\Apie\OpenApiSchema\SubActions\SubAction;
14
use W2w\Lib\Apie\OpenApiSchema\SubActions\SubActionContainer;
15
16
/**
17
 * Class that generated an OpenAPI spec from a list of API resources.
18
 */
19
class OpenApiSpecGenerator
20
{
21
    private $apiResources;
22
23
    private $converter;
24
25
    private $info;
26
27
    private $schemaGenerator;
28
29
    private $apiResourceMetadataFactory;
30
31
    private $identifierExtractor;
32
33
    private $baseUrl;
34
35
    private $subActionContainer;
36
37
    private $addSpecsHook;
38
39
    private $nameConverter;
40
41
    public function __construct(
42
        ApiResourcesInterface $apiResources,
43
        ClassResourceConverter $converter,
44
        OASv3\Info $info,
45
        OpenApiSchemaGenerator $schemaGenerator,
46
        ApiResourceMetadataFactory $apiResourceMetadataFactory,
47
        IdentifierExtractor $identifierExtractor,
48
        string $baseUrl,
49
        SubActionContainer $subActionContainer,
50
        NameConverterInterface $nameConverter,
51
        ?callable $addSpecsHook = null
52
    ) {
53
        $this->apiResources = $apiResources;
54
        $this->converter = $converter;
55
        $this->info = $info;
56
        $this->schemaGenerator = $schemaGenerator;
57
        $this->apiResourceMetadataFactory = $apiResourceMetadataFactory;
58
        $this->identifierExtractor = $identifierExtractor;
59
        $this->baseUrl = $baseUrl;
60
        $this->subActionContainer = $subActionContainer;
61
        $this->nameConverter = $nameConverter;
62
        $this->addSpecsHook = $addSpecsHook;
63
    }
64
65
    private function getIdentifierKey(string $className): ?string
66
    {
67
        $context = $this->apiResourceMetadataFactory->getMetadata($className)->getContext();
68
        return $this->identifierExtractor->getIdentifierKeyOfClass($className, $context);
69
    }
70
71
    /**
72
     * Gets an OpenAPI spec document.
73
     *
74
     * @return OASv3\Document
75
     */
76
    public function getOpenApiSpec(): OASv3\Document
77
    {
78
        $paths = [];
79
        foreach ($this->apiResources->getApiResources() as $apiResourceClass) {
80
            $path = $this->converter->normalize($apiResourceClass);
81
            $identifierName = $this->getIdentifierKey($apiResourceClass);
82
            $paths['/' . $path] = $this->convertAllToPathItem($apiResourceClass, $path);
83
            if ($identifierName) {
84
                $paths['/' . $path . '/{' . $identifierName . '}'] = $this->convertToPathItem($apiResourceClass, $path, $identifierName);
85
                foreach ($this->subActionContainer->getSubActionsForResourceClass($apiResourceClass) as $key => $subAction) {
86
                    $paths['/' . $path . '/{' . $identifierName . '}/' . $key] = $this->convertSubActionToPathItem($subAction, $path, $identifierName);
87
                }
88
            }
89
        }
90
91
        $stringSchema = new OASv3\Schema(['type' => 'string']);
92
        $stringOrIntSchema = new OASv3\Schema(['oneOf' => [$stringSchema, new OASv3\Schema(['type' => 'integer'])]]);
93
        $stringArraySchema = new OASv3\Schema(['type' => 'array', 'items' => $stringSchema]);
94
95
        $errorSchema = new OASv3\Reference('#/components/schemas/Error');
96
97
        $validationErrorSchema = new OASv3\Schema([
98
            'type'       => 'object',
99
            'properties' => [
100
                'type'    => $stringSchema,
101
                'message' => $stringSchema,
102
                'code'    => $stringOrIntSchema,
103
                'trace'   => $stringSchema,
104
                'errors'  => new OASv3\Schema([
105
                    'type'       => 'object',
106
                    'additionalProperties' => $stringArraySchema
107
                ]),
108
            ],
109
            'xml' => new OASv3\Xml(['name' => 'response']),
110
        ]);
111
112
        $doc = new OASv3\Document(
113
            $this->info,
114
            $paths,
115
            '3.0.1',
116
            [
117
                'servers' => [
118
                    new OASv3\Server($this->baseUrl),
119
                ],
120
                'components' => new OASv3\Components([
121
                    'schemas' => [
122
                        'Error' => new OASv3\Schema([
123
                            'type'       => 'object',
124
                            'properties' => [
125
                                'type'    => $stringSchema,
126
                                'message' => $stringSchema,
127
                                'code'    => $stringOrIntSchema,
128
                                'trace'   => $stringSchema,
129
                            ],
130
                            'xml' => new OASv3\Xml(['name' => 'response']),
131
                        ]),
132
                    ],
133
                    'headers' => [
134
                    ],
135
                    'responses' => [
136
                        'InvalidFormat' => new OASv3\Response(
137
                            'The body input could not be parsed',
138
                            [
139
                                'application/json' => new OASv3\MediaType(
140
                                    [
141
                                        'schema' => $errorSchema,
142
                                    ]
143
                                ),
144
                                'application/xml' => new OASv3\MediaType(
145
                                    [
146
                                        'schema' => $errorSchema,
147
                                    ]
148
                                ),
149
                            ]
150
                        ),
151
                        'NotFound' => new OASv3\Response(
152
                            'Response when resource could not be found',
153
                            [
154
                                'application/json' => new OASv3\MediaType(
155
                                    [
156
                                        'schema' => $errorSchema,
157
                                    ]
158
                                ),
159
                                'application/xml' => new OASv3\MediaType(
160
                                    [
161
                                        'schema' => $errorSchema,
162
                                    ]
163
                                ),
164
                            ]
165
                        ),
166
                        'ValidationError' => new OASv3\Response(
167
                            'The body input was in a proper format, but the input values were not valid',
168
                            [
169
                                'application/json' => new OASv3\MediaType(
170
                                    [
171
                                        'schema' => $validationErrorSchema,
172
                                    ]
173
                               ),
174
                               'application/xml' => new OASv3\MediaType(
175
                                    [
176
                                        'schema' => $validationErrorSchema,
177
                                    ]
178
                               ),
179
                           ]
180
                       ),
181
                    ],
182
                ]),
183
            ]
184
        );
185
        if (is_callable($this->addSpecsHook)) {
186
            $res = call_user_func($this->addSpecsHook, $doc);
187
            if ($res instanceof OASv3\Document) {
188
                return $res;
189
            }
190
        }
191
192
        return $doc;
193
    }
194
195
    private function convertSubActionToRequestBody(SubAction $subAction): ?OASv3\RequestBody
196
    {
197
        $properties = [];
198
        foreach ($subAction->getArguments() as $fieldName => $type) {
199
            $denormalizedFieldName = $this->nameConverter->denormalize($fieldName);
200
            if ($type === null || !$type->getClassName()) {
201
                $properties[$denormalizedFieldName] = $this->schemaGenerator->convertTypeToSchema($type, 'post', ['write', 'post'], 0);
202
                continue;
203
            }
204
            $properties[$denormalizedFieldName] = $this->schemaGenerator->createSchema($type->getClassName(), 'post', ['write', 'post']);
205
        }
206
        $jsonSchema = new OASv3\Schema([
207
            'type' => 'object',
208
            'properties' => $properties,
209
        ]);
210
        $xmlSchema = unserialize(serialize($jsonSchema));
211
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
212
213
        return new OASv3\RequestBody(
214
            [
215
                'application/json' => new OASv3\MediaType(
216
                    [
217
                        'schema' => $jsonSchema,
218
                    ]
219
                ),
220
                'application/xml' => new OASv3\MediaType(
221
                    [
222
                        'schema' => $xmlSchema,
223
                    ]
224
                ),
225
            ],
226
            'the resource as JSON to persist',
227
            true
228
        );
229
    }
230
231
    /**
232
     * Returns the content OpenAPI spec for a resource class and a certain operation.
233
     *
234
     * @param string $apiResourceClass
235
     * @param string $operation
236
     * @return OASv3\MediaType[]
237
     */
238
    private function convertToContent(string $apiResourceClass, string $operation): array
239
    {
240
        $readWrite = $this->determineReadWrite($operation);
241
        $jsonSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
242
        $xmlSchema = unserialize(serialize($jsonSchema));
243
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
244
245
        return [
246
            'application/json' => new OASv3\MediaType(
247
                [
248
                    'schema' => $jsonSchema,
249
                ]
250
            ),
251
            'application/xml' => new OASv3\MediaType(
252
                [
253
                    'schema' => $xmlSchema,
254
                ]
255
            ),
256
        ];
257
    }
258
259
    /**
260
     * Returns the content OpenAPI spec for a resource class when it returns an array of resources.
261
     *
262
     * @param string $apiResourceClass
263
     * @param string $operation
264
     * @return OASv3\MediaType[]
265
     */
266
    private function convertToContentArray(string $apiResourceClass, string $operation): array
267
    {
268
        $readWrite = $this->determineReadWrite($operation);
269
        $jsonSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
270
        $xmlSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
271
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
272
273
        return [
274
            'application/json' => new OASv3\MediaType(
275
                [
276
                    'schema' => new OASv3\Schema([
277
                        'type'  => 'array',
278
                        'items' => $jsonSchema,
279
                    ]),
280
                ]
281
            ),
282
            'application/xml' => new OASv3\MediaType(
283
                [
284
                    'schema' => new OASv3\Schema([
285
                        'type'  => 'array',
286
                        'items' => $xmlSchema,
287
                        'xml'   => new OASv3\Xml(['name' => 'response']),
288
                    ]),
289
                ]
290
            ),
291
        ];
292
    }
293
294
    /**
295
     * Determine if the operation is a read or a write.
296
     *
297
     * @param string $operation
298
     * @return string
299
     */
300
    private function determineReadWrite(string $operation): string
301
    {
302
        if ($operation === 'post' || $operation === 'put') {
303
            return 'write';
304
        }
305
306
        return 'read';
307
    }
308
309
    /**
310
     * Sluggify resource name for the operation id.
311
     *
312
     * @param string $resourceName
313
     * @return string|string[]|null
314
     */
315
    private function sluggify(string $resourceName)
316
    {
317
        return (new CamelCaseToSnakeCaseNameConverter(null, false))->denormalize($resourceName);
318
    }
319
320
    /**
321
     * Returns all paths of an api resource without an id in the url.
322
     *
323
     * @param string $apiResourceClass
324
     * @param string $resourceName
325
     * @return OASv3\PathItem
326
     */
327
    private function convertAllToPathItem(string $apiResourceClass, string $resourceName): OASv3\PathItem
328
    {
329
        $paths = [];
330
331
        if ($this->allowed($apiResourceClass, 'all')) {
332
            $paths['get'] = new OASv3\Operation(
333
                [
334
                    '200' => new OASv3\Response(
335
                        'Retrieves all instances of ' . $resourceName,
336
                        $this->convertToContentArray($apiResourceClass, 'get'),
337
                        []
338
                    ),
339
                ],
340
                'resourceGetAll' . $this->sluggify($resourceName),
341
                'get/search all instances of ' . $resourceName,
342
                [
343
                    'tags'       => [$resourceName],
344
                    'parameters' => [
345
                        new OASv3\Parameter('page', 'query', 'pagination index counting from 0', ['schema' => new OASv3\Schema(['type' => 'integer', 'minimum' => 0])]),
346
                        new OASv3\Parameter('limit', 'query', 'number of results', ['schema' => new OASv3\Schema(['type' => 'integer', 'minimum' => 1])]),
347
                    ],
348
                ]
349
            );
350
            $metadata = $this->apiResourceMetadataFactory->getMetadata($apiResourceClass);
351
            $retriever = $metadata->hasResourceRetriever() ? $metadata->getResourceRetriever() : null;
352
            if ($retriever instanceof SearchFilterProviderInterface) {
353
                foreach ($retriever->getSearchFilter($metadata)->getAllPrimitiveSearchFilter() as $name => $filter) {
354
                    $schema = $filter->getSchemaForFilter();
355
                    $paths['get']->parameters[] = new OASv3\Parameter(
356
                        $name,
357
                        'query',
358
                        'search filter ' . $name,
359
                        ['schema' => $schema]
360
                    );
361
                }
362
            }
363
        }
364
365
        if ($this->allowed($apiResourceClass, 'post')) {
366
            $paths['post'] = new OASv3\Operation(
367
                [
368
                    '200' => new OASv3\Response(
369
                        'Creates a new instance of ' . $resourceName,
370
                        $this->convertToContent($apiResourceClass, 'get'),
371
                        []
372
                    ),
373
                    '415' => new OASv3\Reference('#/components/responses/InvalidFormat'),
374
                    '422' => new OASv3\Reference('#/components/responses/ValidationError'),
375
                ],
376
                'resourcePostSingle' . $this->sluggify($resourceName),
377
                'create a new single instance of ' . $resourceName,
378
                [
379
                    'tags'        => [$resourceName],
380
                    'requestBody' => new OASv3\RequestBody(
381
                        $this->convertToContent($apiResourceClass, 'post'),
382
                        'the resource as JSON to persist',
383
                        true
384
                    ),
385
                ]
386
            );
387
        }
388
389
        return new OASv3\PathItem($paths);
390
    }
391
392
    /**
393
     * Creates PathItem for sub actions.
394
     *
395
     * @param SubAction $subAction
396
     * @param string $resourceName
397
     * @param string $identifierName
398
     * @return OASv3\PathItem
399
     */
400
    private function convertSubActionToPathItem(SubAction $subAction, string $resourceName, string $identifierName): OASv3\PathItem
401
    {
402
        $paths = [
403
            'parameters' => [
404
                new OASv3\Parameter($identifierName, 'path', 'the id of the resource', ['required' => true, 'schema' => new OASv3\Schema(['type' => 'string'])]),
405
            ],
406
        ];
407
        $paths['post'] = new OASv3\Operation(
408
            [
409
                '200' => new OASv3\Response(
410
                    'Retrieves return value of ' . $subAction->getName(),
411
                    $subAction->getReturnTypehint()->getClassName() ? $this->convertToContent($subAction->getReturnTypehint()->getClassName(), 'get') : null,
412
                    []
413
                ),
414
                '404' => new OASv3\Reference('#/components/responses/NotFound'),
415
            ],
416
            'resourcePostSubAction' . $this->sluggify($resourceName . '_' . $subAction->getName()),
417
            $subAction->getSummary(),
418
            [
419
                'tags' => [$resourceName],
420
                'requestBody' => $this->convertSubActionToRequestBody($subAction),
421
            ]
422
        );
423
        return new OASv3\PathItem($paths);
424
    }
425
426
    /**
427
     * Returns all paths of an api resource with an id in the url.
428
     * @param string $apiResourceClass
429
     * @param string $resourceName
430
     * @return OASv3\PathItem
431
     */
432
    private function convertToPathItem(string $apiResourceClass, string $resourceName, string $identifierName): OASv3\PathItem
433
    {
434
        $paths = [
435
            'parameters' => [
436
                new OASv3\Parameter($identifierName, 'path', 'the id of the resource', ['required' => true, 'schema' => new OASv3\Schema(['type' => 'string'])]),
437
            ],
438
        ];
439
        if ($this->allowed($apiResourceClass, 'get')) {
440
            $paths['get'] = new OASv3\Operation(
441
                [
442
                    '200' => new OASv3\Response(
443
                        'Retrieves a single instance of ' . $resourceName,
444
                        $this->convertToContent($apiResourceClass, 'get'),
445
                        []
446
                    ),
447
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
448
                ],
449
                'resourceGetSingle' . $this->sluggify($resourceName),
450
                'retrieve a single instance of ' . $resourceName,
451
                [
452
                    'tags' => [$resourceName],
453
                ]
454
            );
455
        }
456
        if ($this->allowed($apiResourceClass, 'delete')) {
457
            $paths['delete'] = new OASv3\Operation(
458
                [
459
                    '204' => new OASv3\Response(
460
                        'Deletes a single instance of ' . $resourceName,
461
                        null,
462
                        []
463
                    ),
464
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
465
                ],
466
                'resourceDeleteSingle' . $this->sluggify($resourceName),
467
                'delete a single instance of ' . $resourceName,
468
                [
469
                    'tags' => [$resourceName],
470
                ]
471
            );
472
        }
473
        if ($this->allowed($apiResourceClass, 'put')) {
474
            $paths['put'] = new OASv3\Operation(
475
                [
476
                    '200' => new OASv3\Response(
477
                        'Retrieves and update a single instance of ' . $resourceName,
478
                        $this->convertToContent($apiResourceClass, 'get'),
479
                        []
480
                    ),
481
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
482
                    '415' => new OASv3\Reference('#/components/responses/InvalidFormat'),
483
                    '422' => new OASv3\Reference('#/components/responses/ValidationError'),
484
                ],
485
                'resourcePutSingle' . $this->sluggify($resourceName),
486
                'modify a single instance of ' . $resourceName,
487
                [
488
                    'tags'        => [$resourceName],
489
                    'requestBody' => new OASv3\RequestBody(
490
                        $this->convertToContent($apiResourceClass, 'put'),
491
                        'the resource as JSON to persist',
492
                        true
493
                    ),
494
                ]
495
            );
496
        }
497
498
        return new OASv3\PathItem($paths);
499
    }
500
501
    /**
502
     * Returns if a specific REST API call is an allowed method.
503
     *
504
     * @param string $apiResourceClass
505
     * @param string $operation
506
     * @return bool
507
     */
508
    private function allowed(string $apiResourceClass, string $operation): bool
509
    {
510
        $metadata = $this->apiResourceMetadataFactory->getMetadata($apiResourceClass);
511
        switch ($operation) {
512
            case 'all':
513
                return $metadata->allowGetAll();
514
            case 'get':
515
                return $metadata->allowGet();
516
            case 'post':
517
                return $metadata->allowPost();
518
            case 'put':
519
                return $metadata->allowPut();
520
            case 'delete':
521
                return $metadata->allowDelete();
522
        }
523
        // @codeCoverageIgnoreStart
524
        return false;
525
        // @codeCoverageIgnoreEnd
526
    }
527
}
528