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