Completed
Push — master ( 1c21c5...279fbd )
by Pieter
16s queued 11s
created

OpenApiSpecGenerator::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 10
nc 1
nop 10
dl 0
loc 22
rs 9.9332
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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