Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.50% |
292 / 309 |
|
46.15% |
6 / 13 |
CRAP | |
0.00% |
0 / 1 |
OpenApiGenerator | |
94.50% |
292 / 309 |
|
46.15% |
6 / 13 |
64.68 | |
0.00% |
0 / 1 |
__construct | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
createDefaultSpec | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
create | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
5 | |||
createSchemaForInput | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
5 | |||
findUploads | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
5.00 | |||
doSchemaForInput | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
doSchemaForOutput | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
createSchemaForOutput | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
2 | |||
createSchemaForParameter | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
8 | |||
generateParameter | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getDisplayValue | |
58.33% |
7 / 12 |
|
0.00% |
0 / 1 |
8.60 | |||
supportsMultipart | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
addAction | |
95.10% |
136 / 143 |
|
0.00% |
0 / 1 |
17 |
1 | <?php |
2 | namespace Apie\RestApi\OpenApi; |
3 | |
4 | use Apie\Common\ContextBuilders\Exceptions\WrongTokenException; |
5 | use Apie\Common\Enums\UrlPrefix; |
6 | use Apie\Common\Interfaces\RestApiRouteDefinition; |
7 | use Apie\Common\Interfaces\RouteDefinitionProviderInterface; |
8 | use Apie\Core\Actions\ActionResponseStatus; |
9 | use Apie\Core\Attributes\AllowMultipart; |
10 | use Apie\Core\BoundedContext\BoundedContext; |
11 | use Apie\Core\BoundedContext\BoundedContextId; |
12 | use Apie\Core\ContextBuilders\ContextBuilderFactory; |
13 | use Apie\Core\ContextConstants; |
14 | use Apie\Core\Dto\ListOf; |
15 | use Apie\Core\Enums\RequestMethod; |
16 | use Apie\Core\Utils\ConverterUtils; |
17 | use Apie\Core\ValueObjects\NonEmptyString; |
18 | use Apie\RestApi\Events\OpenApiOperationAddedEvent; |
19 | use Apie\SchemaGenerator\Builders\ComponentsBuilder; |
20 | use Apie\SchemaGenerator\ComponentsBuilderFactory; |
21 | use Apie\Serializer\Exceptions\NotAcceptedException; |
22 | use Apie\Serializer\Exceptions\ValidationException; |
23 | use Apie\Serializer\Serializer; |
24 | use Apie\TypeConverter\ReflectionTypeFactory; |
25 | use cebe\openapi\Reader; |
26 | use cebe\openapi\ReferenceContext; |
27 | use cebe\openapi\spec\MediaType; |
28 | use cebe\openapi\spec\OpenApi; |
29 | use cebe\openapi\spec\Operation; |
30 | use cebe\openapi\spec\Parameter; |
31 | use cebe\openapi\spec\PathItem; |
32 | use cebe\openapi\spec\Paths; |
33 | use cebe\openapi\spec\Reference; |
34 | use cebe\openapi\spec\RequestBody; |
35 | use cebe\openapi\spec\Response; |
36 | use cebe\openapi\spec\Schema; |
37 | use cebe\openapi\spec\Server; |
38 | use Psr\EventDispatcher\EventDispatcherInterface; |
39 | use ReflectionClass; |
40 | use ReflectionMethod; |
41 | use ReflectionNamedType; |
42 | use ReflectionType; |
43 | use Throwable; |
44 | |
45 | class OpenApiGenerator |
46 | { |
47 | /** |
48 | * Serialized string of OpenAPI so we always get a deep clone. |
49 | */ |
50 | private string $baseSpec; |
51 | public function __construct( |
52 | private ContextBuilderFactory $contextBuilder, |
53 | private ComponentsBuilderFactory $componentsFactory, |
54 | private RouteDefinitionProviderInterface $routeDefinitionProvider, |
55 | private Serializer $serializer, |
56 | private EventDispatcherInterface $dispatcher, |
57 | private string $baseUrl = '', |
58 | ?OpenApi $baseSpec = null |
59 | ) { |
60 | $baseSpec ??= $this->createDefaultSpec(); |
61 | if (!$baseSpec->paths) { |
62 | $baseSpec->paths = new Paths([]); |
63 | } |
64 | $this->baseSpec = serialize($baseSpec); |
65 | } |
66 | |
67 | private function createDefaultSpec(): OpenApi |
68 | { |
69 | return Reader::readFromYamlFile( |
70 | __DIR__ . '/../../resources/openapi.yaml', |
71 | OpenApi::class, |
72 | ReferenceContext::RESOLVE_MODE_INLINE |
73 | ); |
74 | } |
75 | |
76 | public function create(BoundedContext $boundedContext): OpenApi |
77 | { |
78 | $spec = unserialize($this->baseSpec); |
79 | $urlPrefix = $this->baseUrl . '/' . $boundedContext->getId(); |
80 | $spec->servers = [new Server(['url' => $urlPrefix]), new Server(['url' => 'http://localhost/' . $urlPrefix])]; |
81 | $componentsBuilder = $this->componentsFactory->createComponentsBuilder($spec->components); |
82 | $context = $this->contextBuilder->createGeneralContext( |
83 | [ |
84 | OpenApiGenerator::class => $this, |
85 | ContextConstants::REST_API => true, |
86 | Serializer::class => $this->serializer, |
87 | BoundedContextId::class => $boundedContext->getId(), |
88 | BoundedContext::class => $boundedContext, |
89 | ] |
90 | ); |
91 | foreach ($this->routeDefinitionProvider->getActionsForBoundedContext($boundedContext, $context) as $routeDefinition) { |
92 | if ($routeDefinition instanceof RestApiRouteDefinition) { |
93 | if (!in_array(UrlPrefix::API, $routeDefinition->getUrlPrefixes()->toArray())) { |
94 | continue; |
95 | } |
96 | $path = $routeDefinition->getUrl()->toNative(); |
97 | if ($spec->paths->hasPath($path)) { |
98 | $pathItem = $spec->paths->getPath($path); |
99 | } else { |
100 | $pathItem = new PathItem([]); |
101 | $spec->paths->addPath($path, $pathItem); |
102 | } |
103 | $this->addAction($pathItem, $componentsBuilder, $routeDefinition); |
104 | } |
105 | } |
106 | |
107 | $spec->components = $componentsBuilder->getComponents(); |
108 | |
109 | return $spec; |
110 | } |
111 | |
112 | private function createSchemaForInput(ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition, bool $forUpload = false): Schema|Reference |
113 | { |
114 | $input = $routeDefinition->getInputType(); |
115 | |
116 | $result = $this->doSchemaForInput($input, $componentsBuilder, $routeDefinition->getMethod()); |
117 | if ($forUpload && $routeDefinition->getMethod() !== RequestMethod::GET) { |
118 | $uploads = []; |
119 | $visited = []; |
120 | $state = []; |
121 | $this->findUploads($result, $componentsBuilder, $state, $uploads, $visited); |
122 | $required = ['form']; |
123 | foreach ($uploads as $uploadName => $upload) { |
124 | if (!$upload->nullable) { |
125 | $required[] = $uploadName; |
126 | } |
127 | } |
128 | return new Schema([ |
129 | 'type' => 'object', |
130 | 'properties' => [ |
131 | 'form' => $result, |
132 | '_csrf' => new Schema(['type' => 'string']), |
133 | // TODO _internal |
134 | ...$uploads |
135 | ], |
136 | 'required' => $required, |
137 | ]); |
138 | } |
139 | return $result; |
140 | } |
141 | |
142 | /** |
143 | * @param array<int, string> $state |
144 | * @param array <int|string, mixed> $uploads |
145 | * @param array <string, true> $visited |
146 | */ |
147 | private function findUploads( |
148 | Schema|Reference $schema, |
149 | ComponentsBuilder $componentsBuilder, |
150 | array $state, |
151 | array& $uploads, |
152 | array& $visited |
153 | ): void { |
154 | if ($schema instanceof Reference) { |
155 | if (isset($visited[$schema->getReference()])) { |
156 | return; |
157 | } |
158 | $visited[$schema->getReference()] = true; |
159 | $schema = $componentsBuilder->getSchemaForReference($schema); |
160 | } |
161 | if ($schema->__isset('x-upload')) { |
162 | $uploads[implode('.', $state)] = new Schema([ |
163 | 'type' => 'string', |
164 | 'format' => 'binary', |
165 | 'nullable' => $schema->nullable, |
166 | ]); |
167 | } |
168 | foreach ($schema->properties ?? [] as $propertyName => $propertySchema) { |
169 | $this->findUploads( |
170 | $propertySchema, |
171 | $componentsBuilder, |
172 | [...$state, $propertyName], |
173 | $uploads, |
174 | $visited |
175 | ); |
176 | } |
177 | } |
178 | |
179 | /** |
180 | * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $input |
181 | */ |
182 | private function doSchemaForInput(ReflectionClass|ReflectionMethod|ReflectionType $input, ComponentsBuilder $componentsBuilder, RequestMethod $method = RequestMethod::GET): Schema|Reference |
183 | { |
184 | if ($input instanceof ReflectionClass) { |
185 | if ($method === RequestMethod::PATCH) { |
186 | return $componentsBuilder->addModificationSchemaFor($input->name); |
187 | } |
188 | return $componentsBuilder->addCreationSchemaFor($input->name); |
189 | } |
190 | if ($input instanceof ReflectionMethod) { |
191 | $info = $componentsBuilder->getSchemaForMethod($input); |
192 | return new Schema( |
193 | [ |
194 | 'type' => 'object', |
195 | 'properties' => $info->schemas, |
196 | ] + ($info->required ? ['required' => $info->required] : []) |
197 | ); |
198 | } |
199 | return $componentsBuilder->getSchemaForType($input, nullable: $input->allowsNull()); |
200 | } |
201 | |
202 | /** |
203 | * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $output |
204 | */ |
205 | private function doSchemaForOutput(ReflectionClass|ReflectionMethod|ReflectionType $output, ComponentsBuilder $componentsBuilder): Schema|Reference |
206 | { |
207 | if ($output instanceof ReflectionClass) { |
208 | return $componentsBuilder->addDisplaySchemaFor($output->name); |
209 | } |
210 | if ($output instanceof ReflectionMethod) { |
211 | $output = $output->getReturnType(); |
212 | } |
213 | return $componentsBuilder->getSchemaForType($output, false, true, $output ? $output->allowsNull() : true); |
214 | } |
215 | |
216 | private function createSchemaForOutput(ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): Schema|Reference |
217 | { |
218 | $input = $routeDefinition->getOutputType(); |
219 | if ($input instanceof ListOf) { |
220 | return new Schema([ |
221 | 'type' => 'object', |
222 | 'required' => [ |
223 | 'filteredCount', |
224 | 'totalCount', |
225 | 'first', |
226 | 'last', |
227 | 'list', |
228 | ], |
229 | 'properties' => [ |
230 | 'totalCount' => ['type' => 'integer', 'minimum' => 0], |
231 | 'filteredCount' => ['type' => 'integer', 'minimum' => 0], |
232 | 'first' => ['type' => 'string', 'format' => 'uri'], |
233 | 'last' => ['type' => 'string', 'format' => 'uri'], |
234 | 'prev' => ['type' => 'string', 'format' => 'uri'], |
235 | 'next' => ['type' => 'string', 'format' => 'uri'], |
236 | 'list' => [ |
237 | 'type' => 'array', |
238 | 'items' => $this->doSchemaForOutput($input->type, $componentsBuilder), |
239 | ] |
240 | ] |
241 | ]); |
242 | } |
243 | return $this->doSchemaForOutput($input, $componentsBuilder); |
244 | } |
245 | |
246 | private function createSchemaForParameter( |
247 | ComponentsBuilder $componentsBuilder, |
248 | RestApiRouteDefinition $routeDefinition, |
249 | string $placeholderName |
250 | ): Schema|Reference { |
251 | $input = $routeDefinition->getInputType(); |
252 | $found = false; |
253 | if ($input instanceof ReflectionMethod) { |
254 | foreach ($input->getParameters() as $parameter) { |
255 | if ($parameter->name === $placeholderName) { |
256 | $found = true; |
257 | $input = $parameter->getType() ?? ReflectionTypeFactory::createReflectionType('string'); |
258 | break; |
259 | } |
260 | } |
261 | } |
262 | if ($input instanceof ReflectionClass) { |
263 | $methodNames = [ |
264 | ['get' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'], |
265 | ['has' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'], |
266 | ['is' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'], |
267 | [$placeholderName, 'hasProperty', 'getProperty', 'getType'], |
268 | ]; |
269 | |
270 | foreach ($methodNames as $optionToCheck) { |
271 | list($propertyName, $has, $get, $type) = $optionToCheck; |
272 | if ($input->$has($propertyName)) { |
273 | $input = $input->$get($propertyName)->$type(); |
274 | $found = true; |
275 | break; |
276 | } |
277 | } |
278 | } |
279 | if (!$found) { |
280 | $input = ReflectionTypeFactory::createReflectionType(NonEmptyString::class); |
281 | } |
282 | return $this->doSchemaForInput($input, $componentsBuilder); |
283 | } |
284 | |
285 | private function generateParameter( |
286 | ComponentsBuilder $componentsBuilder, |
287 | RestApiRouteDefinition $routeDefinition, |
288 | string $placeholderName |
289 | ): Parameter { |
290 | return new Parameter([ |
291 | 'in' => 'path', |
292 | 'name' => $placeholderName, |
293 | 'required' => true, |
294 | 'description' => $placeholderName . ' of instance of ' . $this->getDisplayValue($routeDefinition->getInputType(), $placeholderName), |
295 | 'schema' => $this->createSchemaForParameter($componentsBuilder, $routeDefinition, $placeholderName), |
296 | ]); |
297 | } |
298 | |
299 | /** |
300 | * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $type |
301 | */ |
302 | private function getDisplayValue(ReflectionClass|ReflectionMethod|ReflectionType $type, string $placeholderName): string |
303 | { |
304 | if ($type instanceof ReflectionNamedType) { |
305 | $name = $type->getName(); |
306 | if (class_exists($name)) { |
307 | return (new ReflectionClass($name))->getShortName(); |
308 | } |
309 | return $name; |
310 | } |
311 | if ($type instanceof ReflectionType) { |
312 | return (string) $type; |
313 | } |
314 | if ($type instanceof ReflectionClass) { |
315 | return $type->getShortName(); |
316 | } |
317 | if ($placeholderName === 'id') { |
318 | return $type->getDeclaringClass()->getShortName(); |
319 | } |
320 | return $type->name; |
321 | } |
322 | |
323 | private function supportsMultipart(RestApiRouteDefinition $routeDefinition): bool |
324 | { |
325 | $input = ConverterUtils::toReflectionClass($routeDefinition->getInputType()); |
326 | if ($input === null) { |
327 | return false; |
328 | } |
329 | if (!in_array($routeDefinition->getMethod(), [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) { |
330 | return false; |
331 | } |
332 | return !empty($input->getAttributes(AllowMultipart::class)); |
333 | } |
334 | |
335 | private function addAction(PathItem $pathItem, ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): void |
336 | { |
337 | $method = $routeDefinition->getMethod(); |
338 | if ($method === RequestMethod::CONNECT) { |
339 | return; |
340 | } |
341 | $inputSchema = $this->createSchemaForInput($componentsBuilder, $routeDefinition); |
342 | $outputSchema = $this->createSchemaForOutput($componentsBuilder, $routeDefinition); |
343 | $operation = new Operation([ |
344 | 'tags' => $routeDefinition->getTags()->toArray(), |
345 | 'description' => $routeDefinition->getDescription(), |
346 | 'operationId' => $routeDefinition->getOperationId(), |
347 | ]); |
348 | $parameters = []; |
349 | $parameters[] = new Parameter([ |
350 | 'name' => 'fields', |
351 | 'in' => 'query', |
352 | 'explode' => false, |
353 | 'schema' => new Schema([ |
354 | 'type' => 'array', |
355 | 'items' => new Schema([ |
356 | 'type' => 'string', |
357 | ]) |
358 | ]) |
359 | ]); |
360 | $parameters[] = new Parameter([ |
361 | 'name' => 'relations', |
362 | 'in' => 'query', |
363 | 'explode' => false, |
364 | 'schema' => new Schema([ |
365 | 'type' => 'array', |
366 | 'items' => new Schema([ |
367 | 'type' => 'string', |
368 | ]) |
369 | ]) |
370 | ]); |
371 | $placeholders = $routeDefinition->getUrl()->getPlaceholders(); |
372 | |
373 | foreach ($placeholders as $placeholderName) { |
374 | $parameters[] = $this->generateParameter($componentsBuilder, $routeDefinition, $placeholderName); |
375 | } |
376 | $operation->parameters = $parameters; |
377 | |
378 | if ($method !== RequestMethod::GET && $method !== RequestMethod::DELETE) { |
379 | $content = [ |
380 | 'application/json' => new MediaType(['schema' => $inputSchema]), |
381 | ]; |
382 | if ($this->supportsMultipart($routeDefinition)) { |
383 | $uploadSchema = $componentsBuilder->runInContentType( |
384 | 'multipart/form-data', |
385 | function () use ($componentsBuilder, $routeDefinition) { |
386 | return $this->createSchemaForInput($componentsBuilder, $routeDefinition, true); |
387 | } |
388 | ); |
389 | $content['multipart/form-data'] = new MediaType([ |
390 | 'schema' => $uploadSchema |
391 | ]); |
392 | $parameters = $operation->parameters; |
393 | $parameters[] = new Parameter([ |
394 | 'name' => 'x-no-crsf', |
395 | 'in' => 'header', |
396 | 'description' => 'Disable csrf', |
397 | 'schema' => [ |
398 | 'type' => 'string', |
399 | 'enum' => [1] |
400 | ], |
401 | ]); |
402 | $operation->parameters = $parameters; |
403 | } |
404 | $operation->requestBody = new RequestBody([ |
405 | 'content' => $content |
406 | ]); |
407 | } |
408 | $responses = [ |
409 | ]; |
410 | foreach ($routeDefinition->getPossibleActionResponseStatuses() as $responseStatus) { |
411 | switch ($responseStatus) { |
412 | case ActionResponseStatus::CREATED: |
413 | $responses[201] = new Response([ |
414 | 'description' => 'Resource was created', |
415 | 'content' => [ |
416 | 'application/json' => new MediaType(['schema' => $outputSchema]) |
417 | ] |
418 | ]); |
419 | break; |
420 | case ActionResponseStatus::SUCCESS: |
421 | $responses[200] = new Response([ |
422 | 'description' => 'OK', |
423 | 'content' => [ |
424 | 'application/json' => new MediaType(['schema' => $outputSchema]) |
425 | ] |
426 | ]); |
427 | break; |
428 | case ActionResponseStatus::CLIENT_ERROR: |
429 | foreach ([400, 405, 406] as $statusCode) { |
430 | $responses[$statusCode] = new Response([ |
431 | 'description' => 'Invalid request', |
432 | 'content' => [ |
433 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(NotAcceptedException::class)]), |
434 | ] |
435 | ]); |
436 | } |
437 | $responses[422] = new Response([ |
438 | 'description' => 'A validation error occurred', |
439 | 'content' => [ |
440 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(ValidationException::class)]), |
441 | ] |
442 | ]); |
443 | break; |
444 | case ActionResponseStatus::AUTHORIZATION_ERROR: |
445 | foreach ([401 => 'Requires authorization', 403 => 'Access denied'] as $statusCode => $description) { |
446 | $responses[$statusCode] = new Response([ |
447 | 'description' => $description, |
448 | 'content' => [ |
449 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(WrongTokenException::class)]), |
450 | ] |
451 | ]); |
452 | } |
453 | break; |
454 | case ActionResponseStatus::DELETED: |
455 | $responses[204] = new Response(['description' => 'Resource was deleted']); |
456 | break; |
457 | case ActionResponseStatus::NOT_FOUND: |
458 | $responses[404] = new Response([ |
459 | 'description' => 'Resource not found', |
460 | 'content' => [ |
461 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]), |
462 | ] |
463 | ]); |
464 | break; |
465 | case ActionResponseStatus::PERISTENCE_ERROR: |
466 | $responses[409] = new Response([ |
467 | 'description' => 'Resource not found', |
468 | 'content' => [ |
469 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]), |
470 | ] |
471 | ]); |
472 | break; |
473 | default: |
474 | $responses[500] = new Response([ |
475 | 'description' => 'Unknown error occurred', |
476 | 'content' => [ |
477 | 'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]), |
478 | ] |
479 | ]); |
480 | } |
481 | } |
482 | $operation->responses = $responses; |
483 | $prop = strtolower($method->value); |
484 | $pathItem->{$prop} = $operation; |
485 | $this->dispatcher->dispatch( |
486 | new OpenApiOperationAddedEvent( |
487 | $componentsBuilder, |
488 | $operation, |
489 | $routeDefinition |
490 | ) |
491 | ); |
492 | } |
493 | } |