Passed
Branch 3.3 (35580f)
by Pieter
15:26
created

OpenApiSpecGenerator::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 9
c 0
b 0
f 0
nc 1
nop 9
dl 0
loc 20
rs 9.9666

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 W2w\Lib\Apie\Core\ApiResourceMetadataFactory;
7
use W2w\Lib\Apie\Core\ClassResourceConverter;
8
use W2w\Lib\Apie\Core\IdentifierExtractor;
9
use W2w\Lib\Apie\Core\PluginContainer;
10
use W2w\Lib\Apie\Core\Resources\ApiResourcesInterface;
11
use W2w\Lib\Apie\Interfaces\SearchFilterProviderInterface;
12
use W2w\Lib\Apie\OpenApiSchema\SubActions\SubAction;
13
use W2w\Lib\Apie\OpenApiSchema\SubActions\SubActionContainer;
14
15
/**
16
 * Class that generated an OpenAPI spec from a list of API resources.
17
 */
18
class OpenApiSpecGenerator
19
{
20
    private $apiResources;
21
22
    private $converter;
23
24
    private $info;
25
26
    private $schemaGenerator;
27
28
    private $apiResourceMetadataFactory;
29
30
    private $identifierExtractor;
31
32
    private $baseUrl;
33
34
    private $subActionContainer;
35
36
    private $addSpecsHook;
37
38
    public function __construct(
39
        ApiResourcesInterface $apiResources,
40
        ClassResourceConverter $converter,
41
        OASv3\Info $info,
42
        SchemaGenerator $schemaGenerator,
43
        ApiResourceMetadataFactory $apiResourceMetadataFactory,
44
        IdentifierExtractor $identifierExtractor,
45
        string $baseUrl,
46
        SubActionContainer $subActionContainer,
47
        ?callable $addSpecsHook = null
48
    ) {
49
        $this->apiResources = $apiResources;
50
        $this->converter = $converter;
51
        $this->info = $info;
52
        $this->schemaGenerator = $schemaGenerator;
53
        $this->apiResourceMetadataFactory = $apiResourceMetadataFactory;
54
        $this->identifierExtractor = $identifierExtractor;
55
        $this->baseUrl = $baseUrl;
56
        $this->subActionContainer = $subActionContainer;
57
        $this->addSpecsHook = $addSpecsHook;
58
    }
59
60
    private function getIdentifierKey(string $className): ?string
61
    {
62
        $context = $this->apiResourceMetadataFactory->getMetadata($className)->getContext();
63
        return $this->identifierExtractor->getIdentifierKeyOfClass($className, $context);
64
    }
65
66
    /**
67
     * Gets an OpenAPI spec document.
68
     *
69
     * @return OASv3\Document
70
     */
71
    public function getOpenApiSpec(): OASv3\Document
72
    {
73
        $paths = [];
74
        foreach ($this->apiResources->getApiResources() as $apiResourceClass) {
75
            $path = $this->converter->normalize($apiResourceClass);
76
            $identifierName = $this->getIdentifierKey($apiResourceClass);
77
            $paths['/' . $path] = $this->convertAllToPathItem($apiResourceClass, $path);
78
            if ($identifierName) {
79
                $paths['/' . $path . '/{' . $identifierName . '}'] = $this->convertToPathItem($apiResourceClass, $path, $identifierName);
80
                foreach ($this->subActionContainer->getSubActionsForResourceClass($apiResourceClass) as $key => $subAction) {
81
                    $paths['/' . $path . '/{' . $identifierName . '}/' . $key] = $this->convertSubActionToPathItem($subAction, $path, $identifierName);
82
                }
83
            }
84
        }
85
86
        $stringSchema = new OASv3\Schema(['type' => 'string']);
87
        $stringOrIntSchema = new OASv3\Schema(['oneOf' => [$stringSchema, new OASv3\Schema(['type' => 'integer'])]]);
88
        $stringArraySchema = new OASv3\Schema(['type' => 'array', 'items' => $stringSchema]);
89
90
        $errorSchema = new OASv3\Reference('#/components/schemas/Error');
91
92
        $validationErrorSchema = new OASv3\Schema([
93
            'type'       => 'object',
94
            'properties' => [
95
                'type'    => $stringSchema,
96
                'message' => $stringSchema,
97
                'code'    => $stringOrIntSchema,
98
                'trace'   => $stringSchema,
99
                'errors'  => new OASv3\Schema([
100
                    'type'       => 'object',
101
                    'additionalProperties' => $stringArraySchema
102
                ]),
103
            ],
104
            'xml' => new OASv3\Xml(['name' => 'response']),
105
        ]);
106
107
        $doc = new OASv3\Document(
108
            $this->info,
109
            $paths,
110
            '3.0.1',
111
            [
112
                'servers' => [
113
                    new OASv3\Server($this->baseUrl),
114
                ],
115
                'components' => new OASv3\Components([
116
                    'schemas' => [
117
                        'Error' => new OASv3\Schema([
118
                            'type'       => 'object',
119
                            'properties' => [
120
                                'type'    => $stringSchema,
121
                                'message' => $stringSchema,
122
                                'code'    => $stringOrIntSchema,
123
                                'trace'   => $stringSchema,
124
                            ],
125
                            'xml' => new OASv3\Xml(['name' => 'response']),
126
                        ]),
127
                    ],
128
                    'headers' => [
129
                        'x-ratelimit-limit' => new Oasv3\Header(
130
                            'Request limit per hour',
131
                            [
132
                                'example' => 100,
133
                                'schema'  => new OASv3\Schema([
134
                                    'type' => 'integer',
135
                                ]),
136
                            ]
137
                        ),
138
                        'x-ratelimit-remaining' => new Oasv3\Header(
139
                            'Request limit per hour',
140
                            [
141
                                'example' => 94,
142
                                'schema'  => new OASv3\Schema([
143
                                    'type' => 'integer',
144
                                ]),
145
                            ]
146
                        ),
147
                    ],
148
                    'responses' => [
149
                        'InvalidFormat' => new OASv3\Response(
150
                            'The body input could not be parsed',
151
                            [
152
                                'application/json' => new OASv3\MediaType(
153
                                    [
154
                                        'schema' => $errorSchema,
155
                                    ]
156
                                ),
157
                                'application/xml' => new OASv3\MediaType(
158
                                    [
159
                                        'schema' => $errorSchema,
160
                                    ]
161
                                ),
162
                            ]
163
                        ),
164
                        'ValidationError' => new OASv3\Response(
165
                            'The body input was in a proper format, but the input values were not valid',
166
                            [
167
                                'application/json' => new OASv3\MediaType(
168
                                    [
169
                                        'schema' => $validationErrorSchema,
170
                                    ]
171
                                ),
172
                                'application/xml' => new OASv3\MediaType(
173
                                    [
174
                                        'schema' => $validationErrorSchema,
175
                                    ]
176
                                ),
177
                            ]
178
                        ),
179
                        'TooManyRequests' => new OASv3\Response(
180
                            'Too many requests per seconds were sent',
181
                            [
182
                                'application/json' => new OASv3\MediaType(
183
                                    [
184
                                        'schema' => $errorSchema,
185
                                    ]
186
                                ),
187
                                'application/xml' => new OASv3\MediaType(
188
                                    [
189
                                        'schema' => $errorSchema,
190
                                    ]
191
                                ),
192
                            ]
193
                        ),
194
                        'MaintenanceMode' => new OASv3\Response(
195
                            'App is in maintenance mode',
196
                            [
197
                                'application/json' => new OASv3\MediaType(
198
                                    [
199
                                        'schema' => $errorSchema,
200
                                    ]
201
                                ),
202
                                'application/xml' => new OASv3\MediaType(
203
                                    [
204
                                        'schema' => $errorSchema,
205
                                    ]
206
                                ),
207
                            ]
208
                        ),
209
                        'NotFound' => new OASv3\Response(
210
                            'Response when resource could not be found',
211
                            [
212
                                'application/json' => new OASv3\MediaType(
213
                                    [
214
                                        'schema' => $errorSchema,
215
                                    ]
216
                                ),
217
                                'application/xml' => new OASv3\MediaType(
218
                                    [
219
                                        'schema' => $errorSchema,
220
                                    ]
221
                                ),
222
                            ]
223
                        ),
224
                        'NotAuthorized' => new OASv3\Response(
225
                            'You have no permission to do this call',
226
                            [
227
                                'application/json' => new OASv3\MediaType(
228
                                    [
229
                                        'schema' => $errorSchema,
230
                                    ]
231
                                ),
232
                                'application/xml' => new OASv3\MediaType(
233
                                    [
234
                                        'schema' => $errorSchema,
235
                                    ]
236
                                ),
237
                            ]
238
                        ),
239
                        'InternalError' => new OASv3\Response(
240
                            'An internal error occured',
241
                            [
242
                                'application/json' => new OASv3\MediaType(
243
                                    [
244
                                        'schema' => $errorSchema,
245
                                    ]
246
                                ),
247
                                'application/xml' => new OASv3\MediaType(
248
                                    [
249
                                        'schema' => $errorSchema,
250
                                    ]
251
                                ),
252
                            ]
253
                        ),
254
                        'ServerDependencyError' => new OASv3\Response(
255
                            'The server required an external response which threw an error',
256
                            [
257
                                'application/json' => new OASv3\MediaType(
258
                                    [
259
                                        'schema' => $errorSchema,
260
                                    ]
261
                                ),
262
                                'application/xml' => new OASv3\MediaType(
263
                                    [
264
                                        'schema' => $errorSchema,
265
                                    ]
266
                                ),
267
                            ]
268
                        ),
269
                    ],
270
                ]),
271
            ]
272
        );
273
        if (is_callable($this->addSpecsHook)) {
274
            $res = call_user_func($this->addSpecsHook, $doc);
275
            if ($res instanceof OASv3\Document) {
276
                return $res;
277
            }
278
        }
279
280
        return $doc;
281
    }
282
283
    /**
284
     * Returns the default HTTP headers we generated for every REST api call.
285
     *
286
     * @return array
287
     */
288
    private function getDefaultHeaders(): array
289
    {
290
        return [
291
            'x-ratelimit-limit'     => new OASv3\Reference('#/components/headers/x-ratelimit-limit'),
292
            'x-ratelimit-remaining' => new OASv3\Reference('#/components/headers/x-ratelimit-remaining'),
293
        ];
294
    }
295
296
    private function convertSubActionToRequestBody(SubAction $subAction): ?OASv3\RequestBody
297
    {
298
        $properties = [];
299
        foreach ($subAction->getArguments() as $fieldName => $type) {
300
            //TODO typehint string etc.
301
            if ($type === null || !$type->getClassName()) {
302
                $properties[$fieldName] = new OASv3\Schema([
303
                    'type' => 'object',
304
                    'additionalProperties' => true,
305
                ]);
306
                continue;
307
            }
308
            $properties[$fieldName] = $this->schemaGenerator->createSchema($type->getClassName(), 'post', ['write', 'post']);
309
        }
310
        $jsonSchema = new OASv3\Schema([
311
            'type' => 'object',
312
            'properties' => $properties,
313
        ]);
314
        $xmlSchema = unserialize(serialize($jsonSchema));
315
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
316
317
        return new OASv3\RequestBody(
318
            [
319
                'application/json' => new OASv3\MediaType(
320
                    [
321
                        'schema' => $jsonSchema,
322
                    ]
323
                ),
324
                'application/xml' => new OASv3\MediaType(
325
                    [
326
                        'schema' => $xmlSchema,
327
                    ]
328
                ),
329
            ],
330
            'the resource as JSON to persist',
331
            true
332
        );
333
    }
334
335
    /**
336
     * Returns the content OpenAPI spec for a resource class and a certain operation.
337
     *
338
     * @param string $apiResourceClass
339
     * @param string $operation
340
     * @return OASv3\MediaType[]
341
     */
342
    private function convertToContent(string $apiResourceClass, string $operation): array
343
    {
344
        $readWrite = $this->determineReadWrite($operation);
345
        $jsonSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
346
        $xmlSchema = unserialize(serialize($jsonSchema));
347
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
348
349
        return [
350
            'application/json' => new OASv3\MediaType(
351
                [
352
                    'schema' => $jsonSchema,
353
                ]
354
            ),
355
            'application/xml' => new OASv3\MediaType(
356
                [
357
                    'schema' => $xmlSchema,
358
                ]
359
            ),
360
        ];
361
    }
362
363
    /**
364
     * Returns the content OpenAPI spec for a resource class when it returns an array of resources.
365
     *
366
     * @param string $apiResourceClass
367
     * @param string $operation
368
     * @return OASv3\MediaType[]
369
     */
370
    private function convertToContentArray(string $apiResourceClass, string $operation): array
371
    {
372
        $readWrite = $this->determineReadWrite($operation);
373
        $jsonSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
374
        $xmlSchema = $this->schemaGenerator->createSchema($apiResourceClass, $operation, [$operation, $readWrite]);
375
        $xmlSchema->xml = new OASv3\Xml(['name' => 'item']);
376
377
        return [
378
            'application/json' => new OASv3\MediaType(
379
                [
380
                    'schema' => new OASv3\Schema([
381
                        'type'  => 'array',
382
                        'items' => $jsonSchema,
383
                    ]),
384
                ]
385
            ),
386
            'application/xml' => new OASv3\MediaType(
387
                [
388
                    'schema' => new OASv3\Schema([
389
                        'type'  => 'array',
390
                        'items' => $xmlSchema,
391
                        'xml'   => new OASv3\Xml(['name' => 'response']),
392
                    ]),
393
                ]
394
            ),
395
        ];
396
    }
397
398
    /**
399
     * Determine if the operation is a read or a write.
400
     *
401
     * @param string $operation
402
     * @return string
403
     */
404
    private function determineReadWrite(string $operation): string
405
    {
406
        if ($operation === 'post' || $operation === 'put') {
407
            return 'write';
408
        }
409
410
        return 'read';
411
    }
412
413
    /**
414
     * Returns all paths of an api resource without an id in the url.
415
     *
416
     * @param string $apiResourceClass
417
     * @param string $resourceName
418
     * @return OASv3\PathItem
419
     */
420
    private function convertAllToPathItem(string $apiResourceClass, string $resourceName): OASv3\PathItem
421
    {
422
        $paths = [];
423
424
        if ($this->allowed($apiResourceClass, 'all')) {
425
            $paths['get'] = new OASv3\Operation(
426
                [
427
                    '200' => new OASv3\Response(
428
                        'Retrieves all instances of ' . $resourceName,
429
                        $this->convertToContentArray($apiResourceClass, 'get'),
430
                        $this->getDefaultHeaders()
431
                    ),
432
                    '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
433
                    '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
434
                    '500' => new OASv3\Reference('#/components/responses/InternalError'),
435
                    '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
436
                    '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
437
                ],
438
                null,
439
                null,
440
                [
441
                    'tags'       => [$resourceName],
442
                    'parameters' => [
443
                        new OASv3\Parameter('page', 'query', 'pagination index counting from 0', ['schema' => new OASv3\Schema(['type' => 'integer', 'minimum' => 0])]),
444
                        new OASv3\Parameter('limit', 'query', 'number of results', ['schema' => new OASv3\Schema(['type' => 'integer', 'minimum' => 1])]),
445
                    ],
446
                ]
447
            );
448
            $metadata = $this->apiResourceMetadataFactory->getMetadata($apiResourceClass);
449
            $retriever = $metadata->hasResourceRetriever() ? $metadata->getResourceRetriever() : null;
450
            if ($retriever instanceof SearchFilterProviderInterface) {
451
                foreach ($retriever->getSearchFilter($metadata)->getAllPrimitiveSearchFilter() as $name => $filter) {
452
                    $schema = $filter->getSchemaForFilter();
453
                    $paths['get']->parameters[] = new OASv3\Parameter(
454
                        $name,
455
                        'query',
456
                        'search filter ' . $name,
457
                        ['schema' => $schema]
458
                    );
459
                }
460
            }
461
        }
462
463
        if ($this->allowed($apiResourceClass, 'post')) {
464
            $paths['post'] = new OASv3\Operation(
465
                [
466
                    '200' => new OASv3\Response(
467
                        'Creates a new instance of ' . $resourceName,
468
                        $this->convertToContent($apiResourceClass, 'get'),
469
                        $this->getDefaultHeaders()
470
                    ),
471
                    '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
472
                    '415' => new OASv3\Reference('#/components/responses/InvalidFormat'),
473
                    '422' => new OASv3\Reference('#/components/responses/ValidationError'),
474
                    '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
475
                    '500' => new OASv3\Reference('#/components/responses/InternalError'),
476
                    '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
477
                    '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
478
                ],
479
                null,
480
                null,
481
                [
482
                    'tags'        => [$resourceName],
483
                    'requestBody' => new OASv3\RequestBody(
484
                        $this->convertToContent($apiResourceClass, 'post'),
485
                        'the resource as JSON to persist',
486
                        true
487
                    ),
488
                ]
489
            );
490
        }
491
492
        return new OASv3\PathItem($paths);
493
    }
494
495
    /**
496
     * Creates PathItem for sub actions.
497
     *
498
     * @param SubAction $subAction
499
     * @param string $resourceName
500
     * @param string $identifierName
501
     * @return OASv3\PathItem
502
     */
503
    private function convertSubActionToPathItem(SubAction $subAction, string $resourceName, string $identifierName): OASv3\PathItem
504
    {
505
        $paths = [
506
            'parameters' => [
507
                new OASv3\Parameter($identifierName, 'path', 'the id of the resource', ['required' => true, 'schema' => new OASv3\Schema(['type' => 'string'])]),
508
            ],
509
        ];
510
        $paths['post'] = new OASv3\Operation(
511
            [
512
                '200' => new OASv3\Response(
513
                    'Retrieves return value of ' . $subAction->getName(),
514
                    $subAction->getReturnTypehint()->getClassName() ? $this->convertToContent($subAction->getReturnTypehint()->getClassName(), 'get') : null,
515
                    $this->getDefaultHeaders()
516
                ),
517
                '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
518
                '404' => new OASv3\Reference('#/components/responses/NotFound'),
519
                '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
520
                '500' => new OASv3\Reference('#/components/responses/InternalError'),
521
                '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
522
                '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
523
            ],
524
            null,
525
            null,
526
            [
527
                'tags' => [$resourceName],
528
                'requestBody' => $this->convertSubActionToRequestBody($subAction),
529
            ]
530
        );
531
        return new OASv3\PathItem($paths);
532
    }
533
534
    /**
535
     * Returns all paths of an api resource with an id in the url.
536
     * @param string $apiResourceClass
537
     * @param string $resourceName
538
     * @return OASv3\PathItem
539
     */
540
    private function convertToPathItem(string $apiResourceClass, string $resourceName, string $identifierName): OASv3\PathItem
541
    {
542
        $paths = [
543
            'parameters' => [
544
                new OASv3\Parameter($identifierName, 'path', 'the id of the resource', ['required' => true, 'schema' => new OASv3\Schema(['type' => 'string'])]),
545
            ],
546
        ];
547
        if ($this->allowed($apiResourceClass, 'get')) {
548
            $paths['get'] = new OASv3\Operation(
549
                [
550
                    '200' => new OASv3\Response(
551
                        'Retrieves a single instance of ' . $resourceName,
552
                        $this->convertToContent($apiResourceClass, 'get'),
553
                        $this->getDefaultHeaders()
554
                    ),
555
                    '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
556
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
557
                    '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
558
                    '500' => new OASv3\Reference('#/components/responses/InternalError'),
559
                    '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
560
                    '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
561
                ],
562
                null,
563
                null,
564
                [
565
                    'tags' => [$resourceName],
566
                ]
567
            );
568
        }
569
        if ($this->allowed($apiResourceClass, 'delete')) {
570
            $paths['delete'] = new OASv3\Operation(
571
                [
572
                    '204' => new OASv3\Response(
573
                        'Deletes a single instance of ' . $resourceName,
574
                        null,
575
                        $this->getDefaultHeaders()
576
                    ),
577
                    '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
578
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
579
                    '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
580
                    '500' => new OASv3\Reference('#/components/responses/InternalError'),
581
                    '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
582
                    '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
583
                ],
584
                null,
585
                null,
586
                [
587
                    'tags' => [$resourceName],
588
                ]
589
            );
590
        }
591
        if ($this->allowed($apiResourceClass, 'put')) {
592
            $paths['put'] = new OASv3\Operation(
593
                [
594
                    '200' => new OASv3\Response(
595
                        'Retrieves and update a single instance of ' . $resourceName,
596
                        $this->convertToContent($apiResourceClass, 'get'),
597
                        $this->getDefaultHeaders()
598
                    ),
599
                    '401' => new OASv3\Reference('#/components/responses/NotAuthorized'),
600
                    '404' => new OASv3\Reference('#/components/responses/NotFound'),
601
                    '415' => new OASv3\Reference('#/components/responses/InvalidFormat'),
602
                    '422' => new OASv3\Reference('#/components/responses/ValidationError'),
603
                    '429' => new OASv3\Reference('#/components/responses/TooManyRequests'),
604
                    '500' => new OASv3\Reference('#/components/responses/InternalError'),
605
                    '502' => new OASv3\Reference('#/components/responses/ServerDependencyError'),
606
                    '503' => new OASv3\Reference('#/components/responses/MaintenanceMode'),
607
                ],
608
                null,
609
                null,
610
                [
611
                    'tags'        => [$resourceName],
612
                    'requestBody' => new OASv3\RequestBody(
613
                        $this->convertToContent($apiResourceClass, 'put'),
614
                        'the resource as JSON to persist',
615
                        true
616
                    ),
617
                ]
618
            );
619
        }
620
621
        return new OASv3\PathItem($paths);
622
    }
623
624
    /**
625
     * Returns if a specific REST API call is an allowed method.
626
     *
627
     * @param string $apiResourceClass
628
     * @param string $operation
629
     * @return bool
630
     */
631
    private function allowed(string $apiResourceClass, string $operation): bool
632
    {
633
        $metadata = $this->apiResourceMetadataFactory->getMetadata($apiResourceClass);
634
        switch ($operation) {
635
            case 'all':
636
                return $metadata->allowGetAll();
637
            case 'get':
638
                return $metadata->allowGet();
639
            case 'post':
640
                return $metadata->allowPost();
641
            case 'put':
642
                return $metadata->allowPut();
643
            case 'delete':
644
                return $metadata->allowDelete();
645
        }
646
        // @codeCoverageIgnoreStart
647
        return false;
648
        // @codeCoverageIgnoreEnd
649
    }
650
}
651