Passed
Push — master ( 94f1d9...2d4769 )
by Pieter
02:23
created

OpenApiSpecGenerator::getIdentifierKey()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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