Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.82% covered (success)
93.82%
349 / 372
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenApiGenerator
93.82% covered (success)
93.82%
349 / 372
46.67% covered (danger)
46.67%
7 / 15
80.48
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
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 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.30% covered (success)
95.30%
142 / 149
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<covariant 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<covariant 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            if (ConverterUtils::isStaticOrSelf($output->getReturnType())) {
246                $output = $output->getDeclaringClass();
247            } else {
248                $output = $output->getReturnType();
249            }
250        }
251
252        return $componentsBuilder->getSchemaForType($output, false, true, $output ? $output->allowsNull() : true);
253    }
254
255    private function createSchemaForOutput(ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): Schema|Reference
256    {
257        $input = $routeDefinition->getOutputType();
258        if ($input instanceof ListOf) {
259            return new Schema([
260                'type' => 'object',
261                'required' => [
262                    'filteredCount',
263                    'totalCount',
264                    'first',
265                    'last',
266                    'list',
267                ],
268                'properties' => [
269                    'totalCount' => ['type' => 'integer', 'minimum' => 0],
270                    'filteredCount' => ['type' => 'integer', 'minimum' => 0],
271                    'first' => ['type' => 'string', 'format' => 'uri'],
272                    'last' => ['type' => 'string', 'format' => 'uri'],
273                    'prev' => ['type' => 'string', 'format' => 'uri'],
274                    'next' => ['type' => 'string', 'format' => 'uri'],
275                    'list' => [
276                        'type' => 'array',
277                        'items' => $this->doSchemaForOutput($input->type, $componentsBuilder),
278                    ]
279                ]
280            ]);
281        }
282        return $this->doSchemaForOutput($input, $componentsBuilder);
283    }
284
285    /**
286     * @return array<string, Example>
287     */
288    private function createExamplesForParameter(
289        RestApiRouteDefinition $routeDefinition,
290        string $placeholderName
291    ): array {
292        $input = $routeDefinition->getInputType();
293        $examples = [];
294        if ($input instanceof ReflectionMethod) {
295            foreach ($input->getParameters() as $parameter) {
296                if ($parameter->name === $placeholderName) {
297                    foreach ($parameter->getAttributes(ExampleValue::class) as $attribute) {
298                        $exampleValue = $attribute->newInstance();
299                        $id = SnakeCaseSlug::fromText($exampleValue->name)->toNative();
300                        $examples[$id] = new Example([
301                            'summary' => $exampleValue->name,
302                            'value' => $exampleValue->toExample()
303                        ]);
304                    }
305                    break;
306                }
307            }
308        }
309        if ($input instanceof ReflectionClass) {
310            $methodNames = [
311                ['get' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
312                ['has' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
313                ['is' . ucfirst($placeholderName), 'hasMethod', 'getMethod'],
314                [$placeholderName, 'hasProperty', 'getProperty'],
315            ];
316
317            foreach ($methodNames as $optionToCheck) {
318                list($propertyName, $has, $get) = $optionToCheck;
319                if ($input->$has($propertyName)) {
320                    $option = $input->$get($propertyName);
321                    foreach ($option->getAttributes(ExampleValue::class) as $attribute) {
322                        $exampleValue = $attribute->newInstance();
323                        $id = SnakeCaseSlug::fromText($exampleValue->name)->toNative();
324                        $examples[$id] = new Example([
325                            'summary' => $exampleValue->name,
326                            'value' => $exampleValue->toExample()
327                        ]);
328                    }
329                }
330            }
331        }
332
333        return $examples;
334    }
335
336    private function createSchemaForParameter(
337        ComponentsBuilder $componentsBuilder,
338        RestApiRouteDefinition $routeDefinition,
339        string $placeholderName
340    ): Schema|Reference {
341        $input = $routeDefinition->getInputType();
342        $found = false;
343        if ($input instanceof ReflectionMethod) {
344            foreach ($input->getParameters() as $parameter) {
345                if ($parameter->name === $placeholderName) {
346                    $found = true;
347                    $input = $parameter->getType() ?? ReflectionTypeFactory::createReflectionType('string');
348                    break;
349                }
350            }
351        }
352        if ($input instanceof ReflectionClass) {
353            $methodNames = [
354                ['get' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
355                ['has' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
356                ['is' . ucfirst($placeholderName), 'hasMethod', 'getMethod', 'getReturnType'],
357                [$placeholderName, 'hasProperty', 'getProperty', 'getType'],
358            ];
359
360            foreach ($methodNames as $optionToCheck) {
361                list($propertyName, $has, $get, $type) = $optionToCheck;
362                if ($input->$has($propertyName)) {
363                    $input = $input->$get($propertyName)->$type();
364                    $found = true;
365                    break;
366                }
367            }
368        }
369        if (!$found) {
370            $input = ReflectionTypeFactory::createReflectionType(NonEmptyString::class);
371        }
372        return $this->doSchemaForInput($input, $componentsBuilder);
373    }
374
375    private function generateParameter(
376        ComponentsBuilder $componentsBuilder,
377        RestApiRouteDefinition $routeDefinition,
378        string $placeholderName
379    ): Parameter {
380        $examples = $this->createExamplesForParameter($routeDefinition, $placeholderName);
381        return new Parameter(array_filter([
382            'in' => 'path',
383            'name' => $placeholderName,
384            'required' => true,
385            'description' => $placeholderName . ' of instance of ' . $this->getDisplayValue($routeDefinition->getInputType(), $placeholderName),
386            'schema' => $this->createSchemaForParameter($componentsBuilder, $routeDefinition, $placeholderName),
387            'examples' => $examples,
388        ]));
389    }
390
391    /**
392     * @param ReflectionClass<covariant object>|ReflectionMethod|ReflectionType $type
393     */
394    private function getDisplayValue(ReflectionClass|ReflectionMethod|ReflectionType $type, string $placeholderName): string
395    {
396        if ($type instanceof ReflectionNamedType) {
397            $name = $type->getName();
398            if (class_exists($name)) {
399                return (new ReflectionClass($name))->getShortName();
400            }
401            return $name;
402        }
403        if ($type instanceof ReflectionType) {
404            return (string) $type;
405        }
406        if ($type instanceof ReflectionClass) {
407            return $type->getShortName();
408        }
409        if ($placeholderName === 'id') {
410            return $type->getDeclaringClass()->getShortName();
411        }
412        return $type->name;
413    }
414
415    private function supportsMultipart(RestApiRouteDefinition $routeDefinition): bool
416    {
417        $input = ConverterUtils::toReflectionClass($routeDefinition->getInputType());
418        if ($input === null) {
419            return false;
420        }
421        if (!in_array($routeDefinition->getMethod(), [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) {
422            return false;
423        }
424        return !empty($input->getAttributes(AllowMultipart::class));
425    }
426
427    private function addAction(PathItem $pathItem, ComponentsBuilder $componentsBuilder, RestApiRouteDefinition $routeDefinition): void
428    {
429        $method = $routeDefinition->getMethod();
430        if (!in_array($method, RequestMethod::allowedInOpenApi())) {
431            return;
432        }
433        $inputSchema = $this->createSchemaForInput($componentsBuilder, $routeDefinition);
434        $examples = $this->createExamplesForInput($componentsBuilder, $routeDefinition);
435        $outputSchema = $this->createSchemaForOutput($componentsBuilder, $routeDefinition);
436        $operation = new Operation([
437            'tags' => $routeDefinition->getTags()->toArray(),
438            'description' => $routeDefinition->getDescription(),
439            'operationId' => $routeDefinition->getOperationId(),
440        ]);
441        $parameters = [];
442        $parameters[] = new Parameter([
443            'name' => 'fields',
444            'in' => 'query',
445            'explode' => false,
446            'schema' => new Schema([
447                'type' => 'array',
448                'items' => new Schema([
449                    'type' => 'string',
450                ])
451            ])
452        ]);
453        $parameters[] = new Parameter([
454            'name' => 'relations',
455            'in' => 'query',
456            'explode' => false,
457            'schema' => new Schema([
458                'type' => 'array',
459                'items' => new Schema([
460                    'type' => 'string',
461                ])
462            ])
463        ]);
464        $placeholders = $routeDefinition->getUrl()->getPlaceholders();
465
466        foreach ($placeholders as $placeholderName) {
467            $parameters[] = $this->generateParameter($componentsBuilder, $routeDefinition, $placeholderName);
468        }
469        $operation->parameters = $parameters;
470
471        if ($method !== RequestMethod::GET && $method !== RequestMethod::DELETE) {
472            $content = [
473                'application/json' => new MediaType(array_filter([
474                    'schema' => $inputSchema,
475                    'examples' => $examples,
476                ])),
477            ];
478            if ($this->supportsMultipart($routeDefinition)) {
479                $uploadSchema = $componentsBuilder->runInContentType(
480                    'multipart/form-data',
481                    function () use ($componentsBuilder, $routeDefinition) {
482                        return $this->createSchemaForInput($componentsBuilder, $routeDefinition, true);
483                    }
484                );
485                $content['multipart/form-data'] = new MediaType(array_filter([
486                    'schema' => $uploadSchema,
487                    'examples' => $examples,
488                ]));
489                $parameters = $operation->parameters;
490                $parameters[] = new Parameter([
491                    'name' => 'x-no-crsf',
492                    'in' => 'header',
493                    'description' => 'Disable csrf',
494                    'schema' => [
495                        'type' => 'string',
496                        'enum' => ['1']
497                    ],
498                ]);
499                $operation->parameters = $parameters;
500            }
501            $operation->requestBody = new RequestBody([
502                'content' => $content
503            ]);
504        }
505        $responses = [
506        ];
507        foreach ($routeDefinition->getPossibleActionResponseStatuses() as $responseStatus) {
508            switch ($responseStatus) {
509                case ActionResponseStatus::CREATED:
510                    $responses[201] = new Response([
511                        'description' => 'Resource was created',
512                        'content' => [
513                            'application/json' => new MediaType(['schema' => $outputSchema])
514                        ]
515                    ]);
516                    break;
517                case ActionResponseStatus::SUCCESS:
518                    $responses[200] = new Response([
519                        'description' => 'OK',
520                        'content' => [
521                            'application/json' => new MediaType(['schema' => $outputSchema])
522                        ]
523                    ]);
524                    break;
525                case ActionResponseStatus::CLIENT_ERROR:
526                    foreach ([400, 405, 406] as $statusCode) {
527                        $responses[$statusCode] = new Response([
528                            'description' => 'Invalid request',
529                            'content' => [
530                                'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(NotAcceptedException::class)]),
531                            ]
532                        ]);
533                    }
534                    $responses[422] = new Response([
535                        'description' => 'A validation error occurred',
536                        'content' => [
537                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(ValidationException::class)]),
538                        ]
539                    ]);
540                    break;
541                case ActionResponseStatus::AUTHORIZATION_ERROR:
542                    foreach ([401 => 'Requires authorization', 403 => 'Access denied'] as $statusCode => $description) {
543                        $responses[$statusCode] = new Response([
544                            'description' => $description,
545                            'content' => [
546                                'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(WrongTokenException::class)]),
547                            ]
548                        ]);
549                    }
550                    break;
551                case ActionResponseStatus::DELETED:
552                    $responses[204] = new Response(['description' => 'Resource was deleted']);
553                    break;
554                case ActionResponseStatus::NOT_FOUND:
555                    $responses[404] = new Response([
556                        'description' => 'Resource not found',
557                        'content' => [
558                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
559                        ]
560                    ]);
561                    break;
562                case ActionResponseStatus::PERISTENCE_ERROR:
563                    $responses[409] = new Response([
564                        'description' => 'Resource not found',
565                        'content' => [
566                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
567                        ]
568                    ]);
569                    break;
570                default:
571                    $responses[500] = new Response([
572                        'description' => 'Unknown error occurred',
573                        'content' => [
574                            'application/json' => new MediaType(['schema' => $componentsBuilder->addDisplaySchemaFor(Throwable::class)]),
575                        ]
576                    ]);
577            }
578        }
579        $operation->responses = $responses;
580        $prop = strtolower($method->value);
581        // @phpstan-ignore-next-line
582        $pathItem->{$prop} = $operation;
583        $this->dispatcher->dispatch(
584            new OpenApiOperationAddedEvent(
585                $componentsBuilder,
586                $operation,
587                $routeDefinition,
588                $method
589            )
590        );
591    }
592}