Issues (16)

src/Routing/RouteLoader.php (5 issues)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the OpenapiBundle package.
7
 *
8
 * (c) Niels Nijens <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Nijens\OpenapiBundle\Routing;
15
16
use Nijens\OpenapiBundle\Controller\CatchAllController;
17
use Nijens\OpenapiBundle\Json\JsonPointer;
18
use Nijens\OpenapiBundle\Json\Reference;
19
use Nijens\OpenapiBundle\Json\SchemaLoaderInterface;
20
use stdClass;
21
use Symfony\Component\Config\FileLocatorInterface;
22
use Symfony\Component\Config\Loader\FileLoader;
23
use Symfony\Component\HttpFoundation\Request;
24
use Symfony\Component\Routing\Route;
25
use Symfony\Component\Routing\RouteCollection;
26
27
/**
28
 * Loads the paths from an OpenAPI specification as routes.
29
 *
30
 * @author Niels Nijens <[email protected]>
31
 */
32
class RouteLoader extends FileLoader
33
{
34
    /**
35
     * @var string
36
     */
37
    public const TYPE = 'openapi';
38
39
    /**
40
     * @var SchemaLoaderInterface
41
     */
42
    private $schemaLoader;
43
44
    /**
45
     * @var bool
46
     */
47
    private $useOperationIdAsRouteName;
48
49
    /**
50
     * Constructs a new RouteLoader instance.
51
     */
52
    public function __construct(
53
        FileLocatorInterface $locator,
54
        SchemaLoaderInterface $schemaLoader,
55
        bool $useOperationIdAsRouteName = false
56
    ) {
57
        parent::__construct($locator);
58
59
        $this->schemaLoader = $schemaLoader;
60
        $this->useOperationIdAsRouteName = $useOperationIdAsRouteName;
61
    }
62
63
    /**
64
     * {@inheritdoc}
65
     */
66
    public function supports($resource, $type = null): bool
67
    {
68
        return self::TYPE === $type;
69
    }
70
71
    /**
72
     * {@inheritdoc}
73
     */
74
    public function load($resource, $type = null): RouteCollection
75
    {
76
        $file = $this->getLocator()->locate($resource, null, true);
77
78
        $schema = $this->schemaLoader->load($file);
0 ignored issues
show
It seems like $file can also be of type array; however, parameter $file of Nijens\OpenapiBundle\Jso...LoaderInterface::load() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

78
        $schema = $this->schemaLoader->load(/** @scrutinizer ignore-type */ $file);
Loading history...
79
80
        $jsonPointer = new JsonPointer($schema);
81
82
        $routeCollection = new RouteCollection();
83
        $routeCollection->addResource($this->schemaLoader->getFileResource($file));
0 ignored issues
show
It seems like $this->schemaLoader->getFileResource($file) can also be of type null; however, parameter $resource of Symfony\Component\Routin...llection::addResource() does only seem to accept Symfony\Component\Config...ource\ResourceInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

83
        $routeCollection->addResource(/** @scrutinizer ignore-type */ $this->schemaLoader->getFileResource($file));
Loading history...
It seems like $file can also be of type array; however, parameter $file of Nijens\OpenapiBundle\Jso...face::getFileResource() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

83
        $routeCollection->addResource($this->schemaLoader->getFileResource(/** @scrutinizer ignore-type */ $file));
Loading history...
84
85
        $paths = get_object_vars($jsonPointer->get('/paths'));
86
        foreach ($paths as $path => $pathItem) {
87
            $this->parsePathItem($jsonPointer, $file, $routeCollection, $path, $pathItem);
0 ignored issues
show
It seems like $file can also be of type array; however, parameter $resource of Nijens\OpenapiBundle\Rou...Loader::parsePathItem() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

87
            $this->parsePathItem($jsonPointer, /** @scrutinizer ignore-type */ $file, $routeCollection, $path, $pathItem);
Loading history...
88
        }
89
90
        $this->addDefaultRoutes($routeCollection, $file);
0 ignored issues
show
It seems like $file can also be of type array; however, parameter $resource of Nijens\OpenapiBundle\Rou...der::addDefaultRoutes() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

90
        $this->addDefaultRoutes($routeCollection, /** @scrutinizer ignore-type */ $file);
Loading history...
91
92
        return $routeCollection;
93
    }
94
95
    /**
96
     * Parses a path item of the OpenAPI specification for a route.
97
     */
98
    private function parsePathItem(
99
        JsonPointer $jsonPointer,
100
        string $resource,
101
        RouteCollection $collection,
102
        string $path,
103
        stdClass $pathItem
104
    ): void {
105
        $operations = get_object_vars($pathItem);
106
        foreach ($operations as $requestMethod => $operation) {
107
            if ($this->isValidRequestMethod($requestMethod) === false) {
108
                return;
109
            }
110
111
            $this->parseOperation($jsonPointer, $resource, $collection, $path, $requestMethod, $operation, $pathItem);
112
        }
113
    }
114
115
    /**
116
     * Parses an operation of the OpenAPI specification for a route.
117
     */
118
    private function parseOperation(
119
        JsonPointer $jsonPointer,
120
        string $resource,
121
        RouteCollection $collection,
122
        string $path,
123
        string $requestMethod,
124
        stdClass $operation,
125
        stdClass $pathItem
126
    ): void {
127
        $defaults = [];
128
        $openapiRouteContext = [
129
            RouteContext::RESOURCE => $resource,
130
        ];
131
132
        $this->parseOpenapiBundleSpecificationExtension($operation, $defaults, $openapiRouteContext);
133
        $this->addRouteContextForValidation(
134
            $jsonPointer,
135
            $path,
136
            $requestMethod,
137
            $operation,
138
            $pathItem,
139
            $openapiRouteContext
140
        );
141
142
        $defaults[RouteContext::REQUEST_ATTRIBUTE] = $openapiRouteContext;
143
144
        $route = new Route($path, $defaults, []);
145
        $route->setMethods($requestMethod);
146
147
        $routeName = null;
148
        if ($this->useOperationIdAsRouteName && isset($operation->operationId)) {
149
            $routeName = $operation->operationId;
150
        }
151
152
        $collection->add(
153
            $routeName ?? $this->createRouteName($path, $requestMethod),
154
            $route
155
        );
156
    }
157
158
    private function parseOpenapiBundleSpecificationExtension(stdClass $operation, array &$defaults, array &$openapiRouteContext): void
159
    {
160
        if (isset($operation->{'x-openapi-bundle'}->controller)) {
161
            $defaults['_controller'] = $operation->{'x-openapi-bundle'}->controller;
162
        }
163
164
        if (isset($operation->{'x-openapi-bundle'}->deserializationObject)) {
165
            $openapiRouteContext[RouteContext::DESERIALIZATION_OBJECT] = $operation->{'x-openapi-bundle'}->deserializationObject;
166
        }
167
168
        if (isset($operation->{'x-openapi-bundle'}->deserializationObjectArgumentName)) {
169
            $openapiRouteContext[RouteContext::DESERIALIZATION_OBJECT_ARGUMENT_NAME] = $operation->{'x-openapi-bundle'}->deserializationObjectArgumentName;
170
        }
171
172
        if (isset($operation->{'x-openapi-bundle'}->additionalRouteAttributes)) {
173
            $additionalRouteAttributes = get_object_vars($operation->{'x-openapi-bundle'}->additionalRouteAttributes);
174
            foreach ($additionalRouteAttributes as $key => $value) {
175
                $defaults[$key] = $value;
176
            }
177
        }
178
    }
179
180
    private function addRouteContextForValidation(
181
        JsonPointer $jsonPointer,
182
        string $path,
183
        string $requestMethod,
184
        stdClass $operation,
185
        stdClass $pathItem,
186
        array &$openapiRouteContext
187
    ): void {
188
        $openapiRouteContext[RouteContext::REQUEST_BODY_REQUIRED] = false;
189
        if (isset($operation->requestBody->required)) {
190
            $openapiRouteContext[RouteContext::REQUEST_BODY_REQUIRED] = $operation->requestBody->required;
191
        }
192
193
        $openapiRouteContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES] = [];
194
        if (isset($operation->requestBody->content)) {
195
            $openapiRouteContext[RouteContext::REQUEST_ALLOWED_CONTENT_TYPES] = array_keys(
196
                get_object_vars($operation->requestBody->content)
197
            );
198
        }
199
200
        $openapiRouteContext[RouteContext::REQUEST_VALIDATE_QUERY_PARAMETERS] = [];
201
        $parameters = array_merge(
202
            $pathItem->parameters ?? [],
203
            $operation->parameters ?? []
204
        );
205
        foreach ($parameters as $parameter) {
206
            if ($parameter->in !== 'query') {
207
                continue;
208
            }
209
210
            if ($parameter instanceof Reference) {
211
                $parameter = $parameter->resolve();
212
            }
213
214
            $openapiRouteContext[RouteContext::REQUEST_VALIDATE_QUERY_PARAMETERS][$parameter->name] = json_encode($parameter);
215
        }
216
217
        if (isset($operation->requestBody->content->{'application/json'}->schema)) {
218
            // Escape %-characters to prevent the router from interpreting them as service container parameters.
219
            $openapiRouteContext[RouteContext::REQUEST_BODY_SCHEMA] = str_replace(
220
                '%',
221
                '%%',
222
                serialize($operation->requestBody->content->{'application/json'}->schema)
223
            );
224
        }
225
226
        if (isset($operation->requestBody->content->{'application/json'})) {
227
            $openapiRouteContext[RouteContext::JSON_REQUEST_VALIDATION_POINTER] = sprintf(
228
                '/paths/%s/%s/requestBody/content/%s/schema',
229
                $jsonPointer->escape($path),
230
                $requestMethod,
231
                $jsonPointer->escape('application/json')
232
            );
233
        }
234
    }
235
236
    /**
237
     * Returns true when the provided request method is a valid request method in the OpenAPI specification.
238
     */
239
    private function isValidRequestMethod(string $requestMethod): bool
240
    {
241
        return in_array(
242
            strtoupper($requestMethod),
243
            [
244
                Request::METHOD_GET,
245
                Request::METHOD_PUT,
246
                Request::METHOD_POST,
247
                Request::METHOD_DELETE,
248
                Request::METHOD_OPTIONS,
249
                Request::METHOD_HEAD,
250
                Request::METHOD_PATCH,
251
                Request::METHOD_TRACE,
252
            ]
253
        );
254
    }
255
256
    /**
257
     * Creates a route name based on the path and request method.
258
     */
259
    private function createRouteName(string $path, string $requestMethod): string
260
    {
261
        return sprintf('%s_%s',
262
            trim(preg_replace('/[^a-zA-Z0-9]+/', '_', $path), '_'),
263
            $requestMethod
264
        );
265
    }
266
267
    /**
268
     * Adds a catch-all route to handle responses for non-existing routes.
269
     */
270
    private function addDefaultRoutes(RouteCollection $collection, string $resource): void
271
    {
272
        $catchAllRoute = new Route(
273
            '/{catchall}',
274
            [
275
                '_controller' => CatchAllController::CONTROLLER_REFERENCE,
276
                RouteContext::REQUEST_ATTRIBUTE => [RouteContext::RESOURCE => $resource],
277
            ],
278
            ['catchall' => '.*']
279
        );
280
281
        $collection->add('catch_all', $catchAllRoute);
282
    }
283
}
284