├── .circleci └── config.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.md ├── README.md ├── composer.json ├── config └── openapi.php ├── docker-compose.override.yml ├── docker-compose.yml └── src ├── Exceptions └── OpenApiException.php ├── Http └── Middleware │ └── ValidateOpenApi.php └── OpenApiServiceProvider.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/php:7.3-node-browsers 7 | working_directory: ~/package # directory where steps will run 8 | steps: 9 | - checkout 10 | - run: sudo apt install -y libsqlite3-dev zlib1g-dev 11 | - run: sudo docker-php-ext-install zip 12 | - run: sudo composer self-update 13 | - run: composer install -n --prefer-dist 14 | - save_cache: 15 | key: composer-v1-{{ checksum "composer.lock" }} 16 | paths: 17 | - vendor 18 | - run: ./vendor/bin/phpunit 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7-cli 2 | 3 | # Avoid warnings by switching to noninteractive 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | # Update 8 | RUN apt-get update 9 | 10 | # This Dockerfile adds a non-root 'developer' user with sudo access. However, for Linux, 11 | # this user's GID/UID must match your local user UID/GID to avoid permission issues 12 | # with bind mounts. Update USER_UID / USER_GID if yours is not 1000. 13 | ARG USER_USERNAME=developer 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | 17 | # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. 18 | RUN groupadd --gid $USER_GID $USER_USERNAME \ 19 | && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USER_USERNAME \ 20 | && apt-get install -y sudo \ 21 | && echo $USER_USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USER_USERNAME\ 22 | && chmod 0440 /etc/sudoers.d/$USER_USERNAME 23 | 24 | # Configure apt and install packages 25 | RUN apt-get update \ 26 | && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ 27 | # 28 | # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed 29 | && apt-get -y install git iproute2 procps lsb-release 30 | 31 | # Install PHP dependencies and extensions 32 | RUN apt-get install -y --no-install-recommends \ 33 | bash-completion \ 34 | mariadb-client \ 35 | less \ 36 | sudo \ 37 | ssh \ 38 | curl \ 39 | git \ 40 | vim \ 41 | nano \ 42 | wget \ 43 | unzip \ 44 | libzip-dev \ 45 | libmemcached-dev \ 46 | libjpeg-dev \ 47 | libz-dev \ 48 | libpq-dev \ 49 | libssl-dev \ 50 | libmcrypt-dev \ 51 | libldap2-dev \ 52 | pcscd \ 53 | scdaemon \ 54 | gnupg2 \ 55 | pcsc-tools 56 | 57 | RUN docker-php-ext-install pdo_mysql \ 58 | && docker-php-ext-install zip \ 59 | && docker-php-ext-install ldap 60 | 61 | # Configure xDebug 62 | RUN yes | pecl install xdebug \ 63 | && echo "xdebug.remote_enable=on" >> /usr/local/etc/php/conf.d/xdebug.ini \ 64 | && echo "xdebug.remote_autostart=off" >> /usr/local/etc/php/conf.d/xdebug.ini \ 65 | && echo "xdebug.default_enable=off" >> /usr/local/etc/php/conf.d/xdebug.ini \ 66 | && echo "xdebug.ide_key=DEBUG" >> /usr/local/etc/php/conf.d/xdebug.ini \ 67 | && echo "xdebug.remote_port=9000" >> /usr/local/etc/php/conf.d/xdebug.ini 68 | 69 | ARG ENABLE_XDEBUG 70 | RUN if [ "x$ENABLE_XDEBUG" = "x1" ] ; then docker-php-ext-enable xdebug; else echo Skipping xdebug activation!; fi 71 | 72 | # Install Composer 73 | RUN curl --silent --show-error https://getcomposer.org/installer | php && \ 74 | mv composer.phar /usr/local/bin/composer 75 | 76 | # Install NodeJS 77 | RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - \ 78 | && apt-get install -y nodejs 79 | 80 | # Clean up 81 | RUN apt-get autoremove -y \ 82 | && apt-get clean -y \ 83 | && rm -rf /var/lib/apt/lists/* 84 | 85 | # Switch back to dialog for any ad-hoc use of apt-get 86 | ENV DEBIAN_FRONTEND= 87 | 88 | WORKDIR /package 89 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Dustin Wheeler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI-driven routing and validation for Laravel 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/vpre/mdwheele/laravel-openapi.svg?style=flat-square)](https://packagist.org/packages/mdwheele/laravel-openapi) 4 | ![PHP from Packagist](https://img.shields.io/packagist/php-v/mdwheele/laravel-openapi) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/mdwheele/laravel-openapi.svg?style=flat-square)](https://packagist.org/packages/mdwheele/laravel-openapi) 6 | [![CircleCI](https://circleci.com/gh/mdwheele/laravel-openapi.svg?style=svg)](https://circleci.com/gh/mdwheele/laravel-openapi) 7 | 8 | 9 | This package allows you to create a single specification for your Laravel application that will register routes and validate all requests your API receives as well as all responses that you generate. 10 | 11 | The [OpenAPI](https://github.com/OAI/OpenAPI-Specification) development experience in PHP feels disjoint... 12 | 13 | * I can update my OpenAPI specification with no impact on the actual implementation, leaving room for drift. 14 | * I can try and glue them together with process and custom tooling, but I feel like I'm gluing 9,001 pieces of the internet together and it's different for each project. I'd prefer if someone else to do that work. 15 | * Documentation generators are **AMAZING**, but if there's nothing to stop implementation from drifting away from documentation, then is it worth it? 16 | * Tooling to validate JSON Schema is great, but the error messages I get back are hard to grok for beginners and aren't always obvious. 17 | 18 | This package aims to create a positive developer experience where you truly have a **single source of record**, your OpenAPI specification. From this, the package will automatically register routes with Laravel. Additionally, it will attach a [Middleware](https://laravel.com/docs/master/middleware) to these routes that will validate all incoming requests and outgoing responses. When the package detects a mismatch in implementation and specification, you'll get a **helpful** error message that hints at **what to do next**. 19 | 20 | 21 | ## Installation 22 | 23 | You can install the package through Composer. 24 | 25 | ```bash 26 | composer require mdwheele/laravel-openapi 27 | ``` 28 | 29 | Optionally, you can publish the config file of this package with this command: 30 | 31 | ```bash 32 | php artisan vendor:public --provider="Mdwheele\OpenApi\OpenApiServiceProvider" 33 | ``` 34 | 35 | The following config file will be published in `config/openapi.php`: 36 | 37 | ```php 38 | env('OPENAPI_PATH'), 46 | 47 | /* 48 | * Whether or not to validate response schemas. You may want to 49 | * enable this in development and disable in production. Do as you 50 | * wish! 51 | */ 52 | 'validate_responses' => env('OPENAPI_VALIDATE_RESPONSES', true) 53 | 54 | ]; 55 | ``` 56 | 57 | ## Usage 58 | 59 | Configure `OPENAPI_PATH` to point at your top-level specification. The package will parse your OpenAPI specification to create appropriate routes and attach the `ValidateOpenApi` middleware. The middleware validates all requests coming to your API as well as all responses that you generate from your Controllers. If the middleware encounters a validation error, it will throw an `OpenApiException`, which will have a summary error message along with a bag of detailed errors describing what's wrong (as best as we can). 60 | 61 | It is a good idea to incorporate this into your normal exception handler like so: 62 | 63 | ```php 64 | class Handler extends Exception Handler 65 | { 66 | /** 67 | * Render an exception into an HTTP response. 68 | * 69 | * @param \Illuminate\Http\Request $request 70 | * @param \Exception $exception 71 | * @return \Illuminate\Http\Response 72 | */ 73 | public function render($request, Exception $exception) 74 | { 75 | // This is only an example. You can format this however you 76 | // wish. The point is that the library gives you easy access to 77 | // "what went wrong" so you can react accordingly. 78 | if ($exception instanceof OpenApiException) { 79 | return response()->json([ 80 | 'message' => $exception->getMessage(), 81 | 'errors' => $exception->getErrors(), 82 | ], 400); 83 | } 84 | 85 | return parent::render($request, $exception); 86 | } 87 | } 88 | ``` 89 | 90 | When you generate a response that doesn't match the OpenApi schema you've specified, you'll get something like the following: 91 | 92 | ```json 93 | { 94 | "message": "The response from CoreFeaturesController@healthCheck does not match your OpenAPI specification.", 95 | "errors": [ 96 | "The [status] property must be one of [ok, warning, critical].", 97 | "The [updates[0].timestamp] property is missing. It must be included.", 98 | "The property unsupported is not defined and the definition for [updates[0]] does not allow additional properties." 99 | ] 100 | } 101 | ``` 102 | 103 | As a further example, check out the following API specification. 104 | 105 | ```yaml 106 | openapi: "3.0.0" 107 | info: 108 | version: 1.0.0 109 | title: Your Application 110 | servers: 111 | - url: https://localhost/api 112 | paths: 113 | /pets: 114 | get: 115 | summary: List all pets 116 | operationId: App\Http\Controllers\PetsController@index 117 | responses: 118 | '200': 119 | description: An array of Pets. 120 | content: 121 | application/json: 122 | schema: 123 | type: array 124 | items: 125 | $ref: '#/components/schemas/Pet' 126 | components: 127 | schemas: 128 | Pet: 129 | type: object 130 | required: 131 | - id 132 | - name 133 | properties: 134 | id: 135 | type: integer 136 | format: int64 137 | name: 138 | type: string 139 | ``` 140 | 141 | This specification says that there will be an endpoint at `https://localhost/api/pets` that can receive a `GET` request and will only return responses with a `200` status code. Those successful responses will return `application/json` that contains an `array` of JavaScript objects that **MUST** have both an `id` (that is an `integer`) and a `name` (that can be any string). 142 | 143 | Any of the following circumstances will trigger an `OpenApiException` that will include more information on what's needed in order to resolve the mismatch between your implementation and the OpenAPI specification you've designed: 144 | 145 | - If you return `403` response from `/api/pets`, you'll get an exception that explains that there is no specified response for `403` and there is no `default` handler. 146 | - If you return anything other than `application/json`, you'll get a similar exception explaining the acceptable media types that can be returned. 147 | - If you return JavaScript objects that use a `string`-based `id` (e.g. `id: 'foo'`), you'll be told that the response your controller generated does not match the specified JSON Schema. Additionally, you'll be given some pointers as to what, specifically, was wrong and some hints on how to resolve. 148 | 149 | ## Caution! 150 | 151 | :mute: **Opinion Alert** *... and feel free to take with grain of salt.* 152 | 153 | Just as over-specifying tests can leave a bad taste in your mouth, over-specifying your API can lead you down a path of resistance and analysis paralysis. When you're using JSON Schema to specify request bodies, parameters and responses, **take care** to understand that you are specifying valid **HTTP messages**, not necessarily every business rule in your system. 154 | 155 | For example, I've seen many folks get stuck with "But @mdwheele! I need to have conditional responses because when X happens, I need one response. But when Y happens, I need a totally different response.". My advice on this is to write tests :grinning:. What this library does for you is allows confidence to not have to write *tons* of structural tests just to make sure everything is under a top-level `data` envelope; that `filter` is allowed as a query parameter, etc. 156 | 157 | Another way to think of this is the difference between "form validation" and "business invariants". There *is* an overlap many times, but the goals are different. Form validation (or OpenAPI specification validation) says "Do I have a valid HTTP message?" while business rules are more nuanced (e.g. "Is this user approved to create purchase orders totaling more than $5,000?"). 158 | 159 | ## Roadmap 160 | 161 | - [ ] Continue to improve error messages to be as helpful as possible. In the mean time, use the package and if it's ever unclear how to respond to an error message, [send it in](https://github.com/mdwheele/laravel-openapi/issues/new) as a bug. 162 | - [x] Add additional specification examples to guarantee we're casting a wide net to accommodate as many use-cases as possible. 163 | - [ ] Improve framework integration error handling. 164 | 165 | ### Testing 166 | 167 | ``` bash 168 | composer test 169 | ``` 170 | 171 | ## Contributing 172 | 173 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 174 | 175 | ### Security 176 | 177 | If you discover any security related issues, please email mdwheele@gmail.com instead of using the issue tracker. 178 | 179 | ## Credits 180 | 181 | - [Dustin Wheeler](https://github.com/mdwheele) 182 | - [All Contributors](../../contributors) 183 | 184 | ## License 185 | 186 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 187 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mdwheele/laravel-openapi", 3 | "version": "0.1.0", 4 | "description": "", 5 | "keywords": [ 6 | "swagger", 7 | "openapi", 8 | "laravel" 9 | ], 10 | "homepage": "https://github.com/mdwheele/laravel-openapi", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Dustin Wheeler", 15 | "email": "mdwheele@gmail.com", 16 | "homepage": "https://mdwheele.github.io", 17 | "role": "Developer" 18 | } 19 | ], 20 | "require": { 21 | "php": "^7.3", 22 | "ext-json": "*", 23 | "cebe/php-openapi": "^1.2", 24 | "justinrainbow/json-schema": "^5.2" 25 | }, 26 | "require-dev": { 27 | "larapack/dd": "^1.0", 28 | "orchestra/testbench": "^4.0", 29 | "phpunit/phpunit": "^8.2" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Mdwheele\\OpenApi\\": "src" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Mdwheele\\OpenApi\\Tests\\": "tests" 39 | } 40 | }, 41 | "scripts": { 42 | "test": "vendor/bin/phpunit", 43 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "extra": { 49 | "branch-alias": { 50 | "dev-master": "1.x-dev" 51 | }, 52 | "laravel": { 53 | "providers": [ 54 | "Mdwheele\\OpenApi\\OpenApiServiceProvider" 55 | ] 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/openapi.php: -------------------------------------------------------------------------------- 1 | env('OPENAPI_PATH'), 9 | 10 | /* 11 | * Whether or not to validate response schemas. You may want to 12 | * enable this in development and disable in production. Do as you 13 | * wish! 14 | */ 15 | 'validate_responses' => env('OPENAPI_VALIDATE_RESPONSES', true) 16 | 17 | ]; 18 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | php: 5 | volumes: 6 | - ${HOME}:/home/${USER_USERNAME} 7 | - ${GPG_AGENT_SOCKET}:/home/${USER_USERNAME}/.gnupg/S.gpg-agent 8 | - ${GPG_SSH_SOCKET}:/home/${USER_USERNAME}/.gnupg/S.gpg-agent.ssh -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | php: 6 | user: ${USER_USERNAME:-developer} 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | args: 11 | USER_USERNAME: ${USER_USERNAME:-developer} 12 | USER_UID: ${USER_UID:-1000} 13 | USER_GID: ${USER_GID:-1000} 14 | image: laravel-openapi:dev 15 | environment: 16 | USER_USERNAME: ${USER_USERNAME:-vscode} 17 | USER_UID: ${USER_UID:-1000} 18 | USER_GID: 19 | stop_signal: SIGKILL 20 | command: sleep infinity 21 | volumes: 22 | - .:/package 23 | -------------------------------------------------------------------------------- /src/Exceptions/OpenApiException.php: -------------------------------------------------------------------------------- 1 | errors = $errors; 16 | return $instance; 17 | } 18 | 19 | public function getRawErrors() 20 | { 21 | return $this->errors; 22 | } 23 | 24 | public function getErrors() 25 | { 26 | return array_map(function ($error) { 27 | switch ($error['constraint']) { 28 | case 'enum': 29 | $enum = implode(', ', $error['enum']); 30 | return "The [{$error['property']}] property must be one of [{$enum}]."; 31 | break; 32 | case 'required': 33 | return "The [{$error['property']}] property is missing. It must be included."; 34 | break; 35 | case 'additionalProp': 36 | return str_replace('definition', "definition for [{$error['property']}]", $error['message']) . '.'; 37 | break; 38 | case 'type': 39 | return $error['message'] . " for property [${error['property']}]."; 40 | default: 41 | return $error['message']; 42 | } 43 | }, $this->errors); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Http/Middleware/ValidateOpenApi.php: -------------------------------------------------------------------------------- 1 | route()->action['openapi.operation']; 29 | 30 | $this->validateParameters($request, $operation); 31 | 32 | if ($operation->requestBody !== null) { 33 | $this->validateBody($request, $operation); 34 | } 35 | 36 | $response = $next($request); 37 | 38 | if (config('openapi.validate_responses') === true && $operation->responses !== null) { 39 | $this->validateResponse($response, $operation); 40 | } 41 | 42 | return $response; 43 | } 44 | 45 | /** 46 | * @param Request $request 47 | * @param Operation $operation 48 | * @throws OpenApiException 49 | */ 50 | private function validateParameters($request, Operation $operation) 51 | { 52 | $route = $request->route(); 53 | $parameters = $operation->parameters; 54 | 55 | foreach ($parameters as $parameter) { 56 | // Verify presence, if required. 57 | if ($parameter->required === true) { 58 | // Parameters can be found in query, header, path or cookie. 59 | if ($parameter->in === 'path' && !$route->hasParameter($parameter->name)) { 60 | throw new OpenApiException("Missing required parameter {$parameter->name} in URL path."); 61 | } elseif ($parameter->in === 'query' && !$request->query->has($parameter->name)) { 62 | throw new OpenApiException("Missing required query parameter [?{$parameter->name}=]."); 63 | } elseif ($parameter->in === 'header' && !$request->headers->has($parameter->name)) { 64 | throw new OpenApiException("Missing required header [{$parameter->name}]."); 65 | } elseif ($parameter->in === 'cookie' && !$request->cookies->has($parameter->name)) { 66 | throw new OpenApiException("Missing required cookie [{$parameter->name}]."); 67 | } 68 | } 69 | 70 | // Validate schemas, if provided. Required or not. 71 | if ($parameter->schema) { 72 | $validator = new Validator(); 73 | $jsonSchema = $parameter->schema->getSerializableData(); 74 | 75 | if ($parameter->in === 'path' && $route->hasParameter($parameter->name)) { 76 | $data = $route->parameters(); 77 | $validator->coerce($data[$parameter->name], $jsonSchema); 78 | } elseif ($parameter->in === 'query' && $request->query->has($parameter->name)) { 79 | $data = $request->query->get($parameter->name); 80 | $validator->coerce($data, $jsonSchema); 81 | } elseif ($parameter->in === 'header' && $request->headers->has($parameter->name)) { 82 | $data = $request->headers->get($parameter->name); 83 | $validator->coerce($data, $jsonSchema); 84 | } elseif ($parameter->in === 'cookie' && $request->cookies->has($parameter->name)) { 85 | $data = $request->cookies->get($parameter->name); 86 | $validator->coerce($data, $jsonSchema); 87 | } 88 | 89 | if (!$validator->isValid()) { 90 | throw OpenApiException::withSchemaErrors( 91 | "Parameter [{$parameter->name}] did not match provided JSON schema.", 92 | $validator->getErrors() 93 | ); 94 | } 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * @param Request $request 101 | * @param Operation $operation 102 | * @throws OpenApiException 103 | */ 104 | private function validateBody($request, Operation $operation) 105 | { 106 | $contentType = $request->header('Content-Type'); 107 | $body = $request->getContent(); 108 | $requestBody = $operation->requestBody; 109 | 110 | if ($requestBody->required === true) { 111 | if (empty($body)) { 112 | throw new OpenApiException('Request body required.'); 113 | } 114 | } 115 | 116 | if (empty($request->getContent())) { 117 | return; 118 | } 119 | 120 | // This isn't good enough for production. This is an *exact* match 121 | // for the media type and does not really take into account media ranges 122 | // at all. We'll fix this later. 123 | if (!array_key_exists($contentType, $requestBody->content) ) { 124 | throw new OpenApiException('Request did not match any specified media type for request body.'); 125 | } 126 | 127 | $jsonSchema = $requestBody->content[$contentType]->schema; 128 | $validator = new Validator(); 129 | 130 | if ($jsonSchema->type === 'object' || $jsonSchema->type === 'array') { 131 | if ($contentType === 'application/json') { 132 | $body = json_decode($body); 133 | } else { 134 | throw new OpenApiException("Unable to map [{$contentType}] to schema type [object]."); 135 | } 136 | } 137 | 138 | $validator->coerce($body, $jsonSchema->getSerializableData()); 139 | 140 | if ($validator->isValid() !== true) { 141 | throw OpenApiException::withSchemaErrors( 142 | "Request body did not match provided JSON schema.", 143 | $validator->getErrors() 144 | ); 145 | } 146 | } 147 | 148 | /** 149 | * @param Response $response 150 | * @param Operation $operation 151 | * @throws OpenApiException 152 | */ 153 | private function validateResponse($response, Operation $operation) 154 | { 155 | $contentType = $response->headers->get('Content-Type'); 156 | $body = $response->getContent(); 157 | $responses = $operation->responses; 158 | 159 | $shortHandler = class_basename($operation->operationId); 160 | 161 | // Get matching response object based on status code. 162 | if ($responses[$response->getStatusCode()] !== null) { 163 | $responseObject = $responses[$response->getStatusCode()]; 164 | } elseif ($responses['default'] !== null) { 165 | $responseObject = $responses['default']; 166 | } else { 167 | throw new OpenApiException("No response object matching returned status code [{$response->getStatusCode()}]."); 168 | } 169 | 170 | // This isn't good enough for production. This is an *exact* match 171 | // for the media type and does not really take into account media ranges 172 | // at all. We'll fix this later. 173 | if (!array_key_exists($contentType, $responseObject->content)) { 174 | throw new OpenApiException('Response did not match any specified media type.'); 175 | } 176 | 177 | $jsonSchema = $responseObject->content[$contentType]->schema; 178 | $validator = new Validator(); 179 | 180 | if ($jsonSchema->type === 'object' || $jsonSchema->type === 'array') { 181 | if ($contentType === 'application/json') { 182 | $body = json_decode($body); 183 | } else { 184 | throw new OpenApiException("Unable to map [{$contentType}] to schema type [object]."); 185 | } 186 | } 187 | 188 | $validator->coerce($body, $jsonSchema->getSerializableData()); 189 | 190 | if ($validator->isValid() !== true) { 191 | throw OpenApiException::withSchemaErrors( 192 | "The response from {$shortHandler} does not match your OpenAPI specification.", 193 | $validator->getErrors() 194 | ); 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/OpenApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | openapi = Reader::readFromYamlFile(config('openapi.path')); 26 | $this->app->instance(OpenApi::class, $this->openapi); 27 | 28 | $this->mergeConfigFrom(__DIR__.'/../config/openapi.php', 'openapi'); 29 | } 30 | 31 | public function boot() 32 | { 33 | $this->publishes([ 34 | __DIR__.'/../config/openapi.php' => config_path('openapi.php'), 35 | ]); 36 | 37 | $this->registerApiRoutes(); 38 | } 39 | 40 | private function registerApiRoutes() 41 | { 42 | Route::prefix($this->getApiPrefix())->group(function () { 43 | foreach ($this->openapi->paths as $path => $pathItem) { 44 | foreach ($pathItem->getOperations() as $method => $operation) { 45 | Route::{$method}($path, [ 46 | 'uses' => $this->getMappedAction($operation), 47 | 'middleware' => ValidateOpenApi::class, 48 | 'openapi.operation' => $operation 49 | ]); 50 | } 51 | } 52 | }); 53 | 54 | return $this; 55 | } 56 | 57 | private function getApiPrefix() 58 | { 59 | return 'api'; 60 | } 61 | 62 | private function getMappedAction(Operation $operation) 63 | { 64 | if ($operation->operationId === null) { 65 | throw new OpenApiException('All operations must have an `operationId`.'); 66 | } 67 | 68 | [$class, $method] = explode('@', $operation->operationId); 69 | 70 | try { 71 | $controller = new \ReflectionClass($class); 72 | } catch (ReflectionException $e) { 73 | throw OpenApiException::wrapPrevious($e->getMessage(), $e); 74 | } 75 | 76 | if ($controller->hasMethod($method) === false) { 77 | throw new OpenApiException("Controller ${class} does not have a method named ${method}."); 78 | } 79 | 80 | return $operation->operationId; 81 | } 82 | } 83 | --------------------------------------------------------------------------------