Completed
Push — master ( 0728e2...7bf439 )
by Tomas
10:58 queued 10s
created

OpenApiHandler::handle()   C

Complexity

Conditions 10
Paths 56

Size

Total Lines 133

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 110

Importance

Changes 0
Metric Value
dl 0
loc 133
ccs 0
cts 117
cp 0
rs 6.1333
c 0
b 0
f 0
cc 10
nc 56
nop 1
crap 110

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Tomaj\NetteApi\Handlers;
6
7
use InvalidArgumentException;
8
use Nette\Application\UI\InvalidLinkException;
9
use Nette\Http\IResponse;
10
use Nette\Http\Request;
11
use Symfony\Component\Yaml\Yaml;
12
use Tomaj\NetteApi\Api;
13
use Tomaj\NetteApi\ApiDecider;
14
use Tomaj\NetteApi\Authorization\BasicAuthentication;
15
use Tomaj\NetteApi\Authorization\BearerTokenAuthorization;
16
use Tomaj\NetteApi\Authorization\CookieApiKeyAuthentication;
17
use Tomaj\NetteApi\Authorization\HeaderApiKeyAuthentication;
18
use Tomaj\NetteApi\Authorization\QueryApiKeyAuthentication;
19
use Tomaj\NetteApi\Link\ApiLink;
20
use Tomaj\NetteApi\Output\JsonOutput;
21
use Tomaj\NetteApi\Output\RedirectOutput;
22
use Tomaj\NetteApi\Params\GetInputParam;
23
use Tomaj\NetteApi\Params\InputParam;
24
use Tomaj\NetteApi\Params\JsonInputParam;
25
use Tomaj\NetteApi\Params\RawInputParam;
26
use Tomaj\NetteApi\Response\JsonApiResponse;
27
use Tomaj\NetteApi\Response\ResponseInterface;
28
use Tomaj\NetteApi\Response\TextApiResponse;
29
30
class OpenApiHandler extends BaseHandler
31
{
32
    /** @var ApiDecider */
33
    private $apiDecider;
34
35
    /** @var ApiLink */
36
    private $apiLink;
37
38
    /** @var Request */
39
    private $request;
40
41
    private $initData = [];
42
43
    private $definitions = [];
44
45
    /**
46
     * OpenApiHandler constructor.
47
     * @param ApiDecider $apiDecider
48
     * @param ApiLink $apiLink
49
     * @param Request $request
50
     * @param array $initData - structured data for initialization response
51
     */
52
    public function __construct(
53
        ApiDecider $apiDecider,
54
        ApiLink $apiLink,
55
        Request $request,
56
        array $initData = []
57
    ) {
58
        parent::__construct();
59
        $this->apiDecider = $apiDecider;
60
        $this->apiLink = $apiLink;
61
        $this->request = $request;
62
        $this->initData = $initData;
63
    }
64
65
    public function params(): array
66
    {
67
        return [
68
            (new GetInputParam('format'))->setAvailableValues(['json', 'yaml'])->setDescription('Response format'),
69
        ];
70
    }
71
72
    /**
73
     * {@inheritdoc}
74
     */
75
    public function description(): string
76
    {
77
        return 'Open API';
78
    }
79
80
    /**
81
     * {@inheritdoc}
82
     */
83
    public function tags(): array
84
    {
85
        return ['openapi'];
86
    }
87
88
    /**
89
     * {@inheritdoc}
90
     */
91
    public function handle(array $params): ResponseInterface
92
    {
93
        $version = $this->getEndpoint()->getVersion();
94
        $apis = $this->getApis($version);
95
        $scheme = $this->request->getUrl()->getScheme();
96
        $host = $this->request->getUrl()->getHost();
97
        $baseUrl = $scheme . '://' . $host;
98
        $basePath = $this->getBasePath($apis, $baseUrl);
99
100
        $securitySchemes = [];
101
102
        foreach ($apis as $api) {
103
            $authorization = $api->getAuthorization();
104
            if ($authorization instanceof BasicAuthentication) {
105
                $securitySchemes['Basic'] = [
106
                    'type' => 'http',
107
                    'scheme' => 'basic',
108
                ];
109
                continue;
110
            }
111
            if ($authorization instanceof BearerTokenAuthorization) {
112
                $securitySchemes['Bearer'] = [
113
                    'type' => 'http',
114
                    'scheme' => 'bearer',
115
                ];
116
                continue;
117
            }
118
            if ($authorization instanceof QueryApiKeyAuthentication) {
119
                $queryParamName = $authorization->getQueryParamName();
120
                $securitySchemes[$this->normalizeSecuritySchemeName('query', $queryParamName)] = [
121
                    'type' => 'apiKey',
122
                    'in' => 'query',
123
                    'name' => $queryParamName,
124
                ];
125
                continue;
126
            }
127
            if ($authorization instanceof HeaderApiKeyAuthentication) {
128
                $headerName = $authorization->getHeaderName();
129
                $securitySchemes[$this->normalizeSecuritySchemeName('header', $headerName)] = [
130
                    'type' => 'apiKey',
131
                    'in' => 'header',
132
                    'name' => $headerName,
133
                ];
134
                continue;
135
            }
136
            if ($authorization instanceof CookieApiKeyAuthentication) {
137
                $cookieName = $authorization->getCookieName();
138
                $securitySchemes[$this->normalizeSecuritySchemeName('cookie', $cookieName)] = [
139
                    'type' => 'apiKey',
140
                    'in' => 'cookie',
141
                    'name' => $cookieName,
142
                ];
143
                continue;
144
            }
145
        }
146
147
        $data = [
148
            'openapi' => '3.0.0',
149
            'info' => [
150
                'version' => (string)$version,
151
                'title' => 'Nette API',
152
            ],
153
            'servers' => [
154
                [
155
                    'url' => $scheme . '://' . $host . $basePath,
156
                ],
157
            ],
158
            'components' => [
159
                'securitySchemes' => $securitySchemes,
160
                'schemas' => [
161
                    'ErrorWrongInput' => [
162
                        'type' => 'object',
163
                        'properties' => [
164
                            'status' => [
165
                                'type' => 'string',
166
                                'enum' => ['error'],
167
                            ],
168
                            'message' => [
169
                                'type' => 'string',
170
                                'enum' => ['Wrong input'],
171
                            ],
172
                        ],
173
                        'required' => ['status', 'message'],
174
                    ],
175
                    'ErrorForbidden' => [
176
                        'type' => 'object',
177
                        'properties' => [
178
                            'status' => [
179
                                'type' => 'string',
180
                                'enum' => ['error'],
181
                            ],
182
                            'message' => [
183
                                'type' => 'string',
184
                                'enum' => ['Authorization header HTTP_Authorization is not set', 'Authorization header contains invalid structure', 'Authorization header doesn\'t contain bearer token', 'API key is not set'],
185
                            ],
186
                        ],
187
                        'required' => ['status', 'message'],
188
                    ],
189
                    'InternalServerError' => [
190
                        'type' => 'object',
191
                        'properties' => [
192
                            'status' => [
193
                                'type' => 'string',
194
                                'enum' => ['error'],
195
                            ],
196
                            'message' => [
197
                                'type' => 'string',
198
                                'enum' => ['Internal server error'],
199
                            ],
200
                        ],
201
                        'required' => ['status', 'message'],
202
                    ],
203
                ],
204
            ],
205
206
            'paths' => $this->getPaths($apis, $baseUrl, $basePath),
207
        ];
208
209
        if (!$securitySchemes) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $securitySchemes of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
210
            unset($data['components']['securitySchemes']);
211
        }
212
213
        if (!empty($this->definitions)) {
214
            $data['components']['schemas'] = array_merge($this->definitions, $data['components']['schemas']);
215
        }
216
217
        $data = array_replace_recursive($data, $this->initData);
218
219
        if ($params['format'] === 'yaml') {
220
            return new TextApiResponse(IResponse::S200_OK, Yaml::dump($data, PHP_INT_MAX, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
221
        }
222
        return new JsonApiResponse(IResponse::S200_OK, $data);
223
    }
224
225
    private function getApis(int $version): array
226
    {
227
        return array_filter($this->apiDecider->getApis(), function (Api $api) use ($version) {
228
            return $version === $api->getEndpoint()->getVersion();
229
        });
230
    }
231
232
    /**
233
     * @param Api[] $versionApis
234
     * @param string $baseUrl
235
     * @param string $basePath
236
     * @return array
237
     * @throws InvalidLinkException
238
     */
239
    private function getPaths(array $versionApis, string $baseUrl, string $basePath): array
240
    {
241
        $list = [];
242
        foreach ($versionApis as $api) {
243
            $handler = $api->getHandler();
244
            $path = str_replace([$baseUrl, $basePath], '', $this->apiLink->link($api->getEndpoint()));
245
            $responses = [];
246
            foreach ($handler->outputs() as $output) {
247
                if ($output instanceof JsonOutput) {
248
                    $schema = $this->transformSchema(json_decode($output->getSchema(), true));
249
                    $responses[$output->getCode()] = [
250
                        'description' => $output->getDescription(),
251
                        'content' => [
252
                            'application/json' => [
253
                                'schema' => $schema,
254
                            ],
255
                        ]
256
                    ];
257
                }
258
259
                if ($output instanceof RedirectOutput) {
260
                    $responses[$output->getCode()] = [
261
                        'description' => 'Redirect',
262
                        'headers' => [
263
                            'Location' => [
264
                                'description' => $output->getDescription(),
265
                                'schema' => [
266
                                    'type' => 'string',
267
                                ]
268
                            ],
269
                        ]
270
                    ];
271
                }
272
            }
273
274
            $responses[IResponse::S400_BAD_REQUEST] = [
275
                'description' => 'Bad request',
276
                'content' => [
277
                    'application/json' => [
278
                        'schema' => [
279
                            '$ref' => '#/components/schemas/ErrorWrongInput',
280
                        ],
281
                    ]
282
                ],
283
            ];
284
285
            $responses[IResponse::S403_FORBIDDEN] = [
286
                'description' => 'Operation forbidden',
287
                'content' => [
288
                    'application/json' => [
289
                        'schema' => [
290
                            '$ref' => '#/components/schemas/ErrorForbidden',
291
                        ],
292
                    ],
293
                ],
294
            ];
295
296
            $responses[IResponse::S500_INTERNAL_SERVER_ERROR] = [
297
                'description' => 'Internal server error',
298
                'content' => [
299
                    'application/json' => [
300
                        'schema' => [
301
                            '$ref' => '#/components/schemas/InternalServerError',
302
                        ],
303
                    ],
304
                ],
305
            ];
306
307
            $settings = [
308
                'summary' => $handler->summary(),
309
                'description' => $handler->description(),
310
                'tags' => $handler->tags(),
311
            ];
312
313
            if ($handler->deprecated()) {
314
                $settings['deprecated'] = true;
315
            }
316
317
            $parameters = $this->createParamsList($handler);
318
            if (!empty($parameters)) {
319
                $settings['parameters'] = $parameters;
320
            }
321
322
            $requestBody = $this->createRequestBody($handler);
323
            if (!empty($requestBody)) {
324
                $settings['requestBody'] = $requestBody;
325
            }
326
327
            $authorization = $api->getAuthorization();
328
            if ($authorization instanceof BearerTokenAuthorization) {
329
                $settings['security'] = [
330
                    [
331
                        'Bearer' => [],
332
                    ],
333
                ];
334
            } elseif ($authorization instanceof BasicAuthentication) {
335
                $settings['security'] = [
336
                    [
337
                        'Basic' => [],
338
                    ],
339
                ];
340
            } elseif ($authorization instanceof QueryApiKeyAuthentication) {
341
                $settings['security'] = [
342
                    [
343
                        $this->normalizeSecuritySchemeName('query', $authorization->getQueryParamName()) => [],
344
                    ],
345
                ];
346
            } elseif ($authorization instanceof HeaderApiKeyAuthentication) {
347
                $settings['security'] = [
348
                    [
349
                        $this->normalizeSecuritySchemeName('header', $authorization->getHeaderName()) => [],
350
                    ],
351
                ];
352
            } elseif ($authorization instanceof CookieApiKeyAuthentication) {
353
                $settings['security'] = [
354
                    [
355
                        $this->normalizeSecuritySchemeName('cookie', $authorization->getCookieName()) => [],
356
                    ],
357
                ];
358
            }
359
            $settings['responses'] = $responses;
360
            $list[$path][strtolower($api->getEndpoint()->getMethod())] = $settings;
361
        }
362
        return $list;
363
    }
364
365
    private function getBasePath(array $apis, string $baseUrl): string
366
    {
367
        $basePath = '';
368
        foreach ($apis as $handler) {
369
            $basePath = $this->getLongestCommonSubstring($basePath, $this->apiLink->link($handler->getEndpoint()));
370
        }
371
        return rtrim(str_replace($baseUrl, '', $basePath), '/');
372
    }
373
374
    private function getLongestCommonSubstring($path1, $path2)
375
    {
376
        if ($path1 === null) {
377
            return $path2;
378
        }
379
        $commonSubstring = '';
380
        $shortest = min(strlen($path1), strlen($path2));
381
        for ($i = 0; $i <= $shortest; ++$i) {
382
            if (substr($path1, 0, $i) !== substr($path2, 0, $i)) {
383
                break;
384
            }
385
            $commonSubstring = substr($path1, 0, $i);
386
        }
387
        return $commonSubstring;
388
    }
389
390
    /**
391
     * Create array with params for specified handler
392
     *
393
     * @param ApiHandlerInterface $handler
394
     *
395
     * @return array
396
     */
397
    private function createParamsList(ApiHandlerInterface $handler)
398
    {
399
        $parameters = [];
400
        foreach ($handler->params() as $param) {
401
            if ($param->getType() !== InputParam::TYPE_GET) {
402
                continue;
403
            }
404
405
            $schema = [
406
                'type' => $param->isMulti() ? 'array' : 'string',
407
            ];
408
409
            $parameter = [
410
                'name' => $param->getKey() . ($param->isMulti() ? '[]' : ''),
411
                'in' => $this->createIn($param->getType()),
412
                'required' => $param->isRequired(),
413
                'description' => $param->getDescription(),
414
            ];
415
416
            if ($param->isMulti()) {
417
                $schema['items'] = ['type' => 'string'];
418
            }
419
            if ($param->getAvailableValues()) {
420
                $schema['enum'] = $param->getAvailableValues();
421
            }
422
            if ($param->getExample() || $param->getDefault()) {
423
                $schema['example'] = $param->getExample() ?: $param->getDefault();
424
            }
425
426
            $parameter['schema'] = $schema;
427
428
            $parameters[] = $parameter;
429
        }
430
        return $parameters;
431
    }
432
433
    private function createRequestBody(ApiHandlerInterface $handler)
434
    {
435
        $postParams = [
436
            'properties' => [],
437
            'required' => [],
438
        ];
439
        $postParamsExample = [];
440
        foreach ($handler->params() as $param) {
441
            if ($param instanceof JsonInputParam) {
442
                $schema = json_decode($param->getSchema(), true);
443
                if ($param->getExample()) {
444
                    $schema['example'] = $param->getExample();
445
                }
446
                return [
447
                    'description' => $param->getDescription(),
448
                    'required' => $param->isRequired(),
449
                    'content' => [
450
                        'application/json' => [
451
                            'schema' => $this->transformSchema($schema),
452
                        ],
453
                    ],
454
                ];
455
            }
456
            if ($param instanceof RawInputParam) {
457
                return [
458
                    'description' => $param->getDescription(),
459
                    'required' => $param->isRequired(),
460
                    'content' => [
461
                        'text/plain' => [
462
                            'schema' => [
463
                                'type' => 'string',
464
                            ],
465
                        ],
466
                    ],
467
                ];
468
            }
469
            if ($param->getType() === InputParam::TYPE_POST) {
470
                $property = [
471
                    'type' => $param->isMulti() ? 'array' : 'string',
472
                    'description' => $param->getDescription(),
473
                ];
474
                if ($param->isMulti()) {
475
                    $property['items'] = ['type' => 'string'];
476
                }
477
                if ($param->getAvailableValues()) {
478
                    $property['enum'] = $param->getAvailableValues();
479
                }
480
481
                $postParams['properties'][$param->getKey() . ($param->isMulti() ? '[]' : '')] = $property;
482
                if ($param->isRequired()) {
483
                    $postParams['required'][] = $param->getKey() . ($param->isMulti() ? '[]' : '');
484
                }
485
486
                if ($param->getExample() || $param->getDefault()) {
487
                    $postParamsExample[$param->getKey()] = $param->getExample() ?: $param->getDefault();
488
                }
489
            }
490
        }
491
492
        if (!empty($postParams['properties'])) {
493
            $postParamsSchema = [
494
                'type' => 'object',
495
                'properties' => $postParams['properties'],
496
                'required' => $postParams['required'],
497
            ];
498
499
            if ($postParamsExample) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $postParamsExample of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
500
                $postParamsSchema['example'] = $postParamsExample;
501
            }
502
503
            return [
504
                'required' => true,
505
                'content' => [
506
                    'application/x-www-form-urlencoded' => [
507
                        'schema' => $postParamsSchema,
508
                    ],
509
                ],
510
            ];
511
        }
512
513
        return null;
514
    }
515
516
    private function createIn($type)
517
    {
518
        if ($type == InputParam::TYPE_GET) {
519
            return 'query';
520
        }
521
        if ($type == InputParam::TYPE_COOKIE) {
522
            return 'cookie';
523
        }
524
        return 'body';
525
    }
526
527
    private function transformSchema(array $schema)
528
    {
529
        $this->transformTypes($schema);
530
531
        if (isset($schema['definitions'])) {
532
            foreach ($schema['definitions'] as $name => $definition) {
533
                $this->addDefinition($name, $this->transformSchema($definition));
534
            }
535
            unset($schema['definitions']);
536
        }
537
        return json_decode(str_replace('#/definitions/', '#/components/schemas/', json_encode($schema, JSON_UNESCAPED_SLASHES)), true);
538
    }
539
540
    private function transformTypes(array &$schema)
541
    {
542
        foreach ($schema as $key => &$value) {
543
            if ($key === 'type' && is_array($value)) {
544
                if (count($value) === 2 && in_array('null', $value)) {
545
                    unset($value[array_search('null', $value)]);
546
                    $value = implode(',', $value);
547
                    $schema['nullable'] = true;
548
                } else {
549
                    throw new InvalidArgumentException('Type cannot be array and if so, one element have to be "null"');
550
                }
551
            } elseif (is_array($value)) {
552
                $this->transformTypes($value);
553
            }
554
        }
555
    }
556
557
    private function addDefinition($name, $definition)
558
    {
559
        if (isset($this->definitions[$name])) {
560
            throw new InvalidArgumentException('Definition with name ' . $name . ' already exists. Rename it or use existing one.');
561
        }
562
        $this->definitions[$name] = $definition;
563
    }
564
565
    private function normalizeSecuritySchemeName(string $type, string $name): string
566
    {
567
        return 'api_key__' . $type . '__' . strtolower(str_replace('-', '_', $name));
568
    }
569
}
570