Completed
Pull Request — master (#81)
by Michal
07:21
created

OpenApiHandler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 0
cts 12
cp 0
rs 9.8666
c 0
b 0
f 0
cc 1
nc 1
nop 4
crap 2
1
<?php
2
3
namespace Tomaj\NetteApi\Handlers;
4
5
use InvalidArgumentException;
6
use Nette\Application\UI\InvalidLinkException;
7
use Nette\Http\IResponse;
8
use Nette\Http\Request;
9
use Symfony\Component\Yaml\Yaml;
10
use Tomaj\NetteApi\Api;
11
use Tomaj\NetteApi\ApiDecider;
12
use Tomaj\NetteApi\Authorization\BearerTokenAuthorization;
13
use Tomaj\NetteApi\Link\ApiLink;
14
use Tomaj\NetteApi\Output\JsonOutput;
15
use Tomaj\NetteApi\Output\RedirectOutput;
16
use Tomaj\NetteApi\Params\GetInputParam;
17
use Tomaj\NetteApi\Params\InputParam;
18
use Tomaj\NetteApi\Params\JsonInputParam;
19
use Tomaj\NetteApi\Response\JsonApiResponse;
20
use Tomaj\NetteApi\Response\ResponseInterface;
21
use Tomaj\NetteApi\Response\TextApiResponse;
22
23
class OpenApiHandler extends BaseHandler
24
{
25
    /** @var ApiDecider */
26
    private $apiDecider;
27
28
    /** @var ApiLink */
29
    private $apiLink;
30
31
    /** @var Request */
32
    private $request;
33
34
    private $initData = [];
35
36
    private $definitions = [];
37
38
    /**
39
     * OpenApiHandler constructor.
40
     * @param ApiDecider $apiDecider
41
     * @param ApiLink $apiLink
42
     * @param Request $request
43
     * @param array $initData - structured data for initialization response
44
     */
45
    public function __construct(
46
        ApiDecider $apiDecider,
47
        ApiLink $apiLink,
48
        Request $request,
49
        array $initData = []
50
    ) {
51
        parent::__construct();
52
        $this->apiDecider = $apiDecider;
53
        $this->apiLink = $apiLink;
54
        $this->request = $request;
55
        $this->initData = $initData;
56
    }
57
58
    public function params(): array
59
    {
60
        return [
61
            (new GetInputParam('format'))->setAvailableValues(['json', 'yaml'])->setDescription('Response format'),
62
        ];
63
    }
64
65
    /**
66
     * {@inheritdoc}
67
     */
68
    public function description(): string
69
    {
70
        return 'Open API';
71
    }
72
73
    /**
74
     * {@inheritdoc}
75
     */
76
    public function tags(): array
77
    {
78
        return ['openapi'];
79
    }
80
81
    /**
82
     * {@inheritdoc}
83
     */
84
    public function handle(array $params): ResponseInterface
85
    {
86
        $version = $this->getEndpoint()->getVersion();
87
        $apis = $this->getApis($version);
88
        $scheme = $this->request->getUrl()->getScheme();
89
        $host = $this->request->getUrl()->getHost();
90
        $baseUrl = $scheme . '://' . $host;
91
        $basePath = $this->getBasePath($apis, $baseUrl);
92
93
        $data = [
94
            'openapi' => '3.0.0',
95
            'info' => [
96
                'version' => $version,
97
            ],
98
            'servers' => [
99
                [
100
                    'url' => $scheme . '://' . $host . $basePath,
101
                ],
102
            ],
103
            'components' => [
104
                'securitySchemes' => [
105
                    'Bearer' => [
106
                        'type' => 'http',
107
                        'scheme' => 'bearer',
108
                    ],
109
                ],
110
                'schemas' => [
111
                    'ErrorWrongInput' => [
112
                        'type' => 'object',
113
                        'properties' => [
114
                            'status' => [
115
                                'type' => 'string',
116
                                'enum' => ['error'],
117
                            ],
118
                            'message' => [
119
                                'type' => 'string',
120
                                'enum' => ['Wrong input'],
121
                            ],
122
                        ],
123
                        'required' => ['status', 'message'],
124
                    ],
125
                    'ErrorForbidden' => [
126
                        'type' => 'object',
127
                        'properties' => [
128
                            'status' => [
129
                                'type' => 'string',
130
                                'enum' => ['error'],
131
                            ],
132
                            'message' => [
133
                                'type' => 'string',
134
                                'enum' => ['Authorization header HTTP_Authorization is not set', 'Authorization header contains invalid structure'],
135
                            ],
136
                        ],
137
                        'required' => ['status', 'message'],
138
                    ],
139
                    'InternalServerError' => [
140
                        'type' => 'object',
141
                        'properties' => [
142
                            'status' => [
143
                                'type' => 'string',
144
                                'enum' => ['error'],
145
                            ],
146
                            'message' => [
147
                                'type' => 'string',
148
                                'enum' => ['Internal server error'],
149
                            ],
150
                        ],
151
                        'required' => ['status', 'message'],
152
                    ],
153
                ],
154
            ],
155
156
            'paths' => $this->getPaths($apis, $baseUrl, $basePath),
157
        ];
158
159
        if (!empty($this->definitions)) {
160
            $data['components']['schemas'] = array_merge($this->definitions, $data['components']['schemas']);
161
        }
162
163
        $data = array_merge_recursive($this->initData, $data);
164
165
        if ($params['format'] === 'yaml') {
166
            return new TextApiResponse(IResponse::S200_OK, Yaml::dump($data, PHP_INT_MAX, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
167
        }
168
        return new JsonApiResponse(IResponse::S200_OK, $data);
169
    }
170
171
    private function getApis(int $version): array
172
    {
173
        return array_filter($this->apiDecider->getApis(), function (Api $api) use ($version) {
174
            return $version === $api->getEndpoint()->getVersion();
175
        });
176
    }
177
178
    /**
179
     * @param Api[] $versionApis
180
     * @param string $baseUrl
181
     * @param string $basePath
182
     * @return array
183
     * @throws InvalidLinkException
184
     */
185
    private function getPaths(array $versionApis, string $baseUrl, string $basePath): array
186
    {
187
        $list = [];
188
        foreach ($versionApis as $api) {
189
            $handler = $api->getHandler();
190
            $path = str_replace([$baseUrl, $basePath], '', $this->apiLink->link($api->getEndpoint()));
191
            $responses = [];
192
            foreach ($handler->outputs() as $output) {
193
                if ($output instanceof JsonOutput) {
194
                    $schema = $this->transformSchema(json_decode($output->getSchema(), true));
195
                    $responses[$output->getCode()] = [
196
                        'description' => $output->getDescription(),
197
                        'content' => [
198
                            'application/json' => [
199
                                'schema' => $schema,
200
                            ],
201
                        ]
202
                    ];
203
                }
204
205
                if ($output instanceof RedirectOutput) {
206
                    $responses[$output->getCode()] = [
207
                        'description' => 'Redirect',
208
                        'headers' => [
209
                            'Location' => [
210
                                'description' => $output->getDescription(),
211
                                'schema' => [
212
                                    'type' => 'string',
213
                                ]
214
                            ],
215
                        ]
216
                    ];
217
                }
218
            }
219
220
            $responses[IResponse::S400_BAD_REQUEST] = [
221
                'description' => 'Bad request',
222
                'content' => [
223
                    'application/json' => [
224
                        'schema' => [
225
                            '$ref' => '#/components/schemas/ErrorWrongInput',
226
                        ],
227
                    ]
228
                ],
229
            ];
230
231
            $responses[IResponse::S403_FORBIDDEN] = [
232
                'description' => 'Operation forbidden',
233
                'content' => [
234
                    'application/json' => [
235
                        'schema' => [
236
                            '$ref' => '#/components/schemas/ErrorForbidden',
237
                        ],
238
                    ],
239
                ],
240
            ];
241
242
            $responses[IResponse::S500_INTERNAL_SERVER_ERROR] = [
243
                'description' => 'Internal server error',
244
                'content' => [
245
                    'application/json' => [
246
                        'schema' => [
247
                            '$ref' => '#/components/schemas/InternalServerError',
248
                        ],
249
                    ],
250
                ],
251
            ];
252
253
            $settings = [
254
                'summary' => $handler->summary(),
255
                'description' => $handler->description(),
256
                'tags' => $handler->tags(),
257
            ];
258
259
            if ($handler->deprecated()) {
260
                $settings['deprecated'] = true;
261
            }
262
263
            $parameters = $this->createParamsList($handler);
264
            if (!empty($parameters)) {
265
                $settings['parameters'] = $parameters;
266
            }
267
268
            $requestBody = $this->createRequestBody($handler);
269
            if (!empty($requestBody)) {
270
                $settings['requestBody'] = $requestBody;
271
            }
272
273
            if ($api->getAuthorization() instanceof BearerTokenAuthorization) {
274
                $settings['security'] = [
275
                    [
276
                        'Bearer' => [],
277
                    ],
278
                ];
279
            }
280
            $settings['responses'] = $responses;
281
            $list[$path][strtolower($api->getEndpoint()->getMethod())] = $settings;
282
        }
283
        return $list;
284
    }
285
286
    private function getBasePath($handlers, $baseUrl)
287
    {
288
        $basePath = null;
289
        foreach ($handlers as $handler) {
290
            $basePath = $this->getLongestCommonSubstring($basePath, $this->apiLink->link($handler['endpoint']));
291
        }
292
        return rtrim(str_replace($baseUrl, '', $basePath), '/');
293
    }
294
295
    private function getLongestCommonSubstring($path1, $path2)
296
    {
297
        if ($path1 === null) {
298
            return $path2;
299
        }
300
        $commonSubstring = '';
301
        $shortest = min(strlen($path1), strlen($path2));
302
        for ($i = 0; $i <= $shortest; ++$i) {
303
            if (substr($path1, 0, $i) !== substr($path2, 0, $i)) {
304
                break;
305
            }
306
            $commonSubstring = substr($path1, 0, $i);
307
        }
308
        return $commonSubstring;
309
    }
310
311
    /**
312
     * Create array with params for specified handler
313
     *
314
     * @param ApiHandlerInterface $handler
315
     *
316
     * @return array
317
     */
318
    private function createParamsList(ApiHandlerInterface $handler)
319
    {
320
        $parameters = [];
321
        foreach ($handler->params() as $param) {
322
            if ($param->getType() !== InputParam::TYPE_GET) {
323
                continue;
324
            }
325
326
            $schema = [
327
                'type' => $param->isMulti() ? 'array' : 'string',
328
            ];
329
330
            $parameter = [
331
                'name' => $param->getKey() . ($param->isMulti() ? '[]' : ''),
332
                'in' => $this->createIn($param->getType()),
333
                'required' => $param->isRequired(),
334
                'description' => $param->getDescription(),
335
            ];
336
337
            if ($param->isMulti()) {
338
                $schema['items'] = ['type' => 'string'];
339
            }
340
            if ($param->getAvailableValues()) {
341
                $schema['enum'] = $param->getAvailableValues();
342
            }
343
            if ($param->getExample() || $param->getDefault()) {
344
                $schema['example'] = $param->getExample() ?: $param->getDefault();
345
            }
346
347
            $parameter['schema'] = $schema;
348
349
            $parameters[] = $parameter;
350
        }
351
        return $parameters;
352
    }
353
354
    private function createRequestBody(ApiHandlerInterface $handler)
355
    {
356
        $postParams = [
357
            'properties' => [],
358
            'required' => [],
359
        ];
360
        $postParamsExample = [];
361
        foreach ($handler->params() as $param) {
362
            if ($param instanceof JsonInputParam) {
363
                $schema = json_decode($param->getSchema(), true);
364
                if ($param->getExample()) {
365
                    $schema['example'] = $param->getExample();
366
                }
367
                return [
368
                    'description' => $param->getDescription(),
369
                    'required' => $param->isRequired(),
370
                    'content' => [
371
                        'application/json' => [
372
                            'schema' => $this->transformSchema($schema),
373
                        ],
374
                    ],
375
                ];
376
            }
377
            if ($param->getType() === InputParam::TYPE_POST) {
378
                $property = [
379
                    'type' => $param->isMulti() ? 'array' : 'string',
380
                    'description' => $param->getDescription(),
381
                ];
382
                if ($param->isMulti()) {
383
                    $property['items'] = ['type' => 'string'];
384
                }
385
                if ($param->getAvailableValues()) {
386
                    $property['enum'] = $param->getAvailableValues();
387
                }
388
389
                $postParams['properties'][$param->getKey() . ($param->isMulti() ? '[]' : '')] = $property;
390
                if ($param->isRequired()) {
391
                    $postParams['required'][] = $param->getKey() . ($param->isMulti() ? '[]' : '');
392
                }
393
394
                if ($param->getExample() || $param->getDefault()) {
395
                    $postParamsExample[$param->getKey()] = $param->getExample() ?: $param->getDefault();
396
                }
397
            }
398
        }
399
400
        if (!empty($postParams['properties'])) {
401
            $postParamsSchema = [
402
                'type' => 'object',
403
                'properties' => $postParams['properties'],
404
                'required' => $postParams['required'],
405
            ];
406
407
            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...
408
                $postParamsSchema['example'] = $postParamsExample;
409
            }
410
411
            return [
412
                'required' => true,
413
                'content' => [
414
                    'application/x-www-form-urlencoded' => [
415
                        'schema' => $postParamsSchema,
416
                    ],
417
                ],
418
            ];
419
        }
420
421
        return null;
422
    }
423
424
    private function createIn($type)
425
    {
426
        if ($type == InputParam::TYPE_GET) {
427
            return 'query';
428
        }
429
        if ($type == InputParam::TYPE_COOKIE) {
430
            return 'cookie';
431
        }
432
        return 'body';
433
    }
434
435
    private function transformSchema(array $schema)
436
    {
437
        $this->transformTypes($schema);
438
439
        if (isset($schema['definitions'])) {
440
            foreach ($schema['definitions'] as $name => $definition) {
441
                $this->addDefinition($name, $this->transformSchema($definition));
442
            }
443
            unset($schema['definitions']);
444
        }
445
        return json_decode(str_replace('#/definitions/', '#/components/schemas/', json_encode($schema, JSON_UNESCAPED_SLASHES)), true);
446
    }
447
448
    private function transformTypes(array &$schema)
449
    {
450
        foreach ($schema as $key => &$value) {
451
            if ($key === 'type' && is_array($value)) {
452
                if (count($value) === 2 && in_array('null', $value)) {
453
                    unset($value[array_search('null', $value)]);
454
                    $value = implode(',', $value);
455
                    $schema['nullable'] = true;
456
                } else {
457
                    throw new InvalidArgumentException('Type cannot be array and if so, one element have to be "null"');
458
                }
459
            } elseif (is_array($value)) {
460
                $this->transformTypes($value);
461
            }
462
        }
463
    }
464
465
    private function addDefinition($name, $definition)
466
    {
467
        if (isset($this->definitions[$name])) {
468
            throw new InvalidArgumentException('Definition with name ' . $name . ' already exists. Rename it or use existing one.');
469
        }
470
        $this->definitions[$name] = $definition;
471
    }
472
}
473