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