Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.99% covered (success)
93.99%
344 / 366
53.33% covered (warning)
53.33%
8 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenApiGenerator
93.99% covered (success)
93.99%
344 / 366
53.33% covered (warning)
53.33%
8 / 15
78.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
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.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        if ($input instanceof ReflectionClass || $input instanceof ReflectionMethod) {
128            $examples = [];
129            foreach ($input->getAttributes(ExampleValue::class) as $attribute) {
130                $exampleValue = $attribute->newInstance();
131                $id = SnakeCaseSlug::fromText($exampleValue->name)->toNative();
132                $examples[$id] = new Example([
133                    'summary' => $exampleValue->name,
134                    'value' => $exampleValue->toExample(),
135                ]);
136            }
137            return $examples;
138        }
139        // TODO: ReflectionType?
140        return [];
141    }
142
143    private function createSchemaForInput(ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition, bool $forUpload = false): Schema|Reference
144    {
145        $input = $routeDefinition->getInputType();
146        
147        $result = $this->doSchemaForInput($input, $componentsBuilder, $routeDefinition->getMethod());
148        if ($forUpload && $routeDefinition->getMethod() !== RequestMethod::GET) {
149            $uploads = [];
150            $visited = [];
151            $state = [];
152            $this->findUploads($result, $componentsBuilder, $state, $uploads, $visited);
153            $required = ['form'];
154            foreach ($uploads as $uploadName => $upload) {
155                if (!$upload->nullable) {
156                    $required[] = $uploadName;
157                }
158            }
159            return new Schema([
160                'type' => 'object',
161                'properties' => [
162                    'form' => $result,
163                    '_csrf' => new Schema(['type' => 'string']),
164                    // TODO _internal
165                    ...$uploads
166                ],
167                'required' => $required,
168            ]);
169        }
170        return $result;
171    }
172
173    /**
174     * @param array<int, string> $state
175     * @param array <int|string, mixed> $uploads
176     * @param array <string, true> $visited
177     */
178    private function findUploads(
179        Schema|Reference $schema,
180        ComponentsBuilder $componentsBuilder,
181        array $state,
182        array& $uploads,
183        array& $visited
184    ): void {
185        if ($schema instanceof Reference) {
186            if (isset($visited[$schema->getReference()])) {
187                return;
188            }
189            $visited[$schema->getReference()] = true;
190            $schema = $componentsBuilder->getSchemaForReference($schema);
191        }
192        if ($schema->__isset('x-upload')) {
193            $uploads[implode('.', $state)] = new Schema([
194                'type' => 'string',
195                'format' => 'binary',
196                'nullable' => $schema->nullable,
197            ]);
198        }
199        foreach ($schema->properties ?? [] as $propertyName => $propertySchema) {
200            $this->findUploads(
201                $propertySchema,
202                $componentsBuilder,
203                [...$state, $propertyName],
204                $uploads,
205                $visited
206            );
207        }
208    }
209
210    /**
211     * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $input
212     */
213    private function doSchemaForInput(ReflectionClass|ReflectionMethod|ReflectionType $input, ComponentsBuilder $componentsBuilder, RequestMethod $method = RequestMethod::GET): Schema|Reference
214    {
215        if ($input instanceof ReflectionClass) {
216            if ($method === RequestMethod::PATCH) {
217                return $componentsBuilder->addModificationSchemaFor($input->name);
218            }
219            return $componentsBuilder->addCreationSchemaFor($input->name);
220        }
221        if ($input instanceof ReflectionMethod) {
222            $info = $componentsBuilder->getSchemaForMethod($input);
223            return new Schema(
224                [
225                    'type' => 'object',
226                    'properties' => $info->schemas,
227                ] + ($info->required ? ['required' => $info->required] : [])
228            );
229        }
230        return $componentsBuilder->getSchemaForType($input, nullable: $input->allowsNull());
231    }
232
233    /**
234     * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $output
235     */
236    private function doSchemaForOutput(ReflectionClass|ReflectionMethod|ReflectionType $output, ComponentsBuilder $componentsBuilder): Schema|Reference
237    {
238        if ($output instanceof ReflectionClass) {
239            return $componentsBuilder->addDisplaySchemaFor($output->name);
240        }
241        if ($output instanceof ReflectionMethod) {
242            $output = $output->getReturnType();
243        }
244        return $componentsBuilder->getSchemaForType($output, false, true, $output ? $output->allowsNull() : true);
245    }
246
247    private function createSchemaForOutput(ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): Schema|Reference
248    {
249        $input = $routeDefinition->getOutputType();
250        if ($input instanceof ListOf) {
251            return new Schema([
252                'type' => 'object',
253                'required' => [
254                    'filteredCount',
255                    'totalCount',
256                    'first',
257                    'last',
258                    'list',
259                ],
260                'properties' => [
261                    'totalCount' => ['type' => 'integer', 'minimum' => 0],
262                    'filteredCount' => ['type' => 'integer', 'minimum' => 0],
263                    'first' => ['type' => 'string', 'format' => 'uri'],
264                    'last' => ['type' => 'string', 'format' => 'uri'],
265                    'prev' => ['type' => 'string', 'format' => 'uri'],
266                    'next' => ['type' => 'string', 'format' => 'uri'],
267                    'list' => [
268                        'type' => 'array',
269                        'items' => $this->doSchemaForOutput($input->type, $componentsBuilder),
270                    ]
271                ]
272            ]);
273        }
274        return $this->doSchemaForOutput($input, $componentsBuilder);
275    }
276
277    /**
278     * @return array<string, Example>
279     */
280    private function createExamplesForParameter(
281        RestApiRouteDefinition $routeDefinition,
282        string $placeholderName
283    ): array {
284        $input = $routeDefinition->getInputType();
285        $examples = [];
286        if ($input instanceof ReflectionMethod) {
287            foreach ($input->getParameters() as $parameter) {
288                if ($parameter->name === $placeholderName) {
289                    foreach ($parameter->getAttributes(ExampleValue::class) as $attribute) {
290                        $exampleValue = $attribute->newInstance();
291                        $id = SnakeCaseSlug::fromText($exampleValue->name)->toNative();
292                        $examples[$id] = new Example([
293                            'summary' => $exampleValue->name,
294                            'value' => $exampleValue->toExample()
295                        ]);
296                    }
297                    break;
298                }
299            }
300        }
301        if ($input instanceof ReflectionClass) {
302            $methodNames = [
303                ['get' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
304                ['has' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
305                ['is' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
306                [$placeholderName, 'hasProperty', 'getProperty'],
307            ];
308
309            foreach ($methodNames as $optionToCheck) {
310                list($propertyName, $has, $get) = $optionToCheck;
311                if ($input->$has($propertyName)) {
312                    $option = $input->$get($propertyName);
313                    foreach ($option->getAttributes(ExampleValue::class) as $attribute) {
314                        $exampleValue = $attribute->newInstance();
315                        $id = SnakeCaseSlug::fromText($exampleValue->name)->toNative();
316                        $examples[$id] = new Example([
317                            'summary' => $exampleValue->name,
318                            'value' => $exampleValue->toExample()
319                        ]);
320                    }
321                }
322            }
323        }
324
325        return $examples;
326    }
327
328    private function createSchemaForParameter(
329        ComponentsBuilder $componentsBuilder,
330        RestApiRouteDefinition $routeDefinition,
331        string $placeholderName
332    ): Schema|Reference {
333        $input = $routeDefinition->getInputType();
334        $found = false;
335        if ($input instanceof ReflectionMethod) {
336            foreach ($input->getParameters() as $parameter) {
337                if ($parameter->name === $placeholderName) {
338                    $found = true;
339                    $input = $parameter->getType() ?? ReflectionTypeFactory::createReflectionType('string');
340                    break;
341                }
342            }
343        }
344        if ($input instanceof ReflectionClass) {
345            $methodNames = [
346                ['get' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
347                ['has' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
348                ['is' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
349                [$placeholderName, 'hasProperty', 'getProperty', 'getType'],
350            ];
351
352            foreach ($methodNames as $optionToCheck) {
353                list($propertyName, $has, $get, $type) = $optionToCheck;
354                if ($input->$has($propertyName)) {
355                    $input = $input->$get($propertyName)->$type();
356                    $found = true;
357                    break;
358                }
359            }
360        }
361        if (!$found) {
362            $input = ReflectionTypeFactory::createReflectionType(NonEmptyString::class);
363        }
364        return $this->doSchemaForInput($input, $componentsBuilder);
365    }
366
367    private function generateParameter(
368        ComponentsBuilder $componentsBuilder,
369        RestApiRouteDefinition $routeDefinition,
370        string $placeholderName
371    ): Parameter {
372        $examples = $this->createExamplesForParameter($routeDefinition, $placeholderName);
373        return new Parameter(array_filter([
374            'in' => 'path',
375            'name' => $placeholderName,
376            'required' => true,
377            'description' => $placeholderName . ' of instance of ' . $this->getDisplayValue($routeDefinition->getInputType(), $placeholderName),
378            'schema' => $this->createSchemaForParameter($componentsBuilder, $routeDefinition, $placeholderName),
379            'examples' => $examples,
380        ]));
381    }
382
383    /**
384     * @param ReflectionClass<object>|ReflectionMethod|ReflectionType $type
385     */
386    private function getDisplayValue(ReflectionClass|ReflectionMethod|ReflectionType $type, string $placeholderName): string
387    {
388        if ($type instanceof ReflectionNamedType) {
389            $name = $type->getName();
390            if (class_exists($name)) {
391                return (new ReflectionClass($name))->getShortName();
392            }
393            return $name;
394        }
395        if ($type instanceof ReflectionType) {
396            return (string) $type;
397        }
398        if ($type instanceof ReflectionClass) {
399            return $type->getShortName();
400        }
401        if ($placeholderName === 'id') {
402            return $type->getDeclaringClass()->getShortName();
403        }
404        return $type->name;
405    }
406
407    private function supportsMultipart(RestApiRouteDefinition $routeDefinition): bool
408    {
409        $input = ConverterUtils::toReflectionClass($routeDefinition->getInputType());
410        if ($input === null) {
411            return false;
412        }
413        if (!in_array($routeDefinition->getMethod(), [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) {
414            return false;
415        }
416        return !empty($input->getAttributes(AllowMultipart::class));
417    }
418
419    private function addAction(PathItem $pathItem, ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): void
420    {
421        $method = $routeDefinition->getMethod();
422        if (!in_array($method, RequestMethod::allowedInOpenApi())) {
423            return;
424        }
425        $inputSchema = $this->createSchemaForInput($componentsBuilder, $routeDefinition);
426        $examples = $this->createExamplesForInput($componentsBuilder, $routeDefinition);
427        $outputSchema = $this->createSchemaForOutput($componentsBuilder, $routeDefinition);
428        $operation = new Operation([
429            'tags' => $routeDefinition->getTags()->toArray(),
430            'description' => $routeDefinition->getDescription(),
431            'operationId' => $routeDefinition->getOperationId(),
432        ]);
433        $parameters = [];
434        $parameters[] = new Parameter([
435            'name' => 'fields',
436            'in' => 'query',
437            'explode' => false,
438            'schema' => new Schema([
439                'type' => 'array',
440                'items' => new Schema([
441                    'type' => 'string',
442                ])
443            ])
444        ]);
445        $parameters[] = new Parameter([
446            'name' => 'relations',
447            'in' => 'query',
448            'explode' => false,
449            'schema' => new Schema([
450                'type' => 'array',
451                'items' => new Schema([
452                    'type' => 'string',
453                ])
454            ])
455        ]);
456        $placeholders = $routeDefinition->getUrl()->getPlaceholders();
457
458        foreach ($placeholders as $placeholderName) {
459            $parameters[] = $this->generateParameter($componentsBuilder, $routeDefinition, $placeholderName);
460        }
461        $operation->parameters = $parameters;
462
463        if ($method !== RequestMethod::GET && $method !== RequestMethod::DELETE) {
464            $content = [
465                'application/json' => new MediaType(array_filter([
466                    'schema' => $inputSchema,
467                    'examples' => $examples,
468                ])),
469            ];
470            if ($this->supportsMultipart($routeDefinition)) {
471                $uploadSchema = $componentsBuilder->runInContentType(
472                    'multipart/form-data',
473                    function () use ($componentsBuilder, $routeDefinition) {
474                        return $this->createSchemaForInput($componentsBuilder, $routeDefinition, true);
475                    }
476                );
477                $content['multipart/form-data'] = new MediaType(array_filter([
478                    'schema' => $uploadSchema,
479                    'examples' => $examples,
480                ]));
481                $parameters = $operation->parameters;
482                $parameters[] = new Parameter([
483                    'name' => 'x-no-crsf',
484                    'in' => 'header',
485                    'description' => 'Disable csrf',
486                    'schema' => [
487                        'type' => 'string',
488                        'enum' => ['1']
489                    ],
490                ]);
491                $operation->parameters = $parameters;
492            }
493            $operation->requestBody = new RequestBody([
494                'content' => $content
495            ]);
496        }
497        $responses = [
498        ];
499        foreach ($routeDefinition->getPossibleActionResponseStatuses() as $responseStatus) {
500            switch ($responseStatus) {
501                case ActionResponseStatus::CREATED:
502                    $responses[201] = new Response([
503                        'description' => 'Resource was created',
504                        'content' => [
505                            'application/json' => new MediaType(['schema' => $outputSchema])
506                        ]
507                    ]);
508                    break;
509                case ActionResponseStatus::SUCCESS:
510                    $responses[200] = new Response([
511                        'description' => 'OK',
512                        'content' => [
513                            'application/json' => new MediaType(['schema' => $outputSchema])
514                        ]
515                    ]);
516                    break;
517                case ActionResponseStatus::CLIENT_ERROR:
518                    foreach ([400, 405, 406] as $statusCode) {
519                        $responses[$statusCode] = new Response([
520                            'description' => 'Invalid request',
521                            'content' => [
522                                'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(NotAcceptedException::class)]),
523                            ]
524                        ]);
525                    }
526                    $responses[422] = new Response([
527                        'description' => 'A validation error occurred',
528                        'content' => [
529                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(ValidationException::class)]),
530                        ]
531                    ]);
532                    break;
533                case ActionResponseStatus::AUTHORIZATION_ERROR:
534                    foreach ([401 => 'Requires authorization', 403 => 'Access denied'] as $statusCode => $description) {
535                        $responses[$statusCode] = new Response([
536                            'description' => $description,
537                            'content' => [
538                                'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(WrongTokenException::class)]),
539                            ]
540                        ]);
541                    }
542                    break;
543                case ActionResponseStatus::DELETED:
544                    $responses[204] = new Response(['description' => 'Resource was deleted']);
545                    break;
546                case ActionResponseStatus::NOT_FOUND:
547                    $responses[404] = new Response([
548                        'description' => 'Resource not found',
549                        'content' => [
550                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
551                        ]
552                    ]);
553                    break;
554                case ActionResponseStatus::PERISTENCE_ERROR:
555                    $responses[409] = new Response([
556                        'description' => 'Resource not found',
557                        'content' => [
558                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
559                        ]
560                    ]);
561                    break;
562                default:
563                    $responses[500] = new Response([
564                        'description' => 'Unknown error occurred',
565                        'content' => [
566                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
567                        ]
568                    ]);
569            }
570        }
571        $operation->responses = $responses;
572        $prop = strtolower($method->value);
573        // @phpstan-ignore-next-line
574        $pathItem->{$prop} = $operation;
575        $this->dispatcher->dispatch(
576            new OpenApiOperationAddedEvent(
577                $componentsBuilder,
578                $operation,
579                $routeDefinition
580            )
581        );
582    }
583}