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

convertSubActionToRequestBody()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 36
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 20
c 0
b 0
f 0
nc 3
nop 1
dl 0
loc 36
rs 9.6
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