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