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