nijens /
openapi-bundle
| 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
Bug
introduced
by
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
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
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
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
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 |