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