├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── example.launch.json ├── example.settings.json └── extensions.json ├── LICENSE ├── SETUP.md ├── docs ├── README.md ├── _config.yml ├── index.html └── openapidriver.html ├── poetry.lock ├── pyproject.toml ├── src └── OpenApiDriver │ ├── __init__.py │ ├── openapi_executors.py │ ├── openapi_reader.py │ ├── openapidriver.libspec │ ├── openapidriver.py │ └── py.typed ├── tasks.py └── tests ├── files ├── mismatched_openapi.json ├── petstore_openapi.json └── petstore_openapi.yaml ├── rf_cli.args ├── server └── testserver.py ├── suites ├── load_from_url.robot ├── load_json.robot ├── load_yaml.robot └── test_mismatching_schemas.robot ├── unittests ├── test_openapi_reader.py └── test_openapidriver.py ├── user_implemented └── custom_user_mappings.py └── variables.py /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim-bookworm 2 | 3 | RUN pip install --upgrade pip 4 | 5 | # poetry install into the default Python interpreter since we're in a container 6 | RUN pip install poetry 7 | RUN poetry config virtualenvs.create false 8 | RUN poetry config virtualenvs.in-project false 9 | 10 | # Copy the pyproject.toml and poetry.lock file to be able to install dependencies using poetry 11 | COPY pyproject.toml pyproject.toml 12 | COPY poetry.lock poetry.lock 13 | 14 | EXPOSE 8888 15 | ENTRYPOINT /bin/sh 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Local Dockerfile", 5 | "build": { 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 9 | "dockerfile": "./Dockerfile", 10 | "args": { 11 | } 12 | }, 13 | "postCreateCommand": "poetry install", 14 | // Configure tool-specific properties. 15 | "customizations": { 16 | "vscode": { 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": { 19 | "robotcode.robot.variables": { 20 | "ROOT": "/workspaces/robotframework-openapidriver" 21 | } 22 | }, 23 | "extensions": [ 24 | "ms-python.python", 25 | "ms-python.vscode-pylance", 26 | "charliermarsh.ruff", 27 | "d-biehl.robotcode", 28 | "ms-azuretools.vscode-docker", 29 | "Gruntfuggly.todo-tree", 30 | "shardulm94.trailing-spaces" 31 | ] 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | with: 13 | fetch-depth: 1 14 | 15 | - name: Set up Python 3.9 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: 3.9 19 | 20 | - name: Install and configure Poetry 21 | uses: snok/install-poetry@v1 22 | with: 23 | virtualenvs-create: true 24 | virtualenvs-in-project: false 25 | virtualenvs-path: ~/.virtualenvs 26 | installer-parallel: true 27 | 28 | - name: Cache poetry virtualenv 29 | uses: actions/cache@v1 30 | id: cache 31 | with: 32 | path: ~/.virtualenvs 33 | key: poetry-${{ hashFiles('**/poetry.lock') }} 34 | restore-keys: | 35 | poetry-${{ hashFiles('**/poetry.lock') }} 36 | 37 | - name: Install dependencies 38 | run: poetry install 39 | if: steps.cache.outputs.cache-hit != 'true' 40 | 41 | - name: Run tests 42 | run: | 43 | poetry run invoke testserver & 44 | poetry run invoke tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | 112 | # ruff 113 | .ruff_cache 114 | 115 | .idea/ 116 | 117 | .dmypy.json 118 | dmypy.json 119 | 120 | # Pyre type checker 121 | .pyre/ 122 | 123 | # Robot Framework logs 124 | log.html 125 | output.xml 126 | report.html 127 | tests/logs 128 | 129 | # IDE config 130 | .vscode/launch.json 131 | .vscode/settings.json 132 | 133 | # PowerShell utility scripts 134 | _*.ps1 -------------------------------------------------------------------------------- /.vscode/example.launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "RobotCode: Default", 9 | "type": "robotcode", 10 | "request": "launch", 11 | "purpose": "default", 12 | "presentation": { 13 | "hidden": true 14 | }, 15 | "attachPython": true, 16 | "pythonConfiguration": "RobotCode: Python", 17 | "args": [ 18 | "--variable=ROOT:${workspaceFolder}", 19 | "--outputdir=${workspaceFolder}/tests/logs", 20 | "--loglevel=TRACE:INFO" 21 | ] 22 | }, 23 | { 24 | "name": "RobotCode: Python", 25 | "type": "python", 26 | "request": "attach", 27 | "presentation": { 28 | "hidden": true 29 | }, 30 | "justMyCode": false 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/example.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "robotcode.robot.variables": { 3 | "ROOT": "/workspaces/robotframework-openapidriver" 4 | } 5 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-vscode-remote.remote-containers", 4 | "ms-python.python", 5 | "ms-python.vscode-pylance", 6 | "charliermarsh.ruff", 7 | "d-biehl.robotcode" 8 | ] 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | ## Preparing your system to run Python-based tests 2 | 3 | This repo uses [poetry](https://python-poetry.org/) for Python environment isolation and package management. Before poetry can be installed, Python must be installed. The minimum version to be 4 | installed can be found in the `pyproject.toml` file (e.g. python = "^3.8"). The appropriate 5 | download for your OS can be found [here](https://www.python.org/downloads/). 6 | 7 | After installing Python, poetry can be installed. For OSX/ Linux / bashonwindows the command is: 8 | 9 | ```curl 10 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python - 11 | ``` 12 | 13 | For Windows the PowerShell command is: 14 | 15 | ```powershell 16 | (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | python - 17 | ``` 18 | 19 | To ensure the install succeeded, you can open a new shell and run 20 | ``` 21 | poetry --version 22 | ``` 23 | > Windows users: if this does not work, see https://python-poetry.org/docs/master/#windows-powershell-install-instructions 24 | 25 | Next poetry can be configured to create virtual environments for repos within the repo 26 | (this makes it easy to locate the .venv for a given repo if you want to clean / delete it): 27 | ``` 28 | poetry config virtualenvs.in-project true 29 | ``` 30 | Now that poetry is set up, the project's Python dependencies can be installed: 31 | ``` 32 | poetry install 33 | ``` 34 | 35 | ## Running tests using poetry and invoke 36 | 37 | In addition to poetry, the [invoke](http://www.pyinvoke.org/index.html) package is used to 38 | create tasks that can be ran on all platforms in the same manner. These tasks are defined in 39 | the `tasks.py` file in the root of the repo. To see which tasks are available, run 40 | ``` 41 | poetry run inv --list 42 | ``` 43 | > If the `.venv` is activated in the current shell, this can be shortened to `inv --list` 44 | 45 | 46 | Further information / documentation of the tasks (if available) can be shown using 47 | ``` 48 | poetry run inv --help 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | # OpenApiDriver for Robot Framework® 5 | 6 | > OpenApiDriver has moved! 7 | Please head over to https://github.com/MarketSquare/robotframework-openapitools for the latest version! 8 | 9 | OpenApiDriver is an extension of the Robot Framework® DataDriver library that allows 10 | for generation and execution of test cases based on the information in an OpenAPI 11 | document (also known as Swagger document). 12 | This document explains how to use the OpenApiDriver library. 13 | 14 | For more information about Robot Framework®, see http://robotframework.org. 15 | 16 | For more information about the DataDriver library, see 17 | https://github.com/Snooz82/robotframework-datadriver. 18 | 19 | --- 20 | 21 | > Note: OpenApiDriver is still under development so there are currently 22 | restrictions / limitations that you may encounter when using this library to run 23 | tests against an API. See [Limitations](#limitations) for details. 24 | 25 | --- 26 | 27 | ## Installation 28 | 29 | If you already have Python >= 3.8 with pip installed, you can simply run: 30 | 31 | `pip install --upgrade robotframework-openapidriver` 32 | 33 | --- 34 | 35 | ## OpenAPI (aka Swagger) 36 | 37 | The OpenAPI Specification (OAS) defines a standard, language-agnostic interface 38 | to RESTful APIs, see https://swagger.io/specification/ 39 | 40 | The OpenApiDriver module implements a reader class that generates a test case for 41 | each path, method and response (i.e. every response for each endpoint) that is defined 42 | in an OpenAPI document, typically an openapi.json or openapi.yaml file. 43 | 44 | > Note: OpenApiDriver is designed for APIs based on the OAS v3 45 | The library has not been tested for APIs based on the OAS v2. 46 | 47 | --- 48 | 49 | ## Getting started 50 | 51 | Before trying to use OpenApiDriver to run automatic validations on the target API 52 | it's recommended to first ensure that the openapi document for the API is valid 53 | under the OpenAPI Specification. 54 | 55 | This can be done using the command line interface of a package that is installed as 56 | a prerequisite for OpenApiDriver. 57 | Both a local openapi.json or openapi.yaml file or one hosted by the API server 58 | can be checked using the `prance validate ` shell command: 59 | 60 | ```shell 61 | prance validate --backend=openapi-spec-validator http://localhost:8000/openapi.json 62 | Processing "http://localhost:8000/openapi.json"... 63 | -> Resolving external references. 64 | Validates OK as OpenAPI 3.0.2! 65 | 66 | prance validate --backend=openapi-spec-validator /tests/files/petstore_openapi.yaml 67 | Processing "/tests/files/petstore_openapi.yaml"... 68 | -> Resolving external references. 69 | Validates OK as OpenAPI 3.0.2! 70 | ``` 71 | 72 | You'll have to change the url or file reference to the location of the openapi 73 | document for your API. 74 | 75 | > Note: Although recursion is technically allowed under the OAS, tool support is limited 76 | and changing the OAS to not use recursion is recommended. 77 | OpenApiDriver has limited support for parsing OpenAPI documents with 78 | recursion in them. See the `recursion_limit` and `recursion_default` parameters. 79 | 80 | If the openapi document passes this validation, the next step is trying to do a test 81 | run with a minimal test suite. 82 | The example below can be used, with `source` and `origin` altered to fit your situation. 83 | 84 | ``` robotframework 85 | *** Settings *** 86 | Library OpenApiDriver 87 | ... source=http://localhost:8000/openapi.json 88 | ... origin=http://localhost:8000 89 | Test Template Validate Using Test Endpoint Keyword 90 | 91 | *** Test Cases *** 92 | Test Endpoint for ${method} on ${path} where ${status_code} is expected 93 | 94 | *** Keywords *** 95 | Validate Using Test Endpoint Keyword 96 | [Arguments] ${path} ${method} ${status_code} 97 | Test Endpoint 98 | ... path=${path} method=${method} status_code=${status_code} 99 | 100 | ``` 101 | 102 | Running the above suite for the first time is likely to result in some 103 | errors / failed tests. 104 | You should look at the Robot Framework `log.html` to determine the reasons 105 | for the failing tests. 106 | Depending on the reasons for the failures, different solutions are possible. 107 | 108 | Details about the OpenApiDriver library parameters that you may need can be found 109 | [here](https://marketsquare.github.io/robotframework-openapidriver/openapidriver.html). 110 | 111 | The OpenApiDriver also support handling of relations between resources within the scope 112 | of the API being validated as well as handling dependencies on resources outside the 113 | scope of the API. In addition there is support for handling restrictions on the values 114 | of parameters and properties. 115 | 116 | Details about the `mappings_path` variable usage can be found 117 | [here](https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html). 118 | 119 | --- 120 | 121 | ## Limitations 122 | 123 | There are currently a number of limitations to supported API structures, supported 124 | data types and properties. The following list details the most important ones: 125 | - Only JSON request and response bodies are supported. 126 | - No support for per-path authorization levels (only simple 401 / 403 validation). 127 | 128 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | defaults: 2 | - 3 | scope: 4 | path: "README.md" 5 | values: 6 | permalink: "README.html" 7 | theme: jekyll-theme-midnight -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | HTML Meta Tag 5 | 6 | 7 | 8 |

Hello GitHub!

9 | 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name="robotframework-openapidriver" 3 | version = "4.3.0" 4 | description = "A library for contract-testing OpenAPI / Swagger APIs." 5 | license = "Apache-2.0" 6 | authors = ["Robin Mackaij "] 7 | maintainers = ["Robin Mackaij "] 8 | readme = "./docs/README.md" 9 | homepage = "https://github.com/MarketSquare/robotframework-openapidriver" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.8", 13 | "License :: OSI Approved :: Apache Software License", 14 | "Operating System :: OS Independent", 15 | "Topic :: Software Development :: Testing", 16 | "Topic :: Software Development :: Testing :: Acceptance", 17 | "Framework :: Robot Framework", 18 | ] 19 | packages = [ 20 | { include = "OpenApiDriver", from = "src" }, 21 | ] 22 | include = ["*.libspec"] 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.8" 26 | robotframework-datadriver = ">=1.6.1" 27 | robotframework-openapi-libcore = "^1.11.0" 28 | 29 | [tool.poetry.group.dev.dependencies] 30 | fastapi = ">=0.95.0" 31 | uvicorn = ">=0.22.0" 32 | invoke = ">=2.0.0" 33 | coverage = {version=">=7.2.5", extras = ["toml"]} 34 | robotframework-stacktrace = ">=0.4.1" 35 | 36 | [tool.poetry.group.formatting.dependencies] 37 | black = ">=22.10.0" 38 | isort = ">=5.10.1" 39 | robotframework-tidy = ">=3.4.0" 40 | 41 | [tool.poetry.group.type-checking.dependencies] 42 | mypy = ">=1.2.0" 43 | pyright = ">=1.1.300" 44 | types-requests = ">=2.28.11" 45 | types-invoke = ">=2.0.0.6" 46 | 47 | [tool.poetry.group.linting.dependencies] 48 | pylint = ">=2.17.2" 49 | ruff = ">=0.0.267" 50 | robotframework-robocop = ">=2.7.0" 51 | 52 | [build-system] 53 | requires = ["poetry-core>=1.0.0"] 54 | build-backend = "poetry.core.masonry.api" 55 | 56 | [tool.coverage.run] 57 | branch = true 58 | parallel = true 59 | source = ["src"] 60 | 61 | [tool.coverage.report] 62 | exclude_lines = [ 63 | "pragma: no cover", 64 | "@abstract" 65 | ] 66 | 67 | [tool.mypy] 68 | plugins = ["pydantic.mypy"] 69 | warn_redundant_casts = true 70 | warn_unused_ignores = true 71 | disallow_any_generics = true 72 | check_untyped_defs = true 73 | disallow_untyped_defs = true 74 | strict = true 75 | show_error_codes = true 76 | 77 | [[tool.mypy.overrides]] 78 | module = [ 79 | "DataDriver.*", 80 | "prance.*", 81 | "robot.*", 82 | "openapi_core.*", 83 | "OpenApiLibCore.*", 84 | "uvicorn", 85 | "invoke", 86 | ] 87 | ignore_missing_imports = true 88 | 89 | [tool.black] 90 | line-length = 88 91 | target-version = ["py38"] 92 | 93 | [tool.isort] 94 | profile = "black" 95 | py_version=38 96 | 97 | [tool.ruff] 98 | line-length = 120 99 | src = ["src/OpenApiDriver"] 100 | 101 | [tool.ruff.lint] 102 | select = ["E", "F", "PL"] 103 | 104 | [tool.pylint.'MESSAGES CONTROL'] 105 | disable = ["logging-fstring-interpolation", "missing-class-docstring"] 106 | 107 | [tool.pylint.'FORMAT CHECKER'] 108 | max-line-length=120 109 | 110 | [tool.pylint.'SIMILARITIES CHECKER'] 111 | ignore-imports="yes" 112 | 113 | [tool.robotidy] 114 | line_length = 120 115 | spacecount = 4 116 | 117 | [tool.robocop] 118 | filetypes = [".robot", ".resource"] 119 | configure = [ 120 | "line-too-long:line_length:120", 121 | "too-many-calls-in-test-case:max_calls:15" 122 | ] 123 | exclude = [ 124 | "missing-doc-suite", 125 | "missing-doc-test-case", 126 | "missing-doc-keyword", 127 | "too-few-calls-in-test-case" 128 | ] 129 | -------------------------------------------------------------------------------- /src/OpenApiDriver/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The OpenApiDriver package is intended to be used as a Robot Framework library. 3 | The following classes and constants are exposed to be used by the library user: 4 | - OpenApiDriver: The class to be used as a Library in the *** Settings *** section 5 | - IdDependency, IdReference, PathPropertiesConstraint, PropertyValueConstraint, 6 | UniquePropertyValueConstraint: Classes to be subclassed by the library user 7 | when implementing a custom mapping module (advanced use). 8 | - Dto, Relation: Base classes that can be used for type annotations. 9 | - IGNORE: A special constant that can be used as a value in the PropertyValueConstraint. 10 | """ 11 | 12 | from importlib.metadata import version 13 | 14 | from OpenApiLibCore.dto_base import ( 15 | Dto, 16 | IdDependency, 17 | IdReference, 18 | PathPropertiesConstraint, 19 | PropertyValueConstraint, 20 | Relation, 21 | UniquePropertyValueConstraint, 22 | ) 23 | from OpenApiLibCore.value_utils import IGNORE 24 | 25 | from OpenApiDriver.openapidriver import OpenApiDriver 26 | 27 | try: 28 | __version__ = version("robotframework-openapidriver") 29 | except Exception: # pragma: no cover 30 | pass 31 | 32 | __all__ = [ 33 | "Dto", 34 | "IdDependency", 35 | "IdReference", 36 | "PathPropertiesConstraint", 37 | "PropertyValueConstraint", 38 | "Relation", 39 | "UniquePropertyValueConstraint", 40 | "IGNORE", 41 | "OpenApiDriver", 42 | ] 43 | -------------------------------------------------------------------------------- /src/OpenApiDriver/openapi_executors.py: -------------------------------------------------------------------------------- 1 | """Module containing the classes to perform automatic OpenAPI contract validation.""" 2 | 3 | import json as _json 4 | from enum import Enum 5 | from logging import getLogger 6 | from pathlib import Path 7 | from random import choice 8 | from typing import Any, Dict, List, Optional, Tuple, Union 9 | 10 | from openapi_core.contrib.requests import ( 11 | RequestsOpenAPIRequest, 12 | RequestsOpenAPIResponse, 13 | ) 14 | from openapi_core.exceptions import OpenAPIError 15 | from openapi_core.validation.exceptions import ValidationError 16 | from openapi_core.validation.response.exceptions import InvalidData 17 | from openapi_core.validation.schemas.exceptions import InvalidSchemaValue 18 | from OpenApiLibCore import OpenApiLibCore, RequestData, RequestValues, resolve_schema 19 | from requests import Response 20 | from requests.auth import AuthBase 21 | from requests.cookies import RequestsCookieJar as CookieJar 22 | from robot.api import Failure, SkipExecution 23 | from robot.api.deco import keyword, library 24 | from robot.libraries.BuiltIn import BuiltIn 25 | 26 | run_keyword = BuiltIn().run_keyword 27 | 28 | 29 | logger = getLogger(__name__) 30 | 31 | 32 | class ValidationLevel(str, Enum): 33 | """The available levels for the response_validation parameter.""" 34 | 35 | DISABLED = "DISABLED" 36 | INFO = "INFO" 37 | WARN = "WARN" 38 | STRICT = "STRICT" 39 | 40 | 41 | @library(scope="TEST SUITE", doc_format="ROBOT") 42 | class OpenApiExecutors(OpenApiLibCore): # pylint: disable=too-many-instance-attributes 43 | """Main class providing the keywords and core logic to perform endpoint validations.""" 44 | 45 | def __init__( # pylint: disable=too-many-arguments 46 | self, 47 | source: str, 48 | origin: str = "", 49 | base_path: str = "", 50 | response_validation: ValidationLevel = ValidationLevel.WARN, 51 | disable_server_validation: bool = True, 52 | mappings_path: Union[str, Path] = "", 53 | invalid_property_default_response: int = 422, 54 | default_id_property_name: str = "id", 55 | faker_locale: Optional[Union[str, List[str]]] = None, 56 | require_body_for_invalid_url: bool = False, 57 | recursion_limit: int = 1, 58 | recursion_default: Any = {}, 59 | username: str = "", 60 | password: str = "", 61 | security_token: str = "", 62 | auth: Optional[AuthBase] = None, 63 | cert: Optional[Union[str, Tuple[str, str]]] = None, 64 | verify_tls: Optional[Union[bool, str]] = True, 65 | extra_headers: Optional[Dict[str, str]] = None, 66 | cookies: Optional[Union[Dict[str, str], CookieJar]] = None, 67 | proxies: Optional[Dict[str, str]] = None, 68 | ) -> None: 69 | super().__init__( 70 | source=source, 71 | origin=origin, 72 | base_path=base_path, 73 | mappings_path=mappings_path, 74 | default_id_property_name=default_id_property_name, 75 | faker_locale=faker_locale, 76 | recursion_limit=recursion_limit, 77 | recursion_default=recursion_default, 78 | username=username, 79 | password=password, 80 | security_token=security_token, 81 | auth=auth, 82 | cert=cert, 83 | verify_tls=verify_tls, 84 | extra_headers=extra_headers, 85 | cookies=cookies, 86 | proxies=proxies, 87 | ) 88 | self.response_validation = response_validation 89 | self.disable_server_validation = disable_server_validation 90 | self.require_body_for_invalid_url = require_body_for_invalid_url 91 | self.invalid_property_default_response = invalid_property_default_response 92 | 93 | @keyword 94 | def test_unauthorized(self, path: str, method: str) -> None: 95 | """ 96 | Perform a request for `method` on the `path`, with no authorization. 97 | 98 | This keyword only passes if the response code is 401: Unauthorized. 99 | 100 | Any authorization parameters used to initialize the library are 101 | ignored for this request. 102 | > Note: No headers or (json) body are send with the request. For security 103 | reasons, the authorization validation should be checked first. 104 | """ 105 | url: str = run_keyword("get_valid_url", path, method) 106 | response = self.session.request( 107 | method=method, 108 | url=url, 109 | verify=False, 110 | ) 111 | assert response.status_code == 401 112 | 113 | @keyword 114 | def test_invalid_url( 115 | self, path: str, method: str, expected_status_code: int = 404 116 | ) -> None: 117 | """ 118 | Perform a request for the provided 'path' and 'method' where the url for 119 | the `path` is invalidated. 120 | 121 | This keyword will be `SKIPPED` if the path contains no parts that 122 | can be invalidated. 123 | 124 | The optional `expected_status_code` parameter (default: 404) can be set to the 125 | expected status code for APIs that do not return a 404 on invalid urls. 126 | 127 | > Note: Depending on API design, the url may be validated before or after 128 | validation of headers, query parameters and / or (json) body. By default, no 129 | parameters are send with the request. The `require_body_for_invalid_url` 130 | parameter can be set to `True` if needed. 131 | """ 132 | valid_url: str = run_keyword("get_valid_url", path, method) 133 | 134 | if not (url := run_keyword("get_invalidated_url", valid_url)): 135 | raise SkipExecution( 136 | f"Path {path} does not contain resource references that " 137 | f"can be invalidated." 138 | ) 139 | 140 | params, headers, json_data = None, None, None 141 | if self.require_body_for_invalid_url: 142 | request_data = self.get_request_data(method=method, endpoint=path) 143 | params = request_data.params 144 | headers = request_data.headers 145 | dto = request_data.dto 146 | json_data = dto.as_dict() 147 | response: Response = run_keyword( 148 | "authorized_request", url, method, params, headers, json_data 149 | ) 150 | if response.status_code != expected_status_code: 151 | raise AssertionError( 152 | f"Response {response.status_code} was not {expected_status_code}" 153 | ) 154 | 155 | @keyword 156 | def test_endpoint(self, path: str, method: str, status_code: int) -> None: 157 | """ 158 | Validate that performing the `method` operation on `path` results in a 159 | `status_code` response. 160 | 161 | This is the main keyword to be used in the `Test Template` keyword when using 162 | the OpenApiDriver. 163 | 164 | The keyword calls other keywords to generate the neccesary data to perform 165 | the desired operation and validate the response against the openapi document. 166 | """ 167 | json_data: Optional[Dict[str, Any]] = None 168 | original_data = None 169 | 170 | url: str = run_keyword("get_valid_url", path, method) 171 | request_data: RequestData = self.get_request_data(method=method, endpoint=path) 172 | params = request_data.params 173 | headers = request_data.headers 174 | json_data = request_data.dto.as_dict() 175 | # when patching, get the original data to check only patched data has changed 176 | if method == "PATCH": 177 | original_data = self.get_original_data(url=url) 178 | # in case of a status code indicating an error, ensure the error occurs 179 | if status_code >= 400: 180 | invalidation_keyword_data = { 181 | "get_invalid_json_data": [ 182 | "get_invalid_json_data", 183 | url, 184 | method, 185 | status_code, 186 | request_data, 187 | ], 188 | "get_invalidated_parameters": [ 189 | "get_invalidated_parameters", 190 | status_code, 191 | request_data, 192 | ], 193 | } 194 | invalidation_keywords = [] 195 | 196 | if request_data.dto.get_relations_for_error_code(status_code): 197 | invalidation_keywords.append("get_invalid_json_data") 198 | if request_data.dto.get_parameter_relations_for_error_code(status_code): 199 | invalidation_keywords.append("get_invalidated_parameters") 200 | if invalidation_keywords: 201 | if ( 202 | invalidation_keyword := choice(invalidation_keywords) 203 | ) == "get_invalid_json_data": 204 | json_data = run_keyword( 205 | *invalidation_keyword_data[invalidation_keyword] 206 | ) 207 | else: 208 | params, headers = run_keyword( 209 | *invalidation_keyword_data[invalidation_keyword] 210 | ) 211 | # if there are no relations to invalide and the status_code is the default 212 | # response_code for invalid properties, invalidate properties instead 213 | elif status_code == self.invalid_property_default_response: 214 | if ( 215 | request_data.params_that_can_be_invalidated 216 | or request_data.headers_that_can_be_invalidated 217 | ): 218 | params, headers = run_keyword( 219 | *invalidation_keyword_data["get_invalidated_parameters"] 220 | ) 221 | if request_data.dto_schema: 222 | json_data = run_keyword( 223 | *invalidation_keyword_data["get_invalid_json_data"] 224 | ) 225 | elif request_data.dto_schema: 226 | json_data = run_keyword( 227 | *invalidation_keyword_data["get_invalid_json_data"] 228 | ) 229 | else: 230 | raise SkipExecution( 231 | "No properties or parameters can be invalidated." 232 | ) 233 | else: 234 | raise AssertionError( 235 | f"No Dto mapping found to cause status_code {status_code}." 236 | ) 237 | run_keyword( 238 | "perform_validated_request", 239 | path, 240 | status_code, 241 | RequestValues( 242 | url=url, 243 | method=method, 244 | params=params, 245 | headers=headers, 246 | json_data=json_data, 247 | ), 248 | original_data, 249 | ) 250 | if status_code < 300 and ( 251 | request_data.has_optional_properties 252 | or request_data.has_optional_params 253 | or request_data.has_optional_headers 254 | ): 255 | logger.info("Performing request without optional properties and parameters") 256 | url = run_keyword("get_valid_url", path, method) 257 | request_data = self.get_request_data(method=method, endpoint=path) 258 | params = request_data.get_required_params() 259 | headers = request_data.get_required_headers() 260 | json_data = request_data.get_required_properties_dict() 261 | original_data = None 262 | if method == "PATCH": 263 | original_data = self.get_original_data(url=url) 264 | run_keyword( 265 | "perform_validated_request", 266 | path, 267 | status_code, 268 | RequestValues( 269 | url=url, 270 | method=method, 271 | params=params, 272 | headers=headers, 273 | json_data=json_data, 274 | ), 275 | original_data, 276 | ) 277 | 278 | def get_original_data(self, url: str) -> Optional[Dict[str, Any]]: 279 | """ 280 | Attempt to GET the current data for the given url and return it. 281 | 282 | If the GET request fails, None is returned. 283 | """ 284 | original_data = None 285 | path = self.get_parameterized_endpoint_from_url(url) 286 | get_request_data = self.get_request_data(endpoint=path, method="GET") 287 | get_params = get_request_data.params 288 | get_headers = get_request_data.headers 289 | response: Response = run_keyword( 290 | "authorized_request", url, "GET", get_params, get_headers 291 | ) 292 | if response.ok: 293 | original_data = response.json() 294 | return original_data 295 | 296 | @keyword 297 | def perform_validated_request( 298 | self, 299 | path: str, 300 | status_code: int, 301 | request_values: RequestValues, 302 | original_data: Optional[Dict[str, Any]] = None, 303 | ) -> None: 304 | """ 305 | This keyword first calls the Authorized Request keyword, then the Validate 306 | Response keyword and finally validates, for `DELETE` operations, whether 307 | the target resource was indeed deleted (OK response) or not (error responses). 308 | """ 309 | response = run_keyword( 310 | "authorized_request", 311 | request_values.url, 312 | request_values.method, 313 | request_values.params, 314 | request_values.headers, 315 | request_values.json_data, 316 | ) 317 | if response.status_code != status_code: 318 | try: 319 | response_json = response.json() 320 | except Exception as _: # pylint: disable=broad-except 321 | logger.info( 322 | f"Failed to get json content from response. " 323 | f"Response text was: {response.text}" 324 | ) 325 | response_json = {} 326 | if not response.ok: 327 | if description := response_json.get("detail"): 328 | pass 329 | else: 330 | description = response_json.get( 331 | "message", "response contains no message or detail." 332 | ) 333 | logger.error(f"{response.reason}: {description}") 334 | 335 | logger.debug( 336 | f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}" 337 | f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}" 338 | ) 339 | raise AssertionError( 340 | f"Response status_code {response.status_code} was not {status_code}" 341 | ) 342 | 343 | run_keyword("validate_response", path, response, original_data) 344 | 345 | if request_values.method == "DELETE": 346 | get_request_data = self.get_request_data(endpoint=path, method="GET") 347 | get_params = get_request_data.params 348 | get_headers = get_request_data.headers 349 | get_response = run_keyword( 350 | "authorized_request", request_values.url, "GET", get_params, get_headers 351 | ) 352 | if response.ok: 353 | if get_response.ok: 354 | raise AssertionError( 355 | f"Resource still exists after deletion. Url was {request_values.url}" 356 | ) 357 | # if the path supports GET, 404 is expected, if not 405 is expected 358 | if get_response.status_code not in [404, 405]: 359 | logger.warning( 360 | f"Unexpected response after deleting resource: Status_code " 361 | f"{get_response.status_code} was received after trying to get {request_values.url} " 362 | f"after sucessfully deleting it." 363 | ) 364 | elif not get_response.ok: 365 | raise AssertionError( 366 | f"Resource could not be retrieved after failed deletion. " 367 | f"Url was {request_values.url}, status_code was {get_response.status_code}" 368 | ) 369 | 370 | @keyword 371 | def validate_response( 372 | self, 373 | path: str, 374 | response: Response, 375 | original_data: Optional[Dict[str, Any]] = None, 376 | ) -> None: 377 | """ 378 | Validate the `response` by performing the following validations: 379 | - validate the `response` against the openapi schema for the `endpoint` 380 | - validate that the response does not contain extra properties 381 | - validate that a href, if present, refers to the correct resource 382 | - validate that the value for a property that is in the response is equal to 383 | the property value that was send 384 | - validate that no `original_data` is preserved when performing a PUT operation 385 | - validate that a PATCH operation only updates the provided properties 386 | """ 387 | if response.status_code == 204: 388 | assert not response.content 389 | return None 390 | 391 | try: 392 | self._validate_response_against_spec(response) 393 | except OpenAPIError: 394 | raise Failure("Response did not pass schema validation.") 395 | 396 | request_method = response.request.method 397 | if request_method is None: 398 | logger.warning( 399 | f"Could not validate response for path {path}; no method found " 400 | f"on the request property of the provided response." 401 | ) 402 | return None 403 | 404 | response_spec = self._get_response_spec( 405 | path=path, 406 | method=request_method, 407 | status_code=response.status_code, 408 | ) 409 | 410 | content_type_from_response = response.headers.get("Content-Type", "unknown") 411 | mime_type_from_response, _, _ = content_type_from_response.partition(";") 412 | 413 | if not response_spec.get("content"): 414 | logger.warning( 415 | "The response cannot be validated: 'content' not specified in the OAS." 416 | ) 417 | return None 418 | 419 | # multiple content types can be specified in the OAS 420 | content_types = list(response_spec["content"].keys()) 421 | supported_types = [ 422 | ct for ct in content_types if ct.partition(";")[0].endswith("json") 423 | ] 424 | if not supported_types: 425 | raise NotImplementedError( 426 | f"The content_types '{content_types}' are not supported. " 427 | f"Only json types are currently supported." 428 | ) 429 | content_type = supported_types[0] 430 | mime_type = content_type.partition(";")[0] 431 | 432 | if mime_type != mime_type_from_response: 433 | raise ValueError( 434 | f"Content-Type '{content_type_from_response}' of the response " 435 | f"does not match '{mime_type}' as specified in the OpenAPI document." 436 | ) 437 | 438 | json_response = response.json() 439 | response_schema = resolve_schema( 440 | response_spec["content"][content_type]["schema"] 441 | ) 442 | if list_item_schema := response_schema.get("items"): 443 | if not isinstance(json_response, list): 444 | raise AssertionError( 445 | f"Response schema violation: the schema specifies an array as " 446 | f"response type but the response was of type {type(json_response)}." 447 | ) 448 | type_of_list_items = list_item_schema.get("type") 449 | if type_of_list_items == "object": 450 | for resource in json_response: 451 | run_keyword( 452 | "validate_resource_properties", resource, list_item_schema 453 | ) 454 | else: 455 | for item in json_response: 456 | self._validate_value_type( 457 | value=item, expected_type=type_of_list_items 458 | ) 459 | # no further validation; value validation of individual resources should 460 | # be performed on the endpoints for the specific resource 461 | return None 462 | 463 | run_keyword("validate_resource_properties", json_response, response_schema) 464 | # ensure the href is valid if present in the response 465 | if href := json_response.get("href"): 466 | self._assert_href_is_valid(href, json_response) 467 | # every property that was sucessfully send and that is in the response 468 | # schema must have the value that was send 469 | if response.ok and response.request.method in ["POST", "PUT", "PATCH"]: 470 | run_keyword("validate_send_response", response, original_data) 471 | return None 472 | 473 | def _assert_href_is_valid(self, href: str, json_response: Dict[str, Any]) -> None: 474 | url = f"{self.origin}{href}" 475 | path = url.replace(self.base_url, "") 476 | request_data = self.get_request_data(endpoint=path, method="GET") 477 | params = request_data.params 478 | headers = request_data.headers 479 | get_response = run_keyword("authorized_request", url, "GET", params, headers) 480 | assert ( 481 | get_response.json() == json_response 482 | ), f"{get_response.json()} not equal to original {json_response}" 483 | 484 | def _validate_response_against_spec(self, response: Response) -> None: 485 | try: 486 | self.validate_response_vs_spec( 487 | request=RequestsOpenAPIRequest(response.request), 488 | response=RequestsOpenAPIResponse(response), 489 | ) 490 | except InvalidData as exception: 491 | errors: List[InvalidSchemaValue] = exception.__cause__ 492 | validation_errors: Optional[List[ValidationError]] = getattr( 493 | errors, "schema_errors", None 494 | ) 495 | if validation_errors: 496 | error_message = "\n".join( 497 | [ 498 | f"{list(error.schema_path)}: {error.message}" 499 | for error in validation_errors 500 | ] 501 | ) 502 | else: 503 | error_message = str(exception) 504 | 505 | if response.status_code == self.invalid_property_default_response: 506 | logger.debug(error_message) 507 | return 508 | if self.response_validation == ValidationLevel.STRICT: 509 | logger.error(error_message) 510 | raise exception 511 | if self.response_validation == ValidationLevel.WARN: 512 | logger.warning(error_message) 513 | elif self.response_validation == ValidationLevel.INFO: 514 | logger.info(error_message) 515 | 516 | @keyword 517 | def validate_resource_properties( 518 | self, resource: Dict[str, Any], schema: Dict[str, Any] 519 | ) -> None: 520 | """ 521 | Validate that the `resource` does not contain any properties that are not 522 | defined in the `schema_properties`. 523 | """ 524 | schema_properties = schema.get("properties", {}) 525 | property_names_from_schema = set(schema_properties.keys()) 526 | property_names_in_resource = set(resource.keys()) 527 | 528 | if property_names_from_schema != property_names_in_resource: 529 | # The additionalProperties property determines whether properties with 530 | # unspecified names are allowed. This property can be boolean or an object 531 | # (dict) that specifies the type of any additional properties. 532 | additional_properties = schema.get("additionalProperties", True) 533 | if isinstance(additional_properties, bool): 534 | allow_additional_properties = additional_properties 535 | allowed_additional_properties_type = None 536 | else: 537 | allow_additional_properties = True 538 | allowed_additional_properties_type = additional_properties["type"] 539 | 540 | extra_property_names = property_names_in_resource.difference( 541 | property_names_from_schema 542 | ) 543 | if allow_additional_properties: 544 | # If a type is defined for extra properties, validate them 545 | if allowed_additional_properties_type: 546 | extra_properties = { 547 | key: value 548 | for key, value in resource.items() 549 | if key in extra_property_names 550 | } 551 | self._validate_type_of_extra_properties( 552 | extra_properties=extra_properties, 553 | expected_type=allowed_additional_properties_type, 554 | ) 555 | # If allowed, validation should not fail on extra properties 556 | extra_property_names = set() 557 | 558 | required_properties = set(schema.get("required", [])) 559 | missing_properties = required_properties.difference( 560 | property_names_in_resource 561 | ) 562 | 563 | if extra_property_names or missing_properties: 564 | extra = ( 565 | f"\n\tExtra properties in response: {extra_property_names}" 566 | if extra_property_names 567 | else "" 568 | ) 569 | missing = ( 570 | f"\n\tRequired properties missing in response: {missing_properties}" 571 | if missing_properties 572 | else "" 573 | ) 574 | raise AssertionError( 575 | f"Response schema violation: the response contains properties that are " 576 | f"not specified in the schema or does not contain properties that are " 577 | f"required according to the schema." 578 | f"\n\tReceived in the response: {property_names_in_resource}" 579 | f"\n\tDefined in the schema: {property_names_from_schema}" 580 | f"{extra}{missing}" 581 | ) 582 | 583 | @staticmethod 584 | def _validate_value_type(value: Any, expected_type: str) -> None: 585 | type_mapping = { 586 | "string": str, 587 | "number": float, 588 | "integer": int, 589 | "boolean": bool, 590 | "array": list, 591 | "object": dict, 592 | } 593 | python_type = type_mapping.get(expected_type, None) 594 | if python_type is None: 595 | raise AssertionError( 596 | f"Validation of type '{expected_type}' is not supported." 597 | ) 598 | if not isinstance(value, python_type): 599 | raise AssertionError(f"{value} is not of type {expected_type}") 600 | 601 | @staticmethod 602 | def _validate_type_of_extra_properties( 603 | extra_properties: Dict[str, Any], expected_type: str 604 | ) -> None: 605 | type_mapping = { 606 | "string": str, 607 | "number": float, 608 | "integer": int, 609 | "boolean": bool, 610 | "array": list, 611 | "object": dict, 612 | } 613 | 614 | python_type = type_mapping.get(expected_type, None) 615 | if python_type is None: 616 | logger.warning( 617 | f"Additonal properties were not validated: " 618 | f"type '{expected_type}' is not supported." 619 | ) 620 | return 621 | 622 | invalid_extra_properties = { 623 | key: value 624 | for key, value in extra_properties.items() 625 | if not isinstance(value, python_type) 626 | } 627 | if invalid_extra_properties: 628 | raise AssertionError( 629 | f"Response contains invalid additionalProperties: " 630 | f"{invalid_extra_properties} are not of type {expected_type}." 631 | ) 632 | 633 | @staticmethod 634 | @keyword 635 | def validate_send_response( 636 | response: Response, original_data: Optional[Dict[str, Any]] = None 637 | ) -> None: 638 | """ 639 | Validate that each property that was send that is in the response has the value 640 | that was send. 641 | In case a PATCH request, validate that only the properties that were patched 642 | have changed and that other properties are still at their pre-patch values. 643 | """ 644 | 645 | def validate_list_response( 646 | send_list: List[Any], received_list: List[Any] 647 | ) -> None: 648 | for item in send_list: 649 | if item not in received_list: 650 | raise AssertionError( 651 | f"Received value '{received_list}' does " 652 | f"not contain '{item}' in the {response.request.method} request." 653 | f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}" 654 | f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}" 655 | ) 656 | 657 | def validate_dict_response( 658 | send_dict: Dict[str, Any], received_dict: Dict[str, Any] 659 | ) -> None: 660 | for send_property_name, send_property_value in send_dict.items(): 661 | # sometimes, a property in the request is not in the response, e.g. a password 662 | if send_property_name not in received_dict.keys(): 663 | continue 664 | if send_property_value is not None: 665 | # if a None value is send, the target property should be cleared or 666 | # reverted to the default value (which cannot be specified in the 667 | # openapi document) 668 | received_value = received_dict[send_property_name] 669 | # In case of lists / arrays, the send values are often appended to 670 | # existing data 671 | if isinstance(received_value, list): 672 | validate_list_response( 673 | send_list=send_property_value, received_list=received_value 674 | ) 675 | continue 676 | 677 | # when dealing with objects, we'll need to iterate the properties 678 | if isinstance(received_value, dict): 679 | validate_dict_response( 680 | send_dict=send_property_value, received_dict=received_value 681 | ) 682 | continue 683 | 684 | assert received_value == send_property_value, ( 685 | f"Received value for {send_property_name} '{received_value}' does not " 686 | f"match '{send_property_value}' in the {response.request.method} request." 687 | f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}" 688 | f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}" 689 | ) 690 | 691 | if response.request.body is None: 692 | logger.warning( 693 | "Could not validate send response; the body of the request property " 694 | "on the provided response was None." 695 | ) 696 | return None 697 | if isinstance(response.request.body, bytes): 698 | send_json = _json.loads(response.request.body.decode("UTF-8")) 699 | else: 700 | send_json = _json.loads(response.request.body) 701 | 702 | response_data = response.json() 703 | # POST on /resource_type/{id}/array_item/ will return the updated {id} resource 704 | # instead of a newly created resource. In this case, the send_json must be 705 | # in the array of the 'array_item' property on {id} 706 | send_path: str = response.request.path_url 707 | response_path = response_data.get("href", None) 708 | if response_path and send_path not in response_path: 709 | property_to_check = send_path.replace(response_path, "")[1:] 710 | if response_data.get(property_to_check) and isinstance( 711 | response_data[property_to_check], list 712 | ): 713 | item_list: List[Dict[str, Any]] = response_data[property_to_check] 714 | # Use the (mandatory) id to get the POSTed resource from the list 715 | [response_data] = [ 716 | item for item in item_list if item["id"] == send_json["id"] 717 | ] 718 | 719 | # incoming arguments are dictionaries, so they can be validated as such 720 | validate_dict_response(send_dict=send_json, received_dict=response_data) 721 | 722 | # In case of PATCH requests, ensure that only send properties have changed 723 | if original_data: 724 | for send_property_name, send_value in original_data.items(): 725 | if send_property_name not in send_json.keys(): 726 | assert send_value == response_data[send_property_name], ( 727 | f"Received value for {send_property_name} '{response_data[send_property_name]}' does not " 728 | f"match '{send_value}' in the pre-patch data" 729 | f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}" 730 | f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}" 731 | ) 732 | return None 733 | 734 | def _get_response_spec( 735 | self, path: str, method: str, status_code: int 736 | ) -> Dict[str, Any]: 737 | method = method.lower() 738 | status = str(status_code) 739 | spec: Dict[str, Any] = {**self.openapi_spec}["paths"][path][method][ 740 | "responses" 741 | ][status] 742 | return spec 743 | -------------------------------------------------------------------------------- /src/OpenApiDriver/openapi_reader.py: -------------------------------------------------------------------------------- 1 | """Module holding the OpenApiReader reader_class implementation.""" 2 | 3 | from typing import Any, Dict, List, Union 4 | 5 | from DataDriver.AbstractReaderClass import AbstractReaderClass 6 | from DataDriver.ReaderConfig import TestCaseData 7 | 8 | 9 | # pylint: disable=too-few-public-methods 10 | class Test: 11 | """ 12 | Helper class to support ignoring endpoint responses when generating the test cases. 13 | """ 14 | 15 | def __init__(self, path: str, method: str, response: Union[str, int]): 16 | self.path = path 17 | self.method = method.lower() 18 | self.response = str(response) 19 | 20 | def __eq__(self, other: Any) -> bool: 21 | if not isinstance(other, type(self)): 22 | return False 23 | return ( 24 | self.path == other.path 25 | and self.method == other.method 26 | and self.response == other.response 27 | ) 28 | 29 | 30 | class OpenApiReader(AbstractReaderClass): 31 | """Implementation of the reader_class used by DataDriver.""" 32 | 33 | def get_data_from_source(self) -> List[TestCaseData]: 34 | test_data: List[TestCaseData] = [] 35 | 36 | paths = getattr(self, "paths") 37 | self._filter_paths(paths) 38 | 39 | ignored_responses_ = [ 40 | str(response) for response in getattr(self, "ignored_responses", []) 41 | ] 42 | 43 | ignored_tests = [Test(*test) for test in getattr(self, "ignored_testcases", [])] 44 | 45 | for path, path_item in paths.items(): 46 | # by reseversing the items, post/put operations come before get and delete 47 | for item_name, item_data in reversed(path_item.items()): 48 | # this level of the OAS also contains data that's not related to a 49 | # path operation 50 | if item_name not in ["get", "put", "post", "delete", "patch"]: 51 | continue 52 | method, method_data = item_name, item_data 53 | tags_from_spec = method_data.get("tags", []) 54 | for response in method_data.get("responses"): 55 | # 'default' applies to all status codes that are not specified, in 56 | # which case we don't know what to expect and therefore can't verify 57 | if ( 58 | response == "default" 59 | or response in ignored_responses_ 60 | or Test(path, method, response) in ignored_tests 61 | ): 62 | continue 63 | 64 | tag_list = _get_tag_list( 65 | tags=tags_from_spec, method=method, response=response 66 | ) 67 | test_data.append( 68 | TestCaseData( 69 | arguments={ 70 | "${path}": path, 71 | "${method}": method.upper(), 72 | "${status_code}": response, 73 | }, 74 | tags=tag_list, 75 | ), 76 | ) 77 | return test_data 78 | 79 | def _filter_paths(self, paths: Dict[str, Any]) -> None: 80 | def matches_include_pattern(path: str) -> bool: 81 | for included_path in included_paths: 82 | if path == included_path: 83 | return True 84 | if included_path.endswith("*"): 85 | wildcard_include, _, _ = included_path.partition("*") 86 | if path.startswith(wildcard_include): 87 | return True 88 | return False 89 | 90 | def matches_ignore_pattern(path: str) -> bool: 91 | for ignored_path in ignored_paths: 92 | if path == ignored_path: 93 | return True 94 | 95 | if ignored_path.endswith("*"): 96 | wildcard_ignore, _, _ = ignored_path.partition("*") 97 | if path.startswith(wildcard_ignore): 98 | return True 99 | return False 100 | 101 | if included_paths := getattr(self, "included_paths", ()): 102 | path_list = list(paths.keys()) 103 | for path in path_list: 104 | if not matches_include_pattern(path): 105 | paths.pop(path) 106 | 107 | if ignored_paths := getattr(self, "ignored_paths", ()): 108 | path_list = list(paths.keys()) 109 | for path in path_list: 110 | if matches_ignore_pattern(path): 111 | paths.pop(path) 112 | 113 | 114 | def _get_tag_list(tags: List[str], method: str, response: str) -> List[str]: 115 | return [*tags, f"Method: {method.upper()}", f"Response: {response}"] 116 | -------------------------------------------------------------------------------- /src/OpenApiDriver/openapidriver.libspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.3.0 4 | <p>Visit the <a href="https://github.com/MarketSquare/robotframework-openapidriver">library page</a> for an introduction and examples.</p> 5 | 6 | 7 | 8 | 9 | 10 | 11 | source 12 | str 13 | 14 | 15 | origin 16 | str 17 | 18 | 19 | 20 | base_path 21 | str 22 | 23 | 24 | 25 | included_paths 26 | Iterable[str] | NoneIterable[str]strNone 27 | None 28 | 29 | 30 | ignored_paths 31 | Iterable[str] | NoneIterable[str]strNone 32 | None 33 | 34 | 35 | ignored_responses 36 | Iterable[int] | NoneIterable[int]intNone 37 | None 38 | 39 | 40 | ignored_testcases 41 | Iterable[Tuple[str, str, int]] | NoneIterable[Tuple[str, str, int]]Tuple[str, str, int]strstrintNone 42 | None 43 | 44 | 45 | response_validation 46 | ValidationLevel 47 | WARN 48 | 49 | 50 | disable_server_validation 51 | bool 52 | True 53 | 54 | 55 | mappings_path 56 | str | PathstrPath 57 | 58 | 59 | 60 | invalid_property_default_response 61 | int 62 | 422 63 | 64 | 65 | default_id_property_name 66 | str 67 | id 68 | 69 | 70 | faker_locale 71 | str | List[str] | NonestrList[str]strNone 72 | None 73 | 74 | 75 | require_body_for_invalid_url 76 | bool 77 | False 78 | 79 | 80 | recursion_limit 81 | int 82 | 1 83 | 84 | 85 | recursion_default 86 | Any 87 | {} 88 | 89 | 90 | username 91 | str 92 | 93 | 94 | 95 | password 96 | str 97 | 98 | 99 | 100 | security_token 101 | str 102 | 103 | 104 | 105 | auth 106 | AuthBase | NoneAuthBaseNone 107 | None 108 | 109 | 110 | cert 111 | str | Tuple[str, str] | NonestrTuple[str, str]strstrNone 112 | None 113 | 114 | 115 | verify_tls 116 | bool | str | NoneboolstrNone 117 | True 118 | 119 | 120 | extra_headers 121 | Dict[str, str] | NoneDict[str, str]strstrNone 122 | None 123 | 124 | 125 | cookies 126 | Dict[str, str] | RequestsCookieJar | NoneDict[str, str]strstrRequestsCookieJarNone 127 | None 128 | 129 | 130 | proxies 131 | Dict[str, str] | NoneDict[str, str]strstrNone 132 | None 133 | 134 | 135 | <h3>Base parameters</h3> 136 | <h4>source</h4> 137 | <p>An absolute path to an openapi.json or openapi.yaml file or an url to such a file.</p> 138 | <h4>origin</h4> 139 | <p>The server (and port) of the target server. E.g. <code>https://localhost:8000</code></p> 140 | <h4>base_path</h4> 141 | <p>The routing between <code>origin</code> and the endpoints as found in the <code>paths</code> section in the openapi document. E.g. <code>/petshop/v2</code>.</p> 142 | <h3>Test case generation and execution</h3> 143 | <h4>included_paths</h4> 144 | <p>A list of paths that will be included when generating the test cases. The <code>*</code> character can be used at the end of a partial path to include all paths starting with the partial path (wildcard include).</p> 145 | <h4>ignored_paths</h4> 146 | <p>A list of paths that will be ignored when generating the test cases. The <code>*</code> character can be used at the end of a partial path to ignore all paths starting with the partial path (wildcard ignore).</p> 147 | <h4>ignored_responses</h4> 148 | <p>A list of responses that will be ignored when generating the test cases.</p> 149 | <h4>ignored_testcases</h4> 150 | <p>A list of specific test cases that, if it would be generated, will be ignored. Specific test cases to ignore must be specified as a <code>Tuple</code> or <code>List</code> of <code>path</code>, <code>method</code> and <code>response</code>.</p> 151 | <h4>response_validation</h4> 152 | <p>By default, a <code>WARN</code> is logged when the Response received after a Request does not comply with the schema as defined in the openapi document for the given operation. The following values are supported:</p> 153 | <ul> 154 | <li><code>DISABLED</code>: All Response validation errors will be ignored</li> 155 | <li><code>INFO</code>: Any Response validation erros will be logged at <code>INFO</code> level</li> 156 | <li><code>WARN</code>: Any Response validation erros will be logged at <code>WARN</code> level</li> 157 | <li><code>STRICT</code>: The Test Case will fail on any Response validation errors</li> 158 | </ul> 159 | <h4>disable_server_validation</h4> 160 | <p>If enabled by setting this parameter to <code>True</code>, the Response validation will also include possible errors for Requests made to a server address that is not defined in the list of servers in the openapi document. This generally means that if there is a mismatch, every Test Case will raise this error. Note that <code>localhost</code> and <code>127.0.0.1</code> are not considered the same by Response validation.</p> 161 | <h3>API-specific configurations</h3> 162 | <h4>mappings_path</h4> 163 | <p>See <a href="https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html">this page</a> for an in-depth explanation.</p> 164 | <h4>invalid_property_default_response</h4> 165 | <p>The default response code for requests with a JSON body that does not comply with the schema. Example: a value outside the specified range or a string value for a property defined as integer in the schema.</p> 166 | <h4>default_id_property_name</h4> 167 | <p>The default name for the property that identifies a resource (i.e. a unique entity) within the API. The default value for this property name is <code>id</code>. If the target API uses a different name for all the resources within the API, you can configure it globally using this property.</p> 168 | <p>If different property names are used for the unique identifier for different types of resources, an <code>ID_MAPPING</code> can be implemented using the <code>mappings_path</code>.</p> 169 | <h4>faker_locale</h4> 170 | <p>A locale string or list of locale strings to pass to the Faker library to be used in generation of string data for supported format types.</p> 171 | <h4>require_body_for_invalid_url</h4> 172 | <p>When a request is made against an invalid url, this usually is because of a "404" request; a request for a resource that does not exist. Depending on API implementation, when a request with a missing or invalid request body is made on a non-existent resource, either a 404 or a 422 or 400 Response is normally returned. If the API being tested processes the request body before checking if the requested resource exists, set this parameter to True.</p> 173 | <h3>Parsing parameters</h3> 174 | <h4>recursion_limit</h4> 175 | <p>The recursion depth to which to fully parse recursive references before the <span class="name">recursion_default</span> is used to end the recursion.</p> 176 | <h4>recursion_default</h4> 177 | <p>The value that is used instead of the referenced schema when the <span class="name">recursion_limit</span> has been reached. The default <span class="name">{}</span> represents an empty object in JSON. Depending on schema definitions, this may cause schema validation errors. If this is the case, 'None' (<code>${NONE}</code> in Robot Framework) or an empty list can be tried as an alternative.</p> 178 | <h3>Security-related parameters</h3> 179 | <p><i>Note: these parameters are equivalent to those in the <code>requests</code> library.</i></p> 180 | <h4>username</h4> 181 | <p>The username to be used for Basic Authentication.</p> 182 | <h4>password</h4> 183 | <p>The password to be used for Basic Authentication.</p> 184 | <h4>security_token</h4> 185 | <p>The token to be used for token based security using the <code>Authorization</code> header.</p> 186 | <h4>auth</h4> 187 | <p>A <a href="https://requests.readthedocs.io/en/latest/api/#authentication">requests <code>AuthBase</code> instance</a> to be used for authentication instead of the <code>username</code> and <code>password</code>.</p> 188 | <h4>cert</h4> 189 | <p>The SSL certificate to use with all requests. If string: the path to ssl client cert file (.pem). If tuple: the ('cert', 'key') pair.</p> 190 | <h4>verify_tls</h4> 191 | <p>Whether or not to verify the TLS / SSL certificate of the server. If boolean: whether or not to verify the server TLS certificate. If string: path to a CA bundle to use for verification.</p> 192 | <h4>extra_headers</h4> 193 | <p>A dictionary with extra / custom headers that will be send with every request. This parameter can be used to send headers that are not documented in the openapi document or to provide an API-key.</p> 194 | <h4>cookies</h4> 195 | <p>A dictionary or <a href="https://docs.python.org/3/library/http.cookiejar.html#http.cookiejar.CookieJar">CookieJar object</a> to send with all requests.</p> 196 | <h4>proxies</h4> 197 | <p>A dictionary of 'protocol': 'proxy url' to use for all requests.</p> 198 | == Base parameters == 199 | 200 | 201 | 202 | 203 | 204 | 205 | path 206 | str 207 | 208 | 209 | method 210 | str 211 | 212 | 213 | status_code 214 | int 215 | 216 | 217 | <p>Validate that performing the <span class="name">method</span> operation on <a href="#type-Path" class="name">path</a> results in a <span class="name">status_code</span> response.</p> 218 | <p>This is the main keyword to be used in the <span class="name">Test Template</span> keyword when using the OpenApiDriver.</p> 219 | <p>The keyword calls other keywords to generate the neccesary data to perform the desired operation and validate the response against the openapi document.</p> 220 | Validate that performing the `method` operation on `path` results in a `status_code` response. 221 | 222 | 223 | 224 | 225 | path 226 | str 227 | 228 | 229 | method 230 | str 231 | 232 | 233 | expected_status_code 234 | int 235 | 404 236 | 237 | 238 | <p>Perform a request for the provided 'path' and 'method' where the url for the <a href="#type-Path" class="name">path</a> is invalidated.</p> 239 | <p>This keyword will be <span class="name">SKIPPED</span> if the path contains no parts that can be invalidated.</p> 240 | <p>The optional <span class="name">expected_status_code</span> parameter (default: 404) can be set to the expected status code for APIs that do not return a 404 on invalid urls.</p> 241 | <p>&gt; Note: Depending on API design, the url may be validated before or after validation of headers, query parameters and / or (json) body. By default, no parameters are send with the request. The <span class="name">require_body_for_invalid_url</span> parameter can be set to <span class="name">True</span> if needed.</p> 242 | Perform a request for the provided 'path' and 'method' where the url for the `path` is invalidated. 243 | 244 | 245 | 246 | 247 | path 248 | str 249 | 250 | 251 | method 252 | str 253 | 254 | 255 | <p>Perform a request for <span class="name">method</span> on the <a href="#type-Path" class="name">path</a>, with no authorization.</p> 256 | <p>This keyword only passes if the response code is 401: Unauthorized.</p> 257 | <p>Any authorization parameters used to initialize the library are ignored for this request. &gt; Note: No headers or (json) body are send with the request. For security reasons, the authorization validation should be checked first.</p> 258 | Perform a request for `method` on the `path`, with no authorization. 259 | 260 | 261 | 262 | 263 | 264 | <p>The available levels for the response_validation parameter.</p> 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | <p>Any value is accepted. No conversion is done.</p> 277 | 278 | Any 279 | 280 | 281 | __init__ 282 | 283 | 284 | 285 | <p>Strings <code>TRUE</code>, <code>YES</code>, <code>ON</code> and <code>1</code> are converted to Boolean <code>True</code>, the empty string as well as strings <code>FALSE</code>, <code>NO</code>, <code>OFF</code> and <code>0</code> are converted to Boolean <code>False</code>, and the string <code>NONE</code> is converted to the Python <code>None</code> object. Other strings and other accepted values are passed as-is, allowing keywords to handle them specially if needed. All string comparisons are case-insensitive.</p> 286 | <p>Examples: <code>TRUE</code> (converted to <code>True</code>), <code>off</code> (converted to <code>False</code>), <code>example</code> (used as-is)</p> 287 | 288 | string 289 | integer 290 | float 291 | None 292 | 293 | 294 | __init__ 295 | 296 | 297 | 298 | <p>Strings must be Python <a href="https://docs.python.org/library/stdtypes.html#dict">dictionary</a> literals. They are converted to actual dictionaries using the <a href="https://docs.python.org/library/ast.html#ast.literal_eval">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including dictionaries and other containers.</p> 299 | <p>If the type has nested types like <code>dict[str, int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p> 300 | <p>Examples: <code>{'a': 1, 'b': 2}</code>, <code>{'key': 1, 'nested': {'key': 2}}</code></p> 301 | 302 | string 303 | Mapping 304 | 305 | 306 | __init__ 307 | 308 | 309 | 310 | <p>Conversion is done using Python's <a href="https://docs.python.org/library/functions.html#int">int</a> built-in function. Floating point numbers are accepted only if they can be represented as integers exactly. For example, <code>1.0</code> is accepted and <code>1.1</code> is not.</p> 311 | <p>Starting from RF 4.1, it is possible to use hexadecimal, octal and binary numbers by prefixing values with <code>0x</code>, <code>0o</code> and <code>0b</code>, respectively.</p> 312 | <p>Starting from RF 4.1, spaces and underscores can be used as visual separators for digit grouping purposes.</p> 313 | <p>Examples: <code>42</code>, <code>-1</code>, <code>0b1010</code>, <code>10 000 000</code>, <code>0xBAD_C0FFEE</code></p> 314 | 315 | string 316 | float 317 | 318 | 319 | __init__ 320 | Test Endpoint 321 | Test Invalid Url 322 | 323 | 324 | 325 | <p>Strings must be Python <a href="https://docs.python.org/library/stdtypes.html#list">list</a> literals. They are converted to actual lists using the <a href="https://docs.python.org/library/ast.html#ast.literal_eval">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including lists and other containers.</p> 326 | <p>If the type has nested types like <code>list[int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p> 327 | <p>Examples: <code>['one', 'two']</code>, <code>[('one', 1), ('two', 2)]</code></p> 328 | 329 | string 330 | Sequence 331 | 332 | 333 | __init__ 334 | 335 | 336 | 337 | <p>String <code>NONE</code> (case-insensitive) is converted to Python <code>None</code> object. Other values cause an error.</p> 338 | 339 | string 340 | 341 | 342 | __init__ 343 | 344 | 345 | 346 | <p>Strings are converted <a href="https://docs.python.org/library/pathlib.html">Path</a> objects. On Windows <code>/</code> is converted to <code>\</code> automatically.</p> 347 | <p>Examples: <code>/tmp/absolute/path</code>, <code>relative/path/to/file.ext</code>, <code>name.txt</code></p> 348 | 349 | string 350 | PurePath 351 | 352 | 353 | __init__ 354 | 355 | 356 | 357 | <p>All arguments are converted to Unicode strings.</p> 358 | 359 | Any 360 | 361 | 362 | __init__ 363 | Test Endpoint 364 | Test Invalid Url 365 | Test Unauthorized 366 | 367 | 368 | 369 | <p>Strings must be Python <a href="https://docs.python.org/library/stdtypes.html#tuple">tuple</a> literals. They are converted to actual tuples using the <a href="https://docs.python.org/library/ast.html#ast.literal_eval">ast.literal_eval</a> function. They can contain any values <code>ast.literal_eval</code> supports, including tuples and other containers.</p> 370 | <p>If the type has nested types like <code>tuple[str, int, int]</code>, items are converted to those types automatically. This in new in Robot Framework 6.0.</p> 371 | <p>Examples: <code>('one', 'two')</code>, <code>(('one', 1), ('two', 2))</code></p> 372 | 373 | string 374 | Sequence 375 | 376 | 377 | __init__ 378 | 379 | 380 | 381 | <p>The available levels for the response_validation parameter.</p> 382 | 383 | string 384 | 385 | 386 | __init__ 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | -------------------------------------------------------------------------------- /src/OpenApiDriver/openapidriver.py: -------------------------------------------------------------------------------- 1 | """ 2 | # OpenApiDriver for Robot Framework® 3 | 4 | OpenApiDriver is an extension of the Robot Framework® DataDriver library that allows 5 | for generation and execution of test cases based on the information in an OpenAPI 6 | document (also known as Swagger document). 7 | This document explains how to use the OpenApiDriver library. 8 | 9 | For more information about Robot Framework®, see http://robotframework.org. 10 | 11 | For more information about the DataDriver library, see 12 | https://github.com/Snooz82/robotframework-datadriver. 13 | 14 | --- 15 | 16 | > Note: OpenApiDriver is still under development so there are currently 17 | restrictions / limitations that you may encounter when using this library to run 18 | tests against an API. See [Limitations](#limitations) for details. 19 | 20 | --- 21 | 22 | ## Installation 23 | 24 | If you already have Python >= 3.8 with pip installed, you can simply run: 25 | 26 | `pip install --upgrade robotframework-openapidriver` 27 | 28 | --- 29 | 30 | ## OpenAPI (aka Swagger) 31 | 32 | The OpenAPI Specification (OAS) defines a standard, language-agnostic interface 33 | to RESTful APIs, see https://swagger.io/specification/ 34 | 35 | The OpenApiDriver module implements a reader class that generates a test case for 36 | each path, method and response (i.e. every response for each endpoint) that is defined 37 | in an OpenAPI document, typically an openapi.json or openapi.yaml file. 38 | 39 | > Note: OpenApiDriver is designed for APIs based on the OAS v3 40 | The library has not been tested for APIs based on the OAS v2. 41 | 42 | --- 43 | 44 | ## Getting started 45 | 46 | Before trying to use OpenApiDriver to run automatic validations on the target API 47 | it's recommended to first ensure that the openapi document for the API is valid 48 | under the OpenAPI Specification. 49 | 50 | This can be done using the command line interface of a package that is installed as 51 | a prerequisite for OpenApiDriver. 52 | Both a local openapi.json or openapi.yaml file or one hosted by the API server 53 | can be checked using the `prance validate ` shell command: 54 | 55 | ```shell 56 | prance validate --backend=openapi-spec-validator http://localhost:8000/openapi.json 57 | Processing "http://localhost:8000/openapi.json"... 58 | -> Resolving external references. 59 | Validates OK as OpenAPI 3.0.2! 60 | 61 | prance validate --backend=openapi-spec-validator /tests/files/petstore_openapi.yaml 62 | Processing "/tests/files/petstore_openapi.yaml"... 63 | -> Resolving external references. 64 | Validates OK as OpenAPI 3.0.2! 65 | ``` 66 | 67 | You'll have to change the url or file reference to the location of the openapi 68 | document for your API. 69 | 70 | > Note: Although recursion is technically allowed under the OAS, tool support is limited 71 | and changing the OAS to not use recursion is recommended. 72 | OpenApiDriver has limited support for parsing OpenAPI documents with 73 | recursion in them. See the `recursion_limit` and `recursion_default` parameters. 74 | 75 | If the openapi document passes this validation, the next step is trying to do a test 76 | run with a minimal test suite. 77 | The example below can be used, with `source` and `origin` altered to fit your situation. 78 | 79 | ``` robotframework 80 | *** Settings *** 81 | Library OpenApiDriver 82 | ... source=http://localhost:8000/openapi.json 83 | ... origin=http://localhost:8000 84 | Test Template Validate Using Test Endpoint Keyword 85 | 86 | *** Test Cases *** 87 | Test Endpoint for ${method} on ${path} where ${status_code} is expected 88 | 89 | *** Keywords *** 90 | Validate Using Test Endpoint Keyword 91 | [Arguments] ${path} ${method} ${status_code} 92 | Test Endpoint 93 | ... path=${path} method=${method} status_code=${status_code} 94 | 95 | ``` 96 | 97 | Running the above suite for the first time is likely to result in some 98 | errors / failed tests. 99 | You should look at the Robot Framework `log.html` to determine the reasons 100 | for the failing tests. 101 | Depending on the reasons for the failures, different solutions are possible. 102 | 103 | Details about the OpenApiDriver library parameters that you may need can be found 104 | [here](https://marketsquare.github.io/robotframework-openapidriver/openapidriver.html). 105 | 106 | The OpenApiDriver also support handling of relations between resources within the scope 107 | of the API being validated as well as handling dependencies on resources outside the 108 | scope of the API. In addition there is support for handling restrictions on the values 109 | of parameters and properties. 110 | 111 | Details about the `mappings_path` variable usage can be found 112 | [here](https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html). 113 | 114 | --- 115 | 116 | ## Limitations 117 | 118 | There are currently a number of limitations to supported API structures, supported 119 | data types and properties. The following list details the most important ones: 120 | - Only JSON request and response bodies are supported. 121 | - No support for per-path authorization levels (only simple 401 / 403 validation). 122 | 123 | """ 124 | 125 | from pathlib import Path 126 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Union 127 | 128 | from DataDriver import DataDriver 129 | from requests.auth import AuthBase 130 | from requests.cookies import RequestsCookieJar as CookieJar 131 | from robot.api.deco import library 132 | 133 | from OpenApiDriver.openapi_executors import OpenApiExecutors, ValidationLevel 134 | from OpenApiDriver.openapi_reader import OpenApiReader 135 | 136 | 137 | @library(scope="TEST SUITE", doc_format="ROBOT") 138 | class OpenApiDriver(OpenApiExecutors, DataDriver): 139 | """ 140 | Visit the [https://github.com/MarketSquare/robotframework-openapidriver | library page] 141 | for an introduction and examples. 142 | """ 143 | 144 | def __init__( # pylint: disable=too-many-arguments, too-many-locals, dangerous-default-value 145 | self, 146 | source: str, 147 | origin: str = "", 148 | base_path: str = "", 149 | included_paths: Optional[Iterable[str]] = None, 150 | ignored_paths: Optional[Iterable[str]] = None, 151 | ignored_responses: Optional[Iterable[int]] = None, 152 | ignored_testcases: Optional[Iterable[Tuple[str, str, int]]] = None, 153 | response_validation: ValidationLevel = ValidationLevel.WARN, 154 | disable_server_validation: bool = True, 155 | mappings_path: Union[str, Path] = "", 156 | invalid_property_default_response: int = 422, 157 | default_id_property_name: str = "id", 158 | faker_locale: Optional[Union[str, List[str]]] = None, 159 | require_body_for_invalid_url: bool = False, 160 | recursion_limit: int = 1, 161 | recursion_default: Any = {}, 162 | username: str = "", 163 | password: str = "", 164 | security_token: str = "", 165 | auth: Optional[AuthBase] = None, 166 | cert: Optional[Union[str, Tuple[str, str]]] = None, 167 | verify_tls: Optional[Union[bool, str]] = True, 168 | extra_headers: Optional[Dict[str, str]] = None, 169 | cookies: Optional[Union[Dict[str, str], CookieJar]] = None, 170 | proxies: Optional[Dict[str, str]] = None, 171 | ): 172 | """ 173 | == Base parameters == 174 | 175 | === source === 176 | An absolute path to an openapi.json or openapi.yaml file or an url to such a file. 177 | 178 | === origin === 179 | The server (and port) of the target server. E.g. ``https://localhost:8000`` 180 | 181 | === base_path === 182 | The routing between ``origin`` and the endpoints as found in the ``paths`` 183 | section in the openapi document. 184 | E.g. ``/petshop/v2``. 185 | 186 | == Test case generation and execution == 187 | 188 | === included_paths === 189 | A list of paths that will be included when generating the test cases. 190 | The ``*`` character can be used at the end of a partial path to include all paths 191 | starting with the partial path (wildcard include). 192 | 193 | === ignored_paths === 194 | A list of paths that will be ignored when generating the test cases. 195 | The ``*`` character can be used at the end of a partial path to ignore all paths 196 | starting with the partial path (wildcard ignore). 197 | 198 | === ignored_responses === 199 | A list of responses that will be ignored when generating the test cases. 200 | 201 | === ignored_testcases === 202 | A list of specific test cases that, if it would be generated, will be ignored. 203 | Specific test cases to ignore must be specified as a ``Tuple`` or ``List`` 204 | of ``path``, ``method`` and ``response``. 205 | 206 | === response_validation === 207 | By default, a ``WARN`` is logged when the Response received after a Request does not 208 | comply with the schema as defined in the openapi document for the given operation. The 209 | following values are supported: 210 | 211 | - ``DISABLED``: All Response validation errors will be ignored 212 | - ``INFO``: Any Response validation erros will be logged at ``INFO`` level 213 | - ``WARN``: Any Response validation erros will be logged at ``WARN`` level 214 | - ``STRICT``: The Test Case will fail on any Response validation errors 215 | 216 | === disable_server_validation === 217 | If enabled by setting this parameter to ``True``, the Response validation will also 218 | include possible errors for Requests made to a server address that is not defined in 219 | the list of servers in the openapi document. This generally means that if there is a 220 | mismatch, every Test Case will raise this error. Note that ``localhost`` and 221 | ``127.0.0.1`` are not considered the same by Response validation. 222 | 223 | == API-specific configurations == 224 | 225 | === mappings_path === 226 | See [https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html | this page] 227 | for an in-depth explanation. 228 | 229 | === invalid_property_default_response === 230 | The default response code for requests with a JSON body that does not comply 231 | with the schema. 232 | Example: a value outside the specified range or a string value 233 | for a property defined as integer in the schema. 234 | 235 | === default_id_property_name === 236 | The default name for the property that identifies a resource (i.e. a unique 237 | entity) within the API. 238 | The default value for this property name is ``id``. 239 | If the target API uses a different name for all the resources within the API, 240 | you can configure it globally using this property. 241 | 242 | If different property names are used for the unique identifier for different 243 | types of resources, an ``ID_MAPPING`` can be implemented using the ``mappings_path``. 244 | 245 | === faker_locale === 246 | A locale string or list of locale strings to pass to the Faker library to be 247 | used in generation of string data for supported format types. 248 | 249 | === require_body_for_invalid_url === 250 | When a request is made against an invalid url, this usually is because of a "404" request; 251 | a request for a resource that does not exist. Depending on API implementation, when a 252 | request with a missing or invalid request body is made on a non-existent resource, 253 | either a 404 or a 422 or 400 Response is normally returned. If the API being tested 254 | processes the request body before checking if the requested resource exists, set 255 | this parameter to True. 256 | 257 | == Parsing parameters == 258 | 259 | === recursion_limit === 260 | The recursion depth to which to fully parse recursive references before the 261 | `recursion_default` is used to end the recursion. 262 | 263 | === recursion_default === 264 | The value that is used instead of the referenced schema when the 265 | `recursion_limit` has been reached. 266 | The default `{}` represents an empty object in JSON. 267 | Depending on schema definitions, this may cause schema validation errors. 268 | If this is the case, 'None' (``${NONE}`` in Robot Framework) or an empty list 269 | can be tried as an alternative. 270 | 271 | == Security-related parameters == 272 | _Note: these parameters are equivalent to those in the ``requests`` library._ 273 | 274 | === username === 275 | The username to be used for Basic Authentication. 276 | 277 | === password === 278 | The password to be used for Basic Authentication. 279 | 280 | === security_token === 281 | The token to be used for token based security using the ``Authorization`` header. 282 | 283 | === auth === 284 | A [https://requests.readthedocs.io/en/latest/api/#authentication | requests ``AuthBase`` instance] 285 | to be used for authentication instead of the ``username`` and ``password``. 286 | 287 | === cert === 288 | The SSL certificate to use with all requests. 289 | If string: the path to ssl client cert file (.pem). 290 | If tuple: the ('cert', 'key') pair. 291 | 292 | === verify_tls === 293 | Whether or not to verify the TLS / SSL certificate of the server. 294 | If boolean: whether or not to verify the server TLS certificate. 295 | If string: path to a CA bundle to use for verification. 296 | 297 | === extra_headers === 298 | A dictionary with extra / custom headers that will be send with every request. 299 | This parameter can be used to send headers that are not documented in the 300 | openapi document or to provide an API-key. 301 | 302 | === cookies === 303 | A dictionary or [https://docs.python.org/3/library/http.cookiejar.html#http.cookiejar.CookieJar | CookieJar object] 304 | to send with all requests. 305 | 306 | === proxies === 307 | A dictionary of 'protocol': 'proxy url' to use for all requests. 308 | """ 309 | included_paths = included_paths if included_paths else () 310 | ignored_paths = ignored_paths if ignored_paths else () 311 | ignored_responses = ignored_responses if ignored_responses else () 312 | ignored_testcases = ignored_testcases if ignored_testcases else () 313 | 314 | mappings_path = Path(mappings_path).as_posix() 315 | OpenApiExecutors.__init__( 316 | self, 317 | source=source, 318 | origin=origin, 319 | base_path=base_path, 320 | response_validation=response_validation, 321 | disable_server_validation=disable_server_validation, 322 | mappings_path=mappings_path, 323 | invalid_property_default_response=invalid_property_default_response, 324 | default_id_property_name=default_id_property_name, 325 | faker_locale=faker_locale, 326 | require_body_for_invalid_url=require_body_for_invalid_url, 327 | recursion_limit=recursion_limit, 328 | recursion_default=recursion_default, 329 | username=username, 330 | password=password, 331 | security_token=security_token, 332 | auth=auth, 333 | cert=cert, 334 | verify_tls=verify_tls, 335 | extra_headers=extra_headers, 336 | cookies=cookies, 337 | proxies=proxies, 338 | ) 339 | 340 | paths = self.openapi_spec["paths"] 341 | DataDriver.__init__( 342 | self, 343 | reader_class=OpenApiReader, 344 | paths=paths, 345 | included_paths=included_paths, 346 | ignored_paths=ignored_paths, 347 | ignored_responses=ignored_responses, 348 | ignored_testcases=ignored_testcases, 349 | ) 350 | 351 | 352 | class DocumentationGenerator(OpenApiDriver): 353 | __doc__ = OpenApiDriver.__doc__ 354 | 355 | @staticmethod 356 | def get_keyword_names() -> List[str]: 357 | """Curated keywords for libdoc and libspec.""" 358 | return [ 359 | "test_unauthorized", 360 | "test_invalid_url", 361 | "test_endpoint", 362 | ] # pragma: no cover 363 | -------------------------------------------------------------------------------- /src/OpenApiDriver/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarketSquare/robotframework-openapidriver/df2c65884e2ee91d24f70552bb1f7e6c1f58b5e6/src/OpenApiDriver/py.typed -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-function-docstring, unused-argument 2 | import pathlib 3 | import subprocess 4 | from importlib.metadata import version 5 | 6 | from invoke.context import Context 7 | from invoke.tasks import task 8 | 9 | from OpenApiDriver import openapidriver 10 | 11 | ROOT = pathlib.Path(__file__).parent.resolve().as_posix() 12 | VERSION = version("robotframework-openapidriver") 13 | 14 | 15 | @task 16 | def start_api(context: Context) -> None: 17 | cmd = [ 18 | "python", 19 | "-m", 20 | "uvicorn", 21 | "testserver:app", 22 | f"--app-dir {ROOT}/tests/server", 23 | "--host 0.0.0.0", 24 | "--port 8000", 25 | "--reload", 26 | f"--reload-dir {ROOT}/tests/server", 27 | ] 28 | subprocess.run(" ".join(cmd), shell=True, check=False) 29 | 30 | 31 | @task 32 | def utests(context: Context) -> None: 33 | cmd = [ 34 | "coverage", 35 | "run", 36 | "-m", 37 | "unittest", 38 | "discover ", 39 | f"{ROOT}/tests/unittests", 40 | ] 41 | subprocess.run(" ".join(cmd), shell=True, check=False) 42 | 43 | 44 | @task 45 | def atests(context: Context) -> None: 46 | cmd = [ 47 | "coverage", 48 | "run", 49 | "-m", 50 | "robot", 51 | f"--argumentfile={ROOT}/tests/rf_cli.args", 52 | f"--variable=root:{ROOT}", 53 | f"--outputdir={ROOT}/tests/logs", 54 | "--loglevel=TRACE:DEBUG", 55 | f"{ROOT}/tests/suites/", 56 | ] 57 | subprocess.run(" ".join(cmd), shell=True, check=False) 58 | 59 | 60 | @task(utests, atests) 61 | def tests(context: Context) -> None: 62 | subprocess.run("coverage combine", shell=True, check=False) 63 | subprocess.run("coverage report", shell=True, check=False) 64 | subprocess.run("coverage html", shell=True, check=False) 65 | 66 | 67 | @task 68 | def type_check(context: Context) -> None: 69 | subprocess.run(f"mypy {ROOT}/src", shell=True, check=False) 70 | subprocess.run(f"pyright {ROOT}/src", shell=True, check=False) 71 | 72 | 73 | @task 74 | def lint(context: Context) -> None: 75 | subprocess.run(f"ruff {ROOT}", shell=True, check=False) 76 | subprocess.run(f"pylint {ROOT}/src/OpenApiDriver", shell=True, check=False) 77 | subprocess.run(f"robocop {ROOT}/tests/suites", shell=True, check=False) 78 | 79 | 80 | @task 81 | def format_code(context: Context) -> None: 82 | subprocess.run(f"black {ROOT}", shell=True, check=False) 83 | subprocess.run(f"isort {ROOT}", shell=True, check=False) 84 | subprocess.run(f"robotidy {ROOT}/tests/suites", shell=True, check=False) 85 | 86 | 87 | @task 88 | def libdoc(context: Context) -> None: 89 | print(f"Generating libdoc for library version {VERSION}") 90 | json_file = f"{ROOT}/tests/files/petstore_openapi.json" 91 | source = f"OpenApiDriver.openapidriver.DocumentationGenerator::{json_file}" 92 | target = f"{ROOT}/docs/openapidriver.html" 93 | cmd = [ 94 | "python", 95 | "-m", 96 | "robot.libdoc", 97 | "-n OpenApiDriver", 98 | f"-v {VERSION}", 99 | source, 100 | target, 101 | ] 102 | subprocess.run(" ".join(cmd), shell=True, check=False) 103 | 104 | 105 | @task 106 | def libspec(context: Context) -> None: 107 | print(f"Generating libspec for library version {VERSION}") 108 | json_file = f"{ROOT}/tests/files/petstore_openapi.json" 109 | source = f"OpenApiDriver.openapidriver.DocumentationGenerator::{json_file}" 110 | target = f"{ROOT}/src/OpenApiDriver/openapidriver.libspec" 111 | cmd = [ 112 | "python", 113 | "-m", 114 | "robot.libdoc", 115 | "-n OpenApiDriver", 116 | f"-v {VERSION}", 117 | source, 118 | target, 119 | ] 120 | subprocess.run(" ".join(cmd), shell=True, check=False) 121 | 122 | 123 | @task 124 | def readme(context: Context) -> None: 125 | front_matter = """---\n---\n""" 126 | with open(f"{ROOT}/docs/README.md", "w", encoding="utf-8") as readme: 127 | doc_string = openapidriver.__doc__ 128 | readme.write(front_matter) 129 | readme.write(str(doc_string).replace("\\", "\\\\").replace("\\\\*", "\\*")) 130 | 131 | 132 | @task(format_code, libdoc, libspec, readme) 133 | def build(context: Context) -> None: 134 | subprocess.run("poetry build", shell=True, check=False) 135 | -------------------------------------------------------------------------------- /tests/files/petstore_openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "info": { 4 | "title": "Swagger Petstore - OpenAPI 3.0", 5 | "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", 6 | "termsOfService": "http://swagger.io/terms/", 7 | "contact": { 8 | "email": "apiteam@swagger.io" 9 | }, 10 | "license": { 11 | "name": "Apache 2.0", 12 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 13 | }, 14 | "version": "1.0.6" 15 | }, 16 | "externalDocs": { 17 | "description": "Find out more about Swagger", 18 | "url": "http://swagger.io" 19 | }, 20 | "servers": [ 21 | { 22 | "url": "/api/v3" 23 | } 24 | ], 25 | "tags": [ 26 | { 27 | "name": "pet", 28 | "description": "Everything about your Pets", 29 | "externalDocs": { 30 | "description": "Find out more", 31 | "url": "http://swagger.io" 32 | } 33 | }, 34 | { 35 | "name": "store", 36 | "description": "Operations about user" 37 | }, 38 | { 39 | "name": "user", 40 | "description": "Access to Petstore orders", 41 | "externalDocs": { 42 | "description": "Find out more about our store", 43 | "url": "http://swagger.io" 44 | } 45 | } 46 | ], 47 | "paths": { 48 | "/pet": { 49 | "put": { 50 | "tags": [ 51 | "pet" 52 | ], 53 | "summary": "Update an existing pet", 54 | "description": "Update an existing pet by Id", 55 | "operationId": "updatePet", 56 | "requestBody": { 57 | "description": "Update an existent pet in the store", 58 | "content": { 59 | "application/json": { 60 | "schema": { 61 | "$ref": "#/components/schemas/Pet" 62 | } 63 | }, 64 | "application/xml": { 65 | "schema": { 66 | "$ref": "#/components/schemas/Pet" 67 | } 68 | }, 69 | "application/x-www-form-urlencoded": { 70 | "schema": { 71 | "$ref": "#/components/schemas/Pet" 72 | } 73 | } 74 | }, 75 | "required": true 76 | }, 77 | "responses": { 78 | "200": { 79 | "description": "Successful operation", 80 | "content": { 81 | "application/xml": { 82 | "schema": { 83 | "$ref": "#/components/schemas/Pet" 84 | } 85 | }, 86 | "application/json": { 87 | "schema": { 88 | "$ref": "#/components/schemas/Pet" 89 | } 90 | } 91 | } 92 | }, 93 | "400": { 94 | "description": "Invalid ID supplied" 95 | }, 96 | "404": { 97 | "description": "Pet not found" 98 | }, 99 | "405": { 100 | "description": "Validation exception" 101 | } 102 | }, 103 | "security": [ 104 | { 105 | "petstore_auth": [ 106 | "write:pets", 107 | "read:pets" 108 | ] 109 | } 110 | ] 111 | }, 112 | "post": { 113 | "tags": [ 114 | "pet" 115 | ], 116 | "summary": "Add a new pet to the store", 117 | "description": "Add a new pet to the store", 118 | "operationId": "addPet", 119 | "requestBody": { 120 | "description": "Create a new pet in the store", 121 | "content": { 122 | "application/json": { 123 | "schema": { 124 | "$ref": "#/components/schemas/Pet" 125 | } 126 | }, 127 | "application/xml": { 128 | "schema": { 129 | "$ref": "#/components/schemas/Pet" 130 | } 131 | }, 132 | "application/x-www-form-urlencoded": { 133 | "schema": { 134 | "$ref": "#/components/schemas/Pet" 135 | } 136 | } 137 | }, 138 | "required": true 139 | }, 140 | "responses": { 141 | "200": { 142 | "description": "Successful operation", 143 | "content": { 144 | "application/xml": { 145 | "schema": { 146 | "$ref": "#/components/schemas/Pet" 147 | } 148 | }, 149 | "application/json": { 150 | "schema": { 151 | "$ref": "#/components/schemas/Pet" 152 | } 153 | } 154 | } 155 | }, 156 | "405": { 157 | "description": "Invalid input" 158 | } 159 | }, 160 | "security": [ 161 | { 162 | "petstore_auth": [ 163 | "write:pets", 164 | "read:pets" 165 | ] 166 | } 167 | ] 168 | } 169 | }, 170 | "/pet/findByStatus": { 171 | "get": { 172 | "tags": [ 173 | "pet" 174 | ], 175 | "summary": "Finds Pets by status", 176 | "description": "Multiple status values can be provided with comma separated strings", 177 | "operationId": "findPetsByStatus", 178 | "parameters": [ 179 | { 180 | "name": "status", 181 | "in": "query", 182 | "description": "Status values that need to be considered for filter", 183 | "required": false, 184 | "explode": true, 185 | "schema": { 186 | "type": "string", 187 | "default": "available", 188 | "enum": [ 189 | "available", 190 | "pending", 191 | "sold" 192 | ] 193 | } 194 | } 195 | ], 196 | "responses": { 197 | "200": { 198 | "description": "successful operation", 199 | "content": { 200 | "application/xml": { 201 | "schema": { 202 | "type": "array", 203 | "items": { 204 | "$ref": "#/components/schemas/Pet" 205 | } 206 | } 207 | }, 208 | "application/json": { 209 | "schema": { 210 | "type": "array", 211 | "items": { 212 | "$ref": "#/components/schemas/Pet" 213 | } 214 | } 215 | } 216 | } 217 | }, 218 | "400": { 219 | "description": "Invalid status value" 220 | } 221 | }, 222 | "security": [ 223 | { 224 | "petstore_auth": [ 225 | "write:pets", 226 | "read:pets" 227 | ] 228 | } 229 | ] 230 | } 231 | }, 232 | "/pet/findByTags": { 233 | "get": { 234 | "tags": [ 235 | "pet" 236 | ], 237 | "summary": "Finds Pets by tags", 238 | "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", 239 | "operationId": "findPetsByTags", 240 | "parameters": [ 241 | { 242 | "name": "tags", 243 | "in": "query", 244 | "description": "Tags to filter by", 245 | "required": false, 246 | "explode": true, 247 | "schema": { 248 | "type": "array", 249 | "items": { 250 | "type": "string" 251 | } 252 | } 253 | } 254 | ], 255 | "responses": { 256 | "200": { 257 | "description": "successful operation", 258 | "content": { 259 | "application/xml": { 260 | "schema": { 261 | "type": "array", 262 | "items": { 263 | "$ref": "#/components/schemas/Pet" 264 | } 265 | } 266 | }, 267 | "application/json": { 268 | "schema": { 269 | "type": "array", 270 | "items": { 271 | "$ref": "#/components/schemas/Pet" 272 | } 273 | } 274 | } 275 | } 276 | }, 277 | "400": { 278 | "description": "Invalid tag value" 279 | } 280 | }, 281 | "security": [ 282 | { 283 | "petstore_auth": [ 284 | "write:pets", 285 | "read:pets" 286 | ] 287 | } 288 | ] 289 | } 290 | }, 291 | "/pet/{petId}": { 292 | "get": { 293 | "tags": [ 294 | "pet" 295 | ], 296 | "summary": "Find pet by ID", 297 | "description": "Returns a single pet", 298 | "operationId": "getPetById", 299 | "parameters": [ 300 | { 301 | "name": "petId", 302 | "in": "path", 303 | "description": "ID of pet to return", 304 | "required": true, 305 | "schema": { 306 | "type": "integer", 307 | "format": "int64" 308 | } 309 | } 310 | ], 311 | "responses": { 312 | "200": { 313 | "description": "successful operation", 314 | "content": { 315 | "application/xml": { 316 | "schema": { 317 | "$ref": "#/components/schemas/Pet" 318 | } 319 | }, 320 | "application/json": { 321 | "schema": { 322 | "$ref": "#/components/schemas/Pet" 323 | } 324 | } 325 | } 326 | }, 327 | "400": { 328 | "description": "Invalid ID supplied" 329 | }, 330 | "404": { 331 | "description": "Pet not found" 332 | } 333 | }, 334 | "security": [ 335 | { 336 | "api_key": [] 337 | }, 338 | { 339 | "petstore_auth": [ 340 | "write:pets", 341 | "read:pets" 342 | ] 343 | } 344 | ] 345 | }, 346 | "post": { 347 | "tags": [ 348 | "pet" 349 | ], 350 | "summary": "Updates a pet in the store with form data", 351 | "description": "", 352 | "operationId": "updatePetWithForm", 353 | "parameters": [ 354 | { 355 | "name": "petId", 356 | "in": "path", 357 | "description": "ID of pet that needs to be updated", 358 | "required": true, 359 | "schema": { 360 | "type": "integer", 361 | "format": "int64" 362 | } 363 | }, 364 | { 365 | "name": "name", 366 | "in": "query", 367 | "description": "Name of pet that needs to be updated", 368 | "schema": { 369 | "type": "string" 370 | } 371 | }, 372 | { 373 | "name": "status", 374 | "in": "query", 375 | "description": "Status of pet that needs to be updated", 376 | "schema": { 377 | "type": "string" 378 | } 379 | } 380 | ], 381 | "responses": { 382 | "405": { 383 | "description": "Invalid input" 384 | } 385 | }, 386 | "security": [ 387 | { 388 | "petstore_auth": [ 389 | "write:pets", 390 | "read:pets" 391 | ] 392 | } 393 | ] 394 | }, 395 | "delete": { 396 | "tags": [ 397 | "pet" 398 | ], 399 | "summary": "Deletes a pet", 400 | "description": "", 401 | "operationId": "deletePet", 402 | "parameters": [ 403 | { 404 | "name": "api_key", 405 | "in": "header", 406 | "description": "", 407 | "required": false, 408 | "schema": { 409 | "type": "string" 410 | } 411 | }, 412 | { 413 | "name": "petId", 414 | "in": "path", 415 | "description": "Pet id to delete", 416 | "required": true, 417 | "schema": { 418 | "type": "integer", 419 | "format": "int64" 420 | } 421 | } 422 | ], 423 | "responses": { 424 | "400": { 425 | "description": "Invalid pet value" 426 | } 427 | }, 428 | "security": [ 429 | { 430 | "petstore_auth": [ 431 | "write:pets", 432 | "read:pets" 433 | ] 434 | } 435 | ] 436 | } 437 | }, 438 | "/pet/{petId}/uploadImage": { 439 | "post": { 440 | "tags": [ 441 | "pet" 442 | ], 443 | "summary": "uploads an image", 444 | "description": "", 445 | "operationId": "uploadFile", 446 | "parameters": [ 447 | { 448 | "name": "petId", 449 | "in": "path", 450 | "description": "ID of pet to update", 451 | "required": true, 452 | "schema": { 453 | "type": "integer", 454 | "format": "int64" 455 | } 456 | }, 457 | { 458 | "name": "additionalMetadata", 459 | "in": "query", 460 | "description": "Additional Metadata", 461 | "required": false, 462 | "schema": { 463 | "type": "string" 464 | } 465 | } 466 | ], 467 | "requestBody": { 468 | "content": { 469 | "application/octet-stream": { 470 | "schema": { 471 | "type": "string", 472 | "format": "binary" 473 | } 474 | } 475 | } 476 | }, 477 | "responses": { 478 | "200": { 479 | "description": "successful operation", 480 | "content": { 481 | "application/json": { 482 | "schema": { 483 | "$ref": "#/components/schemas/ApiResponse" 484 | } 485 | } 486 | } 487 | } 488 | }, 489 | "security": [ 490 | { 491 | "petstore_auth": [ 492 | "write:pets", 493 | "read:pets" 494 | ] 495 | } 496 | ] 497 | } 498 | }, 499 | "/store/inventory": { 500 | "get": { 501 | "tags": [ 502 | "store" 503 | ], 504 | "summary": "Returns pet inventories by status", 505 | "description": "Returns a map of status codes to quantities", 506 | "operationId": "getInventory", 507 | "responses": { 508 | "200": { 509 | "description": "successful operation", 510 | "content": { 511 | "application/json": { 512 | "schema": { 513 | "type": "object", 514 | "additionalProperties": { 515 | "type": "integer", 516 | "format": "int32" 517 | } 518 | } 519 | } 520 | } 521 | } 522 | }, 523 | "security": [ 524 | { 525 | "api_key": [] 526 | } 527 | ] 528 | } 529 | }, 530 | "/store/order": { 531 | "post": { 532 | "tags": [ 533 | "store" 534 | ], 535 | "summary": "Place an order for a pet", 536 | "description": "Place a new order in the store", 537 | "operationId": "placeOrder", 538 | "requestBody": { 539 | "content": { 540 | "application/json": { 541 | "schema": { 542 | "$ref": "#/components/schemas/Order" 543 | } 544 | }, 545 | "application/xml": { 546 | "schema": { 547 | "$ref": "#/components/schemas/Order" 548 | } 549 | }, 550 | "application/x-www-form-urlencoded": { 551 | "schema": { 552 | "$ref": "#/components/schemas/Order" 553 | } 554 | } 555 | } 556 | }, 557 | "responses": { 558 | "200": { 559 | "description": "successful operation", 560 | "content": { 561 | "application/json": { 562 | "schema": { 563 | "$ref": "#/components/schemas/Order" 564 | } 565 | } 566 | } 567 | }, 568 | "405": { 569 | "description": "Invalid input" 570 | } 571 | } 572 | } 573 | }, 574 | "/store/order/{orderId}": { 575 | "get": { 576 | "tags": [ 577 | "store" 578 | ], 579 | "summary": "Find purchase order by ID", 580 | "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generated exceptions", 581 | "operationId": "getOrderById", 582 | "parameters": [ 583 | { 584 | "name": "orderId", 585 | "in": "path", 586 | "description": "ID of order that needs to be fetched", 587 | "required": true, 588 | "schema": { 589 | "type": "integer", 590 | "format": "int64" 591 | } 592 | } 593 | ], 594 | "responses": { 595 | "200": { 596 | "description": "successful operation", 597 | "content": { 598 | "application/xml": { 599 | "schema": { 600 | "$ref": "#/components/schemas/Order" 601 | } 602 | }, 603 | "application/json": { 604 | "schema": { 605 | "$ref": "#/components/schemas/Order" 606 | } 607 | } 608 | } 609 | }, 610 | "400": { 611 | "description": "Invalid ID supplied" 612 | }, 613 | "404": { 614 | "description": "Order not found" 615 | } 616 | } 617 | }, 618 | "delete": { 619 | "tags": [ 620 | "store" 621 | ], 622 | "summary": "Delete purchase order by ID", 623 | "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", 624 | "operationId": "deleteOrder", 625 | "parameters": [ 626 | { 627 | "name": "orderId", 628 | "in": "path", 629 | "description": "ID of the order that needs to be deleted", 630 | "required": true, 631 | "schema": { 632 | "type": "integer", 633 | "format": "int64" 634 | } 635 | } 636 | ], 637 | "responses": { 638 | "400": { 639 | "description": "Invalid ID supplied" 640 | }, 641 | "404": { 642 | "description": "Order not found" 643 | } 644 | } 645 | } 646 | }, 647 | "/user": { 648 | "post": { 649 | "tags": [ 650 | "user" 651 | ], 652 | "summary": "Create user", 653 | "description": "This can only be done by the logged in user.", 654 | "operationId": "createUser", 655 | "requestBody": { 656 | "description": "Created user object", 657 | "content": { 658 | "application/json": { 659 | "schema": { 660 | "$ref": "#/components/schemas/User" 661 | } 662 | }, 663 | "application/xml": { 664 | "schema": { 665 | "$ref": "#/components/schemas/User" 666 | } 667 | }, 668 | "application/x-www-form-urlencoded": { 669 | "schema": { 670 | "$ref": "#/components/schemas/User" 671 | } 672 | } 673 | } 674 | }, 675 | "responses": { 676 | "default": { 677 | "description": "successful operation", 678 | "content": { 679 | "application/json": { 680 | "schema": { 681 | "$ref": "#/components/schemas/User" 682 | } 683 | }, 684 | "application/xml": { 685 | "schema": { 686 | "$ref": "#/components/schemas/User" 687 | } 688 | } 689 | } 690 | } 691 | } 692 | } 693 | }, 694 | "/user/createWithList": { 695 | "post": { 696 | "tags": [ 697 | "user" 698 | ], 699 | "summary": "Creates list of users with given input array", 700 | "description": "Creates list of users with given input array", 701 | "operationId": "createUsersWithListInput", 702 | "requestBody": { 703 | "content": { 704 | "application/json": { 705 | "schema": { 706 | "type": "array", 707 | "items": { 708 | "$ref": "#/components/schemas/User" 709 | } 710 | } 711 | } 712 | } 713 | }, 714 | "responses": { 715 | "200": { 716 | "description": "Successful operation", 717 | "content": { 718 | "application/xml": { 719 | "schema": { 720 | "$ref": "#/components/schemas/User" 721 | } 722 | }, 723 | "application/json": { 724 | "schema": { 725 | "$ref": "#/components/schemas/User" 726 | } 727 | } 728 | } 729 | }, 730 | "default": { 731 | "description": "successful operation" 732 | } 733 | } 734 | } 735 | }, 736 | "/user/login": { 737 | "get": { 738 | "tags": [ 739 | "user" 740 | ], 741 | "summary": "Logs user into the system", 742 | "description": "", 743 | "operationId": "loginUser", 744 | "parameters": [ 745 | { 746 | "name": "username", 747 | "in": "query", 748 | "description": "The user name for login", 749 | "required": false, 750 | "schema": { 751 | "type": "string" 752 | } 753 | }, 754 | { 755 | "name": "password", 756 | "in": "query", 757 | "description": "The password for login in clear text", 758 | "required": false, 759 | "schema": { 760 | "type": "string" 761 | } 762 | } 763 | ], 764 | "responses": { 765 | "200": { 766 | "description": "successful operation", 767 | "headers": { 768 | "X-Rate-Limit": { 769 | "description": "calls per hour allowed by the user", 770 | "schema": { 771 | "type": "integer", 772 | "format": "int32" 773 | } 774 | }, 775 | "X-Expires-After": { 776 | "description": "date in UTC when token expires", 777 | "schema": { 778 | "type": "string", 779 | "format": "date-time" 780 | } 781 | } 782 | }, 783 | "content": { 784 | "application/xml": { 785 | "schema": { 786 | "type": "string" 787 | } 788 | }, 789 | "application/json": { 790 | "schema": { 791 | "type": "string" 792 | } 793 | } 794 | } 795 | }, 796 | "400": { 797 | "description": "Invalid username/password supplied" 798 | } 799 | } 800 | } 801 | }, 802 | "/user/logout": { 803 | "get": { 804 | "tags": [ 805 | "user" 806 | ], 807 | "summary": "Logs out current logged in user session", 808 | "description": "", 809 | "operationId": "logoutUser", 810 | "parameters": [], 811 | "responses": { 812 | "default": { 813 | "description": "successful operation" 814 | } 815 | } 816 | } 817 | }, 818 | "/user/{username}": { 819 | "get": { 820 | "tags": [ 821 | "user" 822 | ], 823 | "summary": "Get user by user name", 824 | "description": "", 825 | "operationId": "getUserByName", 826 | "parameters": [ 827 | { 828 | "name": "username", 829 | "in": "path", 830 | "description": "The name that needs to be fetched. Use user1 for testing. ", 831 | "required": true, 832 | "schema": { 833 | "type": "string" 834 | } 835 | } 836 | ], 837 | "responses": { 838 | "200": { 839 | "description": "successful operation", 840 | "content": { 841 | "application/xml": { 842 | "schema": { 843 | "$ref": "#/components/schemas/User" 844 | } 845 | }, 846 | "application/json": { 847 | "schema": { 848 | "$ref": "#/components/schemas/User" 849 | } 850 | } 851 | } 852 | }, 853 | "400": { 854 | "description": "Invalid username supplied" 855 | }, 856 | "404": { 857 | "description": "User not found" 858 | } 859 | } 860 | }, 861 | "put": { 862 | "tags": [ 863 | "user" 864 | ], 865 | "summary": "Update user", 866 | "description": "This can only be done by the logged in user.", 867 | "operationId": "updateUser", 868 | "parameters": [ 869 | { 870 | "name": "username", 871 | "in": "path", 872 | "description": "name that need to be deleted", 873 | "required": true, 874 | "schema": { 875 | "type": "string" 876 | } 877 | } 878 | ], 879 | "requestBody": { 880 | "description": "Update an existent user in the store", 881 | "content": { 882 | "application/json": { 883 | "schema": { 884 | "$ref": "#/components/schemas/User" 885 | } 886 | }, 887 | "application/xml": { 888 | "schema": { 889 | "$ref": "#/components/schemas/User" 890 | } 891 | }, 892 | "application/x-www-form-urlencoded": { 893 | "schema": { 894 | "$ref": "#/components/schemas/User" 895 | } 896 | } 897 | } 898 | }, 899 | "responses": { 900 | "default": { 901 | "description": "successful operation" 902 | } 903 | } 904 | }, 905 | "delete": { 906 | "tags": [ 907 | "user" 908 | ], 909 | "summary": "Delete user", 910 | "description": "This can only be done by the logged in user.", 911 | "operationId": "deleteUser", 912 | "parameters": [ 913 | { 914 | "name": "username", 915 | "in": "path", 916 | "description": "The name that needs to be deleted", 917 | "required": true, 918 | "schema": { 919 | "type": "string" 920 | } 921 | } 922 | ], 923 | "responses": { 924 | "400": { 925 | "description": "Invalid username supplied" 926 | }, 927 | "404": { 928 | "description": "User not found" 929 | } 930 | } 931 | } 932 | } 933 | }, 934 | "components": { 935 | "schemas": { 936 | "Order": { 937 | "type": "object", 938 | "properties": { 939 | "id": { 940 | "type": "integer", 941 | "format": "int64", 942 | "example": 10 943 | }, 944 | "petId": { 945 | "type": "integer", 946 | "format": "int64", 947 | "example": 198772 948 | }, 949 | "quantity": { 950 | "type": "integer", 951 | "format": "int32", 952 | "example": 7 953 | }, 954 | "shipDate": { 955 | "type": "string", 956 | "format": "date-time" 957 | }, 958 | "status": { 959 | "type": "string", 960 | "description": "Order Status", 961 | "example": "approved", 962 | "enum": [ 963 | "placed", 964 | "approved", 965 | "delivered" 966 | ] 967 | }, 968 | "complete": { 969 | "type": "boolean" 970 | } 971 | }, 972 | "xml": { 973 | "name": "order" 974 | } 975 | }, 976 | "Customer": { 977 | "type": "object", 978 | "properties": { 979 | "id": { 980 | "type": "integer", 981 | "format": "int64", 982 | "example": 100000 983 | }, 984 | "username": { 985 | "type": "string", 986 | "example": "fehguy" 987 | }, 988 | "address": { 989 | "type": "array", 990 | "xml": { 991 | "name": "addresses", 992 | "wrapped": true 993 | }, 994 | "items": { 995 | "$ref": "#/components/schemas/Address" 996 | } 997 | } 998 | }, 999 | "xml": { 1000 | "name": "customer" 1001 | } 1002 | }, 1003 | "Address": { 1004 | "type": "object", 1005 | "properties": { 1006 | "street": { 1007 | "type": "string", 1008 | "example": "437 Lytton" 1009 | }, 1010 | "city": { 1011 | "type": "string", 1012 | "example": "Palo Alto" 1013 | }, 1014 | "state": { 1015 | "type": "string", 1016 | "example": "CA" 1017 | }, 1018 | "zip": { 1019 | "type": "string", 1020 | "example": "94301" 1021 | } 1022 | }, 1023 | "xml": { 1024 | "name": "address" 1025 | } 1026 | }, 1027 | "Category": { 1028 | "type": "object", 1029 | "properties": { 1030 | "id": { 1031 | "type": "integer", 1032 | "format": "int64", 1033 | "example": 1 1034 | }, 1035 | "name": { 1036 | "type": "string", 1037 | "example": "Dogs" 1038 | } 1039 | }, 1040 | "xml": { 1041 | "name": "category" 1042 | } 1043 | }, 1044 | "User": { 1045 | "type": "object", 1046 | "properties": { 1047 | "id": { 1048 | "type": "integer", 1049 | "format": "int64", 1050 | "example": 10 1051 | }, 1052 | "username": { 1053 | "type": "string", 1054 | "example": "theUser" 1055 | }, 1056 | "firstName": { 1057 | "type": "string", 1058 | "example": "John" 1059 | }, 1060 | "lastName": { 1061 | "type": "string", 1062 | "example": "James" 1063 | }, 1064 | "email": { 1065 | "type": "string", 1066 | "example": "john@email.com" 1067 | }, 1068 | "password": { 1069 | "type": "string", 1070 | "example": "12345" 1071 | }, 1072 | "phone": { 1073 | "type": "string", 1074 | "example": "12345" 1075 | }, 1076 | "userStatus": { 1077 | "type": "integer", 1078 | "description": "User Status", 1079 | "format": "int32", 1080 | "example": 1 1081 | } 1082 | }, 1083 | "xml": { 1084 | "name": "user" 1085 | } 1086 | }, 1087 | "Tag": { 1088 | "type": "object", 1089 | "properties": { 1090 | "id": { 1091 | "type": "integer", 1092 | "format": "int64" 1093 | }, 1094 | "name": { 1095 | "type": "string" 1096 | } 1097 | }, 1098 | "xml": { 1099 | "name": "tag" 1100 | } 1101 | }, 1102 | "Pet": { 1103 | "required": [ 1104 | "name", 1105 | "photoUrls" 1106 | ], 1107 | "type": "object", 1108 | "properties": { 1109 | "id": { 1110 | "type": "integer", 1111 | "format": "int64", 1112 | "example": 10 1113 | }, 1114 | "name": { 1115 | "type": "string", 1116 | "example": "doggie" 1117 | }, 1118 | "category": { 1119 | "$ref": "#/components/schemas/Category" 1120 | }, 1121 | "photoUrls": { 1122 | "type": "array", 1123 | "xml": { 1124 | "wrapped": true 1125 | }, 1126 | "items": { 1127 | "type": "string", 1128 | "xml": { 1129 | "name": "photoUrl" 1130 | } 1131 | } 1132 | }, 1133 | "tags": { 1134 | "type": "array", 1135 | "xml": { 1136 | "wrapped": true 1137 | }, 1138 | "items": { 1139 | "$ref": "#/components/schemas/Tag" 1140 | } 1141 | }, 1142 | "status": { 1143 | "type": "string", 1144 | "description": "pet status in the store", 1145 | "enum": [ 1146 | "available", 1147 | "pending", 1148 | "sold" 1149 | ] 1150 | } 1151 | }, 1152 | "xml": { 1153 | "name": "pet" 1154 | } 1155 | }, 1156 | "ApiResponse": { 1157 | "type": "object", 1158 | "properties": { 1159 | "code": { 1160 | "type": "integer", 1161 | "format": "int32" 1162 | }, 1163 | "type": { 1164 | "type": "string" 1165 | }, 1166 | "message": { 1167 | "type": "string" 1168 | } 1169 | }, 1170 | "xml": { 1171 | "name": "##default" 1172 | } 1173 | } 1174 | }, 1175 | "requestBodies": { 1176 | "Pet": { 1177 | "description": "Pet object that needs to be added to the store", 1178 | "content": { 1179 | "application/json": { 1180 | "schema": { 1181 | "$ref": "#/components/schemas/Pet" 1182 | } 1183 | }, 1184 | "application/xml": { 1185 | "schema": { 1186 | "$ref": "#/components/schemas/Pet" 1187 | } 1188 | } 1189 | } 1190 | }, 1191 | "UserArray": { 1192 | "description": "List of user object", 1193 | "content": { 1194 | "application/json": { 1195 | "schema": { 1196 | "type": "array", 1197 | "items": { 1198 | "$ref": "#/components/schemas/User" 1199 | } 1200 | } 1201 | } 1202 | } 1203 | } 1204 | }, 1205 | "securitySchemes": { 1206 | "api_key": { 1207 | "type": "apiKey", 1208 | "name": "api_key", 1209 | "in": "header" 1210 | } 1211 | } 1212 | } 1213 | } -------------------------------------------------------------------------------- /tests/files/petstore_openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.2 2 | servers: 3 | - url: /v3 4 | info: 5 | description: |- 6 | This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about 7 | Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! 8 | You can now help us improve the API whether it's by making changes to the definition itself or to the code. 9 | That way, with time, we can improve the API in general, and expose some of the new features in OAS3. 10 | Some useful links: 11 | - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) 12 | - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) 13 | version: 1.0.6 14 | title: Swagger Petstore - OpenAPI 3.0 15 | termsOfService: 'http://swagger.io/terms/' 16 | contact: 17 | email: apiteam@swagger.io 18 | license: 19 | name: Apache 2.0 20 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 21 | tags: 22 | - name: pet 23 | description: Everything about your Pets 24 | externalDocs: 25 | description: Find out more 26 | url: 'http://swagger.io' 27 | - name: store 28 | description: Operations about user 29 | - name: user 30 | description: Access to Petstore orders 31 | externalDocs: 32 | description: Find out more about our store 33 | url: 'http://swagger.io' 34 | paths: 35 | /pet: 36 | post: 37 | tags: 38 | - pet 39 | summary: Add a new pet to the store 40 | description: Add a new pet to the store 41 | operationId: addPet 42 | responses: 43 | '200': 44 | description: Successful operation 45 | content: 46 | application/xml: 47 | schema: 48 | $ref: '#/components/schemas/Pet' 49 | application/json: 50 | schema: 51 | $ref: '#/components/schemas/Pet' 52 | '405': 53 | description: Invalid input 54 | security: 55 | - petstore_auth: 56 | - 'write:pets' 57 | - 'read:pets' 58 | requestBody: 59 | description: Create a new pet in the store 60 | required: true 61 | content: 62 | application/json: 63 | schema: 64 | $ref: '#/components/schemas/Pet' 65 | application/xml: 66 | schema: 67 | $ref: '#/components/schemas/Pet' 68 | application/x-www-form-urlencoded: 69 | schema: 70 | $ref: '#/components/schemas/Pet' 71 | put: 72 | tags: 73 | - pet 74 | summary: Update an existing pet 75 | description: Update an existing pet by Id 76 | operationId: updatePet 77 | responses: 78 | '200': 79 | description: Successful operation 80 | content: 81 | application/xml: 82 | schema: 83 | $ref: '#/components/schemas/Pet' 84 | application/json: 85 | schema: 86 | $ref: '#/components/schemas/Pet' 87 | '400': 88 | description: Invalid ID supplied 89 | '404': 90 | description: Pet not found 91 | '405': 92 | description: Validation exception 93 | security: 94 | - petstore_auth: 95 | - 'write:pets' 96 | - 'read:pets' 97 | requestBody: 98 | description: Update an existent pet in the store 99 | required: true 100 | content: 101 | application/json: 102 | schema: 103 | $ref: '#/components/schemas/Pet' 104 | application/xml: 105 | schema: 106 | $ref: '#/components/schemas/Pet' 107 | application/x-www-form-urlencoded: 108 | schema: 109 | $ref: '#/components/schemas/Pet' 110 | /pet/findByStatus: 111 | get: 112 | tags: 113 | - pet 114 | summary: Finds Pets by status 115 | description: Multiple status values can be provided with comma separated strings 116 | operationId: findPetsByStatus 117 | parameters: 118 | - name: status 119 | in: query 120 | description: Status values that need to be considered for filter 121 | required: false 122 | explode: true 123 | schema: 124 | type: string 125 | enum: 126 | - available 127 | - pending 128 | - sold 129 | default: available 130 | responses: 131 | '200': 132 | description: successful operation 133 | content: 134 | application/xml: 135 | schema: 136 | type: array 137 | items: 138 | $ref: '#/components/schemas/Pet' 139 | application/json: 140 | schema: 141 | type: array 142 | items: 143 | $ref: '#/components/schemas/Pet' 144 | '400': 145 | description: Invalid status value 146 | security: 147 | - petstore_auth: 148 | - 'write:pets' 149 | - 'read:pets' 150 | /pet/findByTags: 151 | get: 152 | tags: 153 | - pet 154 | summary: Finds Pets by tags 155 | description: >- 156 | Multiple tags can be provided with comma separated strings. Use tag1, 157 | tag2, tag3 for testing. 158 | operationId: findPetsByTags 159 | parameters: 160 | - name: tags 161 | in: query 162 | description: Tags to filter by 163 | required: false 164 | explode: true 165 | schema: 166 | type: array 167 | items: 168 | type: string 169 | responses: 170 | '200': 171 | description: successful operation 172 | content: 173 | application/xml: 174 | schema: 175 | type: array 176 | items: 177 | $ref: '#/components/schemas/Pet' 178 | application/json: 179 | schema: 180 | type: array 181 | items: 182 | $ref: '#/components/schemas/Pet' 183 | '400': 184 | description: Invalid tag value 185 | security: 186 | - petstore_auth: 187 | - 'write:pets' 188 | - 'read:pets' 189 | '/pet/{petId}': 190 | get: 191 | tags: 192 | - pet 193 | summary: Find pet by ID 194 | description: Returns a single pet 195 | operationId: getPetById 196 | parameters: 197 | - name: petId 198 | in: path 199 | description: ID of pet to return 200 | required: true 201 | schema: 202 | type: integer 203 | format: int64 204 | responses: 205 | '200': 206 | description: successful operation 207 | content: 208 | application/xml: 209 | schema: 210 | $ref: '#/components/schemas/Pet' 211 | application/json: 212 | schema: 213 | $ref: '#/components/schemas/Pet' 214 | '400': 215 | description: Invalid ID supplied 216 | '404': 217 | description: Pet not found 218 | security: 219 | - api_key: [] 220 | - petstore_auth: 221 | - 'write:pets' 222 | - 'read:pets' 223 | post: 224 | tags: 225 | - pet 226 | summary: Updates a pet in the store with form data 227 | description: '' 228 | operationId: updatePetWithForm 229 | parameters: 230 | - name: petId 231 | in: path 232 | description: ID of pet that needs to be updated 233 | required: true 234 | schema: 235 | type: integer 236 | format: int64 237 | - name: name 238 | in: query 239 | description: Name of pet that needs to be updated 240 | schema: 241 | type: string 242 | - name: status 243 | in: query 244 | description: Status of pet that needs to be updated 245 | schema: 246 | type: string 247 | responses: 248 | '405': 249 | description: Invalid input 250 | security: 251 | - petstore_auth: 252 | - 'write:pets' 253 | - 'read:pets' 254 | delete: 255 | tags: 256 | - pet 257 | summary: Deletes a pet 258 | description: '' 259 | operationId: deletePet 260 | parameters: 261 | - name: api_key 262 | in: header 263 | description: '' 264 | required: false 265 | schema: 266 | type: string 267 | - name: petId 268 | in: path 269 | description: Pet id to delete 270 | required: true 271 | schema: 272 | type: integer 273 | format: int64 274 | responses: 275 | '400': 276 | description: Invalid pet value 277 | security: 278 | - petstore_auth: 279 | - 'write:pets' 280 | - 'read:pets' 281 | '/pet/{petId}/uploadImage': 282 | post: 283 | tags: 284 | - pet 285 | summary: uploads an image 286 | description: '' 287 | operationId: uploadFile 288 | parameters: 289 | - name: petId 290 | in: path 291 | description: ID of pet to update 292 | required: true 293 | schema: 294 | type: integer 295 | format: int64 296 | - name: additionalMetadata 297 | in: query 298 | description: Additional Metadata 299 | required: false 300 | schema: 301 | type: string 302 | responses: 303 | '200': 304 | description: successful operation 305 | content: 306 | application/json: 307 | schema: 308 | $ref: '#/components/schemas/ApiResponse' 309 | security: 310 | - petstore_auth: 311 | - 'write:pets' 312 | - 'read:pets' 313 | requestBody: 314 | content: 315 | application/octet-stream: 316 | schema: 317 | type: string 318 | format: binary 319 | /store/inventory: 320 | get: 321 | tags: 322 | - store 323 | summary: Returns pet inventories by status 324 | description: Returns a map of status codes to quantities 325 | operationId: getInventory 326 | x-swagger-router-controller: OrderController 327 | responses: 328 | '200': 329 | description: successful operation 330 | content: 331 | application/json: 332 | schema: 333 | type: object 334 | additionalProperties: 335 | type: integer 336 | format: int32 337 | security: 338 | - api_key: [] 339 | /store/order: 340 | post: 341 | tags: 342 | - store 343 | summary: Place an order for a pet 344 | description: Place a new order in the store 345 | operationId: placeOrder 346 | x-swagger-router-controller: OrderController 347 | responses: 348 | '200': 349 | description: successful operation 350 | content: 351 | application/json: 352 | schema: 353 | $ref: '#/components/schemas/Order' 354 | '405': 355 | description: Invalid input 356 | requestBody: 357 | content: 358 | application/json: 359 | schema: 360 | $ref: '#/components/schemas/Order' 361 | application/xml: 362 | schema: 363 | $ref: '#/components/schemas/Order' 364 | application/x-www-form-urlencoded: 365 | schema: 366 | $ref: '#/components/schemas/Order' 367 | '/store/order/{orderId}': 368 | get: 369 | tags: 370 | - store 371 | summary: Find purchase order by ID 372 | x-swagger-router-controller: OrderController 373 | description: >- 374 | For valid response try integer IDs with value <= 5 or > 10. Other values 375 | will generated exceptions 376 | operationId: getOrderById 377 | parameters: 378 | - name: orderId 379 | in: path 380 | description: ID of order that needs to be fetched 381 | required: true 382 | schema: 383 | type: integer 384 | format: int64 385 | responses: 386 | '200': 387 | description: successful operation 388 | content: 389 | application/xml: 390 | schema: 391 | $ref: '#/components/schemas/Order' 392 | application/json: 393 | schema: 394 | $ref: '#/components/schemas/Order' 395 | '400': 396 | description: Invalid ID supplied 397 | '404': 398 | description: Order not found 399 | delete: 400 | tags: 401 | - store 402 | summary: Delete purchase order by ID 403 | x-swagger-router-controller: OrderController 404 | description: >- 405 | For valid response try integer IDs with value < 1000. Anything above 406 | 1000 or nonintegers will generate API errors 407 | operationId: deleteOrder 408 | parameters: 409 | - name: orderId 410 | in: path 411 | description: ID of the order that needs to be deleted 412 | required: true 413 | schema: 414 | type: integer 415 | format: int64 416 | responses: 417 | '400': 418 | description: Invalid ID supplied 419 | '404': 420 | description: Order not found 421 | /user: 422 | post: 423 | tags: 424 | - user 425 | summary: Create user 426 | description: This can only be done by the logged in user. 427 | operationId: createUser 428 | responses: 429 | default: 430 | description: successful operation 431 | content: 432 | application/json: 433 | schema: 434 | $ref: '#/components/schemas/User' 435 | application/xml: 436 | schema: 437 | $ref: '#/components/schemas/User' 438 | requestBody: 439 | content: 440 | application/json: 441 | schema: 442 | $ref: '#/components/schemas/User' 443 | application/xml: 444 | schema: 445 | $ref: '#/components/schemas/User' 446 | application/x-www-form-urlencoded: 447 | schema: 448 | $ref: '#/components/schemas/User' 449 | description: Created user object 450 | /user/createWithList: 451 | post: 452 | tags: 453 | - user 454 | summary: Creates list of users with given input array 455 | description: 'Creates list of users with given input array' 456 | x-swagger-router-controller: UserController 457 | operationId: createUsersWithListInput 458 | responses: 459 | '200': 460 | description: Successful operation 461 | content: 462 | application/xml: 463 | schema: 464 | $ref: '#/components/schemas/User' 465 | application/json: 466 | schema: 467 | $ref: '#/components/schemas/User' 468 | default: 469 | description: successful operation 470 | requestBody: 471 | content: 472 | application/json: 473 | schema: 474 | type: array 475 | items: 476 | $ref: '#/components/schemas/User' 477 | /user/login: 478 | get: 479 | tags: 480 | - user 481 | summary: Logs user into the system 482 | description: '' 483 | operationId: loginUser 484 | parameters: 485 | - name: username 486 | in: query 487 | description: The user name for login 488 | required: false 489 | schema: 490 | type: string 491 | - name: password 492 | in: query 493 | description: The password for login in clear text 494 | required: false 495 | schema: 496 | type: string 497 | responses: 498 | '200': 499 | description: successful operation 500 | headers: 501 | X-Rate-Limit: 502 | description: calls per hour allowed by the user 503 | schema: 504 | type: integer 505 | format: int32 506 | X-Expires-After: 507 | description: date in UTC when token expires 508 | schema: 509 | type: string 510 | format: date-time 511 | content: 512 | application/xml: 513 | schema: 514 | type: string 515 | application/json: 516 | schema: 517 | type: string 518 | '400': 519 | description: Invalid username/password supplied 520 | /user/logout: 521 | get: 522 | tags: 523 | - user 524 | summary: Logs out current logged in user session 525 | description: '' 526 | operationId: logoutUser 527 | parameters: [] 528 | responses: 529 | default: 530 | description: successful operation 531 | '/user/{username}': 532 | get: 533 | tags: 534 | - user 535 | summary: Get user by user name 536 | description: '' 537 | operationId: getUserByName 538 | parameters: 539 | - name: username 540 | in: path 541 | description: 'The name that needs to be fetched. Use user1 for testing. ' 542 | required: true 543 | schema: 544 | type: string 545 | responses: 546 | '200': 547 | description: successful operation 548 | content: 549 | application/xml: 550 | schema: 551 | $ref: '#/components/schemas/User' 552 | application/json: 553 | schema: 554 | $ref: '#/components/schemas/User' 555 | '400': 556 | description: Invalid username supplied 557 | '404': 558 | description: User not found 559 | put: 560 | tags: 561 | - user 562 | summary: Update user 563 | x-swagger-router-controller: UserController 564 | description: This can only be done by the logged in user. 565 | operationId: updateUser 566 | parameters: 567 | - name: username 568 | in: path 569 | description: name that need to be deleted 570 | required: true 571 | schema: 572 | type: string 573 | responses: 574 | default: 575 | description: successful operation 576 | requestBody: 577 | description: Update an existent user in the store 578 | content: 579 | application/json: 580 | schema: 581 | $ref: '#/components/schemas/User' 582 | application/xml: 583 | schema: 584 | $ref: '#/components/schemas/User' 585 | application/x-www-form-urlencoded: 586 | schema: 587 | $ref: '#/components/schemas/User' 588 | delete: 589 | tags: 590 | - user 591 | summary: Delete user 592 | description: This can only be done by the logged in user. 593 | operationId: deleteUser 594 | parameters: 595 | - name: username 596 | in: path 597 | description: The name that needs to be deleted 598 | required: true 599 | schema: 600 | type: string 601 | responses: 602 | '400': 603 | description: Invalid username supplied 604 | '404': 605 | description: User not found 606 | externalDocs: 607 | description: Find out more about Swagger 608 | url: 'http://swagger.io' 609 | components: 610 | schemas: 611 | Order: 612 | x-swagger-router-model: io.swagger.petstore.model.Order 613 | properties: 614 | id: 615 | type: integer 616 | format: int64 617 | example: 10 618 | petId: 619 | type: integer 620 | format: int64 621 | example: 198772 622 | quantity: 623 | type: integer 624 | format: int32 625 | example: 7 626 | shipDate: 627 | type: string 628 | format: date-time 629 | status: 630 | type: string 631 | description: Order Status 632 | enum: 633 | - placed 634 | - approved 635 | - delivered 636 | example: approved 637 | complete: 638 | type: boolean 639 | xml: 640 | name: order 641 | type: object 642 | Customer: 643 | properties: 644 | id: 645 | type: integer 646 | format: int64 647 | example: 100000 648 | username: 649 | type: string 650 | example: fehguy 651 | address: 652 | type: array 653 | items: 654 | $ref: '#/components/schemas/Address' 655 | xml: 656 | wrapped: true 657 | name: addresses 658 | xml: 659 | name: customer 660 | type: object 661 | Address: 662 | properties: 663 | street: 664 | type: string 665 | example: 437 Lytton 666 | city: 667 | type: string 668 | example: Palo Alto 669 | state: 670 | type: string 671 | example: CA 672 | zip: 673 | type: string 674 | example: 94301 675 | xml: 676 | name: address 677 | type: object 678 | Category: 679 | x-swagger-router-model: io.swagger.petstore.model.Category 680 | properties: 681 | id: 682 | type: integer 683 | format: int64 684 | example: 1 685 | name: 686 | type: string 687 | example: Dogs 688 | xml: 689 | name: category 690 | type: object 691 | User: 692 | x-swagger-router-model: io.swagger.petstore.model.User 693 | properties: 694 | id: 695 | type: integer 696 | format: int64 697 | example: 10 698 | username: 699 | type: string 700 | example: theUser 701 | firstName: 702 | type: string 703 | example: John 704 | lastName: 705 | type: string 706 | example: James 707 | email: 708 | type: string 709 | example: john@email.com 710 | password: 711 | type: string 712 | example: 12345 713 | phone: 714 | type: string 715 | example: 12345 716 | userStatus: 717 | type: integer 718 | format: int32 719 | example: 1 720 | description: User Status 721 | xml: 722 | name: user 723 | type: object 724 | Tag: 725 | x-swagger-router-model: io.swagger.petstore.model.Tag 726 | properties: 727 | id: 728 | type: integer 729 | format: int64 730 | name: 731 | type: string 732 | xml: 733 | name: tag 734 | type: object 735 | Pet: 736 | x-swagger-router-model: io.swagger.petstore.model.Pet 737 | required: 738 | - name 739 | - photoUrls 740 | properties: 741 | id: 742 | type: integer 743 | format: int64 744 | example: 10 745 | name: 746 | type: string 747 | example: doggie 748 | category: 749 | $ref: '#/components/schemas/Category' 750 | photoUrls: 751 | type: array 752 | xml: 753 | wrapped: true 754 | items: 755 | type: string 756 | xml: 757 | name: photoUrl 758 | tags: 759 | type: array 760 | xml: 761 | wrapped: true 762 | items: 763 | $ref: '#/components/schemas/Tag' 764 | xml: 765 | name: tag 766 | status: 767 | type: string 768 | description: pet status in the store 769 | enum: 770 | - available 771 | - pending 772 | - sold 773 | xml: 774 | name: pet 775 | type: object 776 | ApiResponse: 777 | properties: 778 | code: 779 | type: integer 780 | format: int32 781 | type: 782 | type: string 783 | message: 784 | type: string 785 | xml: 786 | name: '##default' 787 | type: object 788 | requestBodies: 789 | Pet: 790 | content: 791 | application/json: 792 | schema: 793 | $ref: '#/components/schemas/Pet' 794 | application/xml: 795 | schema: 796 | $ref: '#/components/schemas/Pet' 797 | description: Pet object that needs to be added to the store 798 | UserArray: 799 | content: 800 | application/json: 801 | schema: 802 | type: array 803 | items: 804 | $ref: '#/components/schemas/User' 805 | description: List of user object 806 | securitySchemes: 807 | api_key: 808 | type: apiKey 809 | name: api_key 810 | in: header -------------------------------------------------------------------------------- /tests/rf_cli.args: -------------------------------------------------------------------------------- 1 | 2 | --loglevel TRACE:DEBUG 3 | --listener RobotStackTracer 4 | -------------------------------------------------------------------------------- /tests/server/testserver.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import datetime 3 | from enum import Enum 4 | from sys import float_info 5 | from typing import Dict, List, Optional, Union 6 | from uuid import uuid4 7 | 8 | from fastapi import FastAPI, Header, HTTPException, Path, Query, Response 9 | from pydantic import BaseModel, Field 10 | 11 | API_KEY = "OpenApiLibCore" 12 | API_KEY_NAME = "api_key" 13 | 14 | 15 | app = FastAPI() 16 | 17 | 18 | REMOVE_ME: str = uuid4().hex 19 | DEPRECATED: int = uuid4().int 20 | DELTA = 1000 * float_info.epsilon 21 | 22 | 23 | class EnergyLabel(str, Enum): 24 | A = "A" 25 | B = "B" 26 | C = "C" 27 | D = "D" 28 | E = "E" 29 | F = "F" 30 | G = "G" 31 | X = "No registered label" 32 | 33 | 34 | class WeekDay(str, Enum): 35 | Monday = "Monday" 36 | Tuesday = "Tuesday" 37 | Wednesday = "Wednesday" 38 | Thursday = "Thursday" 39 | Friday = "Friday" 40 | 41 | 42 | class Wing(str, Enum): 43 | N = "North" 44 | E = "East" 45 | S = "South" 46 | W = "West" 47 | 48 | 49 | class Message(BaseModel): 50 | message: str 51 | 52 | 53 | class Detail(BaseModel): 54 | detail: str 55 | 56 | 57 | class Event(BaseModel, extra="forbid"): 58 | message: Message 59 | details: List[Detail] 60 | 61 | 62 | class WageGroup(BaseModel): 63 | wagegroup_id: str 64 | hourly_rate: Union[float, int] = Field(alias="hourly-rate") 65 | overtime_percentage: Optional[int] = DEPRECATED 66 | 67 | 68 | class EmployeeDetails(BaseModel): 69 | identification: str 70 | name: str 71 | employee_number: int 72 | wagegroup_id: str 73 | date_of_birth: datetime.date 74 | parttime_day: Optional[WeekDay] = None 75 | 76 | 77 | class Employee(BaseModel): 78 | name: str 79 | wagegroup_id: str 80 | date_of_birth: datetime.date 81 | parttime_day: Optional[WeekDay] = None 82 | 83 | 84 | class EmployeeUpdate(BaseModel): 85 | name: Optional[str] = None 86 | employee_number: Optional[int] = None 87 | wagegroup_id: Optional[str] = None 88 | date_of_birth: Optional[datetime.date] = None 89 | parttime_day: Optional[WeekDay] = None 90 | 91 | 92 | WAGE_GROUPS: Dict[str, WageGroup] = {} 93 | EMPLOYEES: Dict[str, EmployeeDetails] = {} 94 | EMPLOYEE_NUMBERS = iter(range(1, 1000)) 95 | ENERGY_LABELS: Dict[str, Dict[int, Dict[str, EnergyLabel]]] = { 96 | "1111AA": { 97 | 10: { 98 | "": EnergyLabel.A, 99 | "C": EnergyLabel.C, 100 | }, 101 | } 102 | } 103 | EVENTS: List[Event] = [ 104 | Event(message=Message(message="Hello?"), details=[Detail(detail="First post")]), 105 | Event(message=Message(message="First!"), details=[Detail(detail="Second post")]), 106 | ] 107 | 108 | 109 | @app.get("/", status_code=200, response_model=Message) 110 | def get_root(*, name_from_header: str = Header(""), title: str = Header("")) -> Message: 111 | name = name_from_header if name_from_header else "stranger" 112 | return Message(message=f"Welcome {title}{name}!") 113 | 114 | 115 | @app.get( 116 | "/secret_message", 117 | status_code=200, 118 | response_model=Message, 119 | responses={401: {"model": Detail}, 403: {"model": Detail}}, 120 | ) 121 | def get_message( 122 | *, secret_code: int = Header(...), seal: str = Header(REMOVE_ME) 123 | ) -> Message: 124 | if secret_code != 42: 125 | raise HTTPException( 126 | status_code=401, detail=f"Provided code {secret_code} is incorrect!" 127 | ) 128 | if seal is not REMOVE_ME: 129 | raise HTTPException(status_code=403, detail="Seal was not removed!") 130 | return Message(message="Welcome, agent HAL") 131 | 132 | 133 | # deliberate trailing / 134 | @app.get("/events/", status_code=200, response_model=List[Event]) 135 | def get_events( 136 | search_strings: Optional[List[str]] = Query(default=[]), 137 | ) -> List[Event]: 138 | if search_strings: 139 | result: List[Event] = [] 140 | for search_string in search_strings: 141 | result.extend([e for e in EVENTS if search_string in e.message.message]) 142 | return result 143 | return EVENTS 144 | 145 | 146 | # deliberate trailing / 147 | @app.post("/events/", status_code=201, response_model=Event) 148 | def post_event(event: Event) -> Event: 149 | event.details.append(Detail(detail=str(datetime.datetime.now()))) 150 | EVENTS.append(event) 151 | return event 152 | 153 | 154 | @app.get( 155 | "/energy_label/{zipcode}/{home_number}", 156 | status_code=200, 157 | response_model=Message, 158 | ) 159 | def get_energy_label( 160 | zipcode: str = Path(..., min_length=6, max_length=6), 161 | home_number: int = Path(..., ge=1), 162 | extension: Optional[str] = Query(" ", min_length=1, max_length=9), 163 | ) -> Message: 164 | if not (labels_for_zipcode := ENERGY_LABELS.get(zipcode)): 165 | return Message(message=EnergyLabel.X) 166 | if not (labels_for_home_number := labels_for_zipcode.get(home_number)): 167 | return Message(message=EnergyLabel.X) 168 | extension = "" if extension is None else extension.strip() 169 | return Message(message=labels_for_home_number.get(extension, EnergyLabel.X)) 170 | 171 | 172 | @app.post( 173 | "/wagegroups", 174 | status_code=201, 175 | response_model=WageGroup, 176 | responses={418: {"model": Detail}, 422: {"model": Detail}}, 177 | ) 178 | def post_wagegroup(wagegroup: WageGroup) -> WageGroup: 179 | if wagegroup.wagegroup_id in WAGE_GROUPS: 180 | raise HTTPException(status_code=418, detail="Wage group already exists.") 181 | if not (0.99 - DELTA) < (wagegroup.hourly_rate % 1) < (0.99 + DELTA): 182 | raise HTTPException( 183 | status_code=422, 184 | detail="Hourly rates must end with .99 for psychological reasons.", 185 | ) 186 | if wagegroup.overtime_percentage != DEPRECATED: 187 | raise HTTPException( 188 | status_code=422, detail="Overtime percentage is deprecated." 189 | ) 190 | wagegroup.overtime_percentage = None 191 | WAGE_GROUPS[wagegroup.wagegroup_id] = wagegroup 192 | return wagegroup 193 | 194 | 195 | @app.get( 196 | "/wagegroups/{wagegroup_id}", 197 | status_code=200, 198 | response_model=WageGroup, 199 | responses={404: {"model": Detail}}, 200 | ) 201 | def get_wagegroup(wagegroup_id: str) -> WageGroup: 202 | if wagegroup_id not in WAGE_GROUPS: 203 | raise HTTPException(status_code=404, detail="Wage group not found") 204 | return WAGE_GROUPS[wagegroup_id] 205 | 206 | 207 | @app.put( 208 | "/wagegroups/{wagegroup_id}", 209 | status_code=200, 210 | response_model=WageGroup, 211 | responses={404: {"model": Detail}, 418: {"model": Detail}, 422: {"model": Detail}}, 212 | ) 213 | def put_wagegroup(wagegroup_id: str, wagegroup: WageGroup) -> WageGroup: 214 | if wagegroup_id not in WAGE_GROUPS: 215 | raise HTTPException(status_code=404, detail="Wage group not found.") 216 | if wagegroup.wagegroup_id in WAGE_GROUPS: 217 | raise HTTPException(status_code=418, detail="Wage group already exists.") 218 | if not (0.99 - DELTA) < (wagegroup.hourly_rate % 1) < (0.99 + DELTA): 219 | raise HTTPException( 220 | status_code=422, 221 | detail="Hourly rates must end with .99 for psychological reasons.", 222 | ) 223 | if wagegroup.overtime_percentage != DEPRECATED: 224 | raise HTTPException( 225 | status_code=422, detail="Overtime percentage is deprecated." 226 | ) 227 | wagegroup.overtime_percentage = None 228 | WAGE_GROUPS[wagegroup.wagegroup_id] = wagegroup 229 | return wagegroup 230 | 231 | 232 | @app.delete( 233 | "/wagegroups/{wagegroup_id}", 234 | status_code=204, 235 | response_class=Response, 236 | responses={404: {"model": Detail}, 406: {"model": Detail}}, 237 | ) 238 | def delete_wagegroup(wagegroup_id: str) -> None: 239 | if wagegroup_id not in WAGE_GROUPS: 240 | raise HTTPException(status_code=404, detail="Wage group not found.") 241 | used_by = [e for e in EMPLOYEES.values() if e.wagegroup_id == wagegroup_id] 242 | if used_by: 243 | raise HTTPException( 244 | status_code=406, 245 | detail=f"Wage group still in use by {len(used_by)} employees.", 246 | ) 247 | WAGE_GROUPS.pop(wagegroup_id) 248 | 249 | 250 | @app.get( 251 | "/wagegroups/{wagegroup_id}/employees", 252 | status_code=200, 253 | response_model=List[EmployeeDetails], 254 | responses={404: {"model": Detail}}, 255 | ) 256 | def get_employees_in_wagegroup(wagegroup_id: str) -> List[EmployeeDetails]: 257 | if wagegroup_id not in WAGE_GROUPS: 258 | raise HTTPException(status_code=404, detail="Wage group not found.") 259 | return [e for e in EMPLOYEES.values() if e.wagegroup_id == wagegroup_id] 260 | 261 | 262 | @app.post( 263 | "/employees", 264 | status_code=201, 265 | response_model=EmployeeDetails, 266 | responses={403: {"model": Detail}, 451: {"model": Detail}}, 267 | ) 268 | def post_employee(employee: Employee) -> EmployeeDetails: 269 | wagegroup_id = employee.wagegroup_id 270 | if wagegroup_id not in WAGE_GROUPS: 271 | raise HTTPException( 272 | status_code=451, detail=f"Wage group with id {wagegroup_id} does not exist." 273 | ) 274 | today = datetime.date.today() 275 | employee_age = today - employee.date_of_birth 276 | if employee_age.days < 18 * 365: 277 | raise HTTPException( 278 | status_code=403, detail="An employee must be at least 18 years old." 279 | ) 280 | new_employee = EmployeeDetails( 281 | identification=uuid4().hex, 282 | employee_number=next(EMPLOYEE_NUMBERS), 283 | **employee.dict(), 284 | ) 285 | EMPLOYEES[new_employee.identification] = new_employee 286 | return new_employee 287 | 288 | 289 | @app.get( 290 | "/employees", 291 | status_code=200, 292 | response_model=List[EmployeeDetails], 293 | ) 294 | def get_employees() -> List[EmployeeDetails]: 295 | return list(EMPLOYEES.values()) 296 | 297 | 298 | @app.get( 299 | "/employees/{employee_id}", 300 | status_code=200, 301 | response_model=EmployeeDetails, 302 | responses={404: {"model": Detail}}, 303 | ) 304 | def get_employee(employee_id: str) -> EmployeeDetails: 305 | if employee_id not in EMPLOYEES: 306 | raise HTTPException(status_code=404, detail="Employee not found") 307 | return EMPLOYEES[employee_id] 308 | 309 | 310 | @app.patch( 311 | "/employees/{employee_id}", 312 | status_code=200, 313 | response_model=EmployeeDetails, 314 | responses={404: {"model": Detail}}, 315 | ) 316 | def patch_employee(employee_id: str, employee: EmployeeUpdate) -> EmployeeDetails: 317 | if employee_id not in EMPLOYEES.keys(): 318 | raise HTTPException(status_code=404, detail="Employee not found") 319 | stored_employee_data = EMPLOYEES[employee_id] 320 | employee_update_data = employee.model_dump( 321 | exclude_defaults=True, exclude_unset=True 322 | ) 323 | 324 | wagegroup_id = employee_update_data.get("wagegroup_id", None) 325 | if wagegroup_id and wagegroup_id not in WAGE_GROUPS: 326 | raise HTTPException( 327 | status_code=451, detail=f"Wage group with id {wagegroup_id} does not exist." 328 | ) 329 | 330 | today = datetime.date.today() 331 | if date_of_birth := employee_update_data.get("date_of_birth", None): 332 | employee_age = today - date_of_birth 333 | if employee_age.days < 18 * 365: 334 | raise HTTPException( 335 | status_code=403, detail="An employee must be at least 18 years old." 336 | ) 337 | 338 | updated_employee = stored_employee_data.model_copy(update=employee_update_data) 339 | EMPLOYEES[employee_id] = updated_employee 340 | return updated_employee 341 | 342 | 343 | @app.get("/available_employees", status_code=200, response_model=List[EmployeeDetails]) 344 | def get_available_employees(weekday: WeekDay = Query(...)) -> List[EmployeeDetails]: 345 | return [ 346 | e for e in EMPLOYEES.values() if getattr(e, "parttime_day", None) != weekday 347 | ] 348 | -------------------------------------------------------------------------------- /tests/suites/load_from_url.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Variables ${ROOT}/tests/variables.py 3 | Library OpenApiDriver 4 | ... source=http://localhost:8000/openapi.json 5 | ... origin=http://localhost:8000 6 | ... base_path=${EMPTY} 7 | ... mappings_path=${ROOT}/tests/user_implemented/custom_user_mappings.py 8 | ... response_validation=STRICT 9 | ... require_body_for_invalid_url=${TRUE} 10 | ... extra_headers=${API_KEY} 11 | ... faker_locale=nl_NL 12 | ... default_id_property_name=identification 13 | 14 | Test Template Validate Test Endpoint Keyword 15 | 16 | 17 | *** Test Cases *** 18 | Test Endpoint for ${method} on ${path} where ${status_code} is expected 19 | 20 | 21 | *** Keywords *** 22 | Validate Test Endpoint Keyword 23 | [Arguments] ${path} ${method} ${status_code} 24 | IF ${status_code} == 404 25 | Test Invalid Url path=${path} method=${method} 26 | ELSE 27 | Test Endpoint 28 | ... path=${path} method=${method} status_code=${status_code} 29 | END 30 | -------------------------------------------------------------------------------- /tests/suites/load_json.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiDriver 3 | ... source=${ROOT}/tests/files/petstore_openapi.json 4 | ... ignored_responses=${IGNORED_RESPONSES} 5 | ... ignored_testcases=${IGNORED_TESTS} 6 | 7 | Test Template Do Nothing 8 | 9 | 10 | *** Variables *** 11 | @{IGNORED_RESPONSES}= 200 404 400 12 | @{IGNORE_POST_PET}= /pet POST 405 13 | @{IGNORE_POST_PET_ID}= /pet/{petId} post 405 14 | @{IGNORE_POST_ORDER}= /store/order post 405 15 | @{IGNORED_TESTS}= ${IGNORE_POST_PET} ${IGNORE_POST_PET_ID} ${IGNORE_POST_ORDER} 16 | 17 | 18 | *** Test Cases *** 19 | OpenApiJson test for ${method} on ${path} where ${status_code} is expected 20 | 21 | 22 | *** Keywords *** 23 | Do Nothing 24 | [Arguments] ${path} ${method} ${status_code} 25 | No Operation 26 | -------------------------------------------------------------------------------- /tests/suites/load_yaml.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiDriver 3 | ... source=${ROOT}/tests/files/petstore_openapi.yaml 4 | ... included_paths=${INCLUDED_PATHS} 5 | ... ignored_paths=${IGNORED_PATHS} 6 | 7 | Test Template Do Nothing 8 | 9 | 10 | *** Variables *** 11 | @{INCLUDED_PATHS}= 12 | ... /pet/{petId}/uploadImage 13 | ... /user* 14 | @{IGNORED_PATHS}= 15 | ... /user/createWithList /user/l* 16 | 17 | 18 | *** Test Cases *** 19 | OpenApiYaml test for ${method} on ${path} where ${status_code} is expected 20 | 21 | 22 | *** Keywords *** 23 | Do Nothing 24 | [Arguments] ${path} ${method} ${status_code} 25 | No Operation 26 | -------------------------------------------------------------------------------- /tests/suites/test_mismatching_schemas.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Variables ${ROOT}/tests/variables.py 3 | Library OpenApiDriver 4 | ... source=${ROOT}/tests/files/mismatched_openapi.json 5 | ... origin=http://localhost:8000 6 | ... base_path=${EMPTY} 7 | ... mappings_path=${ROOT}/tests/user_implemented/custom_user_mappings.py 8 | ... response_validation=INFO 9 | ... require_body_for_invalid_url=${TRUE} 10 | ... extra_headers=${API_KEY} 11 | ... faker_locale=nl_NL 12 | ... default_id_property_name=identification 13 | 14 | Test Template Validate Test Endpoint Keyword 15 | 16 | 17 | *** Variables *** 18 | @{EXPECTED_FAILURES} 19 | ... GET /reactions/ 200 20 | ... POST /events/ 201 21 | ... POST /employees 201 22 | ... GET /employees 200 23 | ... PATCH /employees/{employee_id} 200 24 | ... GET /employees/{employee_id} 200 25 | ... GET /available_employees 200 26 | &{API_KEY} api_key=Super secret key 27 | 28 | 29 | *** Test Cases *** 30 | Test Endpoint for ${method} on ${path} where ${status_code} is expected 31 | 32 | 33 | *** Keywords *** 34 | Validate Test Endpoint Keyword 35 | [Arguments] ${path} ${method} ${status_code} 36 | IF ${status_code} == 404 37 | Test Invalid Url path=${path} method=${method} 38 | ELSE 39 | ${operation}= Set Variable ${method}${SPACE}${path}${SPACE}${status_code} 40 | IF $operation in $EXPECTED_FAILURES 41 | Run Keyword And Expect Error * Test Endpoint 42 | ... path=${path} method=${method} status_code=${status_code} 43 | ELSE 44 | Test Endpoint 45 | ... path=${path} method=${method} status_code=${status_code} 46 | END 47 | END 48 | -------------------------------------------------------------------------------- /tests/unittests/test_openapi_reader.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from OpenApiDriver.openapi_reader import Test 4 | 5 | 6 | class TestInit(unittest.TestCase): 7 | def test_test_class_not_equal(self) -> None: 8 | test = Test("/", "GET", 200) 9 | self.assertFalse(test == ("/", "GET", 200)) 10 | 11 | 12 | if __name__ == "__main__": 13 | unittest.main() 14 | -------------------------------------------------------------------------------- /tests/unittests/test_openapidriver.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from OpenApiDriver import OpenApiDriver 4 | 5 | 6 | class TestInit(unittest.TestCase): 7 | def test_load_from_invalid_source(self) -> None: 8 | self.assertRaises( 9 | Exception, OpenApiDriver, source="http://localhost:8000/openapi.doc" 10 | ) 11 | 12 | 13 | if __name__ == "__main__": 14 | unittest.main() 15 | -------------------------------------------------------------------------------- /tests/user_implemented/custom_user_mappings.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=invalid-name 2 | from typing import Dict, List, Tuple, Type 3 | 4 | from OpenApiLibCore import ( 5 | IGNORE, 6 | Dto, 7 | IdDependency, 8 | IdReference, 9 | PathPropertiesConstraint, 10 | PropertyValueConstraint, 11 | Relation, 12 | UniquePropertyValueConstraint, 13 | ) 14 | 15 | 16 | class WagegroupDto(Dto): 17 | @staticmethod 18 | def get_relations() -> List[Relation]: 19 | relations: List[Relation] = [ 20 | UniquePropertyValueConstraint( 21 | property_name="id", 22 | value="Teapot", 23 | error_code=418, 24 | ), 25 | IdReference( 26 | property_name="wagegroup_id", 27 | post_path="/employees", 28 | error_code=406, 29 | ), 30 | PropertyValueConstraint( 31 | property_name="overtime_percentage", 32 | values=[IGNORE], 33 | invalid_value=110, 34 | invalid_value_error_code=422, 35 | ), 36 | PropertyValueConstraint( 37 | property_name="hourly-rate", 38 | values=[80.99, 90.99, 99.99], 39 | error_code=400, 40 | ), 41 | ] 42 | return relations 43 | 44 | 45 | class WagegroupDeleteDto(Dto): 46 | @staticmethod 47 | def get_relations() -> List[Relation]: 48 | relations: List[Relation] = [ 49 | UniquePropertyValueConstraint( 50 | property_name="id", 51 | value="Teapot", 52 | error_code=418, 53 | ), 54 | IdReference( 55 | property_name="wagegroup_id", 56 | post_path="/employees", 57 | error_code=406, 58 | ), 59 | ] 60 | return relations 61 | 62 | 63 | class EmployeeDto(Dto): 64 | @staticmethod 65 | def get_relations() -> List[Relation]: 66 | relations: List[Relation] = [ 67 | IdDependency( 68 | property_name="wagegroup_id", 69 | get_path="/wagegroups", 70 | error_code=451, 71 | ), 72 | PropertyValueConstraint( 73 | property_name="date_of_birth", 74 | values=["1970-07-07", "1980-08-08", "1990-09-09"], 75 | invalid_value="2020-02-20", 76 | invalid_value_error_code=403, 77 | error_code=422, 78 | ), 79 | ] 80 | return relations 81 | 82 | 83 | class EnergyLabelDto(Dto): 84 | @staticmethod 85 | def get_relations() -> List[Relation]: 86 | relations: List[Relation] = [ 87 | PathPropertiesConstraint(path="/energy_label/1111AA/10"), 88 | ] 89 | return relations 90 | 91 | 92 | class MessageDto(Dto): 93 | @staticmethod 94 | def get_parameter_relations() -> List[Relation]: 95 | relations: List[Relation] = [ 96 | PropertyValueConstraint( 97 | property_name="secret-code", # note: property name converted by FastAPI 98 | values=[42], 99 | error_code=401, 100 | ), 101 | PropertyValueConstraint( 102 | property_name="seal", 103 | values=[IGNORE], 104 | error_code=403, 105 | ), 106 | ] 107 | return relations 108 | 109 | 110 | DTO_MAPPING: Dict[Tuple[str, str], Type[Dto]] = { 111 | ("/wagegroups", "post"): WagegroupDto, 112 | ("/wagegroups/{wagegroup_id}", "delete"): WagegroupDeleteDto, 113 | ("/wagegroups/{wagegroup_id}", "put"): WagegroupDto, 114 | ("/employees", "post"): EmployeeDto, 115 | ("/employees/{employee_id}", "patch"): EmployeeDto, 116 | ("/energy_label/{zipcode}/{home_number}", "get"): EnergyLabelDto, 117 | ("/secret_message", "get"): MessageDto, 118 | } 119 | 120 | # NOTE: "/available_employees": "identification" is not mapped for testing purposes 121 | ID_MAPPING: Dict[str, str] = { 122 | "/employees": "identification", 123 | "/employees/{employee_id}": "identification", 124 | "/wagegroups": "wagegroup_id", 125 | "/wagegroups/{wagegroup_id}": "wagegroup_id", 126 | "/wagegroups/{wagegroup_id}/employees": "identification", 127 | } 128 | -------------------------------------------------------------------------------- /tests/variables.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | 3 | 4 | def get_variables() -> Dict[str, Any]: 5 | return { 6 | "API_KEY": {"api_key": "Super secret key"}, 7 | } 8 | --------------------------------------------------------------------------------