├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .gitignore ├── .vscode ├── example.launch.json ├── example.settings.json └── extensions.json ├── LICENSE ├── SETUP.md ├── docs ├── README.md ├── _config.yml ├── advanced_use.md └── openapi_libcore.html ├── poetry.lock ├── pyproject.toml ├── src └── OpenApiLibCore │ ├── __init__.py │ ├── dto_base.py │ ├── dto_utils.py │ ├── oas_cache.py │ ├── openapi_libcore.libspec │ ├── openapi_libcore.py │ ├── py.typed │ └── value_utils.py ├── tasks.py └── tests ├── files ├── petstore_openapi.json ├── schema_with_allof.yaml └── schema_with_readOnly.json ├── rf_cli.args ├── server └── testserver.py ├── suites ├── test_authorized_request.robot ├── test_default_id_property_name.robot ├── test_ensure_in_use.robot ├── test_extra_headers.robot ├── test_faker_locale.robot ├── test_get_ids_for_endpoint.robot ├── test_get_invalid_json_data.robot ├── test_get_invalidated_parameters.robot ├── test_get_invalidated_url.robot ├── test_get_json_data_for_dto_class.robot ├── test_get_json_data_with_conflict.robot ├── test_get_parameterized_endpoint_from_url.robot ├── test_get_request_data.robot ├── test_get_valid_id_for_endpoint.robot ├── test_get_valid_url.robot ├── test_readonly.robot ├── test_request_data_class.robot └── test_schema_variations.robot ├── unittests ├── __init__.py ├── test_dto_utils.py ├── test_get_safe_key.py └── value_utils │ ├── __init__.py │ ├── test_get_invalid_value.py │ ├── test_get_valid_value.py │ ├── test_invalid_value_from_constraint.py │ ├── test_invalid_value_from_enum.py │ ├── test_localized_faker.py │ ├── test_random_array.py │ ├── test_random_float.py │ ├── test_random_integer.py │ ├── test_random_string.py │ ├── test_type_name_mappers.py │ └── test_value_out_of_bounds.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-openapi-libcore" 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 | } -------------------------------------------------------------------------------- /.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-openapi-libcore" 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 --remove-untracked 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 | # OpenApiLibCore for Robot Framework 5 | 6 | > OpenApiLibCore has moved! 7 | Please head over to https://github.com/MarketSquare/robotframework-openapitools for the latest version! 8 | 9 | The OpenApiLibCore library is a utility library that is meant to simplify creation 10 | of other Robot Framework libraries for API testing based on the information in 11 | an OpenAPI document (also known as Swagger document). 12 | This document explains how to use the OpenApiLibCore library. 13 | 14 | My RoboCon 2022 talk about OpenApiDriver and OpenApiLibCore can be found 15 | [here](https://www.youtube.com/watch?v=7YWZEHxk9Ps) 16 | 17 | For more information about Robot Framework, see http://robotframework.org. 18 | 19 | --- 20 | 21 | > Note: OpenApiLibCore is still being developed 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-openapi-libcore` 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 OpenApiLibCore implements a number of Robot Framework keywords that make it 41 | easy to interact with an OpenAPI implementation by using the information in the 42 | openapi document (Swagger file), for examply by automatic generation of valid values 43 | for requests based on the schema information in the document. 44 | 45 | > Note: OpenApiLibCore is designed for APIs based on the OAS v3 46 | The library has not been tested for APIs based on the OAS v2. 47 | 48 | --- 49 | 50 | ## Getting started 51 | 52 | Before trying to use the keywords exposed by OpenApiLibCore on the target API 53 | it's recommended to first ensure that the openapi document for the API is valid 54 | under the OpenAPI Specification. 55 | 56 | This can be done using the command line interface of a package that is installed as 57 | a prerequisite for OpenApiLibCore. 58 | Both a local openapi.json or openapi.yaml file or one hosted by the API server 59 | can be checked using the `prance validate ` shell command: 60 | 61 | ```shell 62 | prance validate --backend=openapi-spec-validator http://localhost:8000/openapi.json 63 | Processing "http://localhost:8000/openapi.json"... 64 | -> Resolving external references. 65 | Validates OK as OpenAPI 3.0.2! 66 | 67 | prance validate --backend=openapi-spec-validator /tests/files/petstore_openapi.yaml 68 | Processing "/tests/files/petstore_openapi.yaml"... 69 | -> Resolving external references. 70 | Validates OK as OpenAPI 3.0.2! 71 | ``` 72 | 73 | You'll have to change the url or file reference to the location of the openapi 74 | document for your API. 75 | 76 | > Note: Although recursion is technically allowed under the OAS, tool support is limited 77 | and changing the OAS to not use recursion is recommended. 78 | OpenApiLibCore has limited support for parsing OpenAPI documents with 79 | recursion in them. See the `recursion_limit` and `recursion_default` parameters. 80 | 81 | If the openapi document passes this validation, the next step is trying to do a test 82 | run with a minimal test suite. 83 | The example below can be used, with `source`, `origin` and 'endpoint' altered to 84 | fit your situation. 85 | 86 | ``` robotframework 87 | *** Settings *** 88 | Library OpenApiLibCore 89 | ... source=http://localhost:8000/openapi.json 90 | ... origin=http://localhost:8000 91 | 92 | *** Test Cases *** 93 | Getting Started 94 | ${url}= Get Valid Url endpoint=/employees/{employee_id} method=get 95 | 96 | ``` 97 | 98 | Running the above suite for the first time may result in an error / failed test. 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 OpenApiLibCore library parameters and keywords that you may need can be found 104 | [here](https://marketsquare.github.io/robotframework-openapi-libcore/openapi_libcore.html). 105 | 106 | The OpenApiLibCore 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-endpoint authorization levels. 122 | - Parsing of OAS 3.1 documents is supported by the parsing tools, but runtime behavior is untested. 123 | 124 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-midnight -------------------------------------------------------------------------------- /docs/advanced_use.md: -------------------------------------------------------------------------------- 1 | # Advanced use scenario's: using the mappings_path 2 | 3 | ## Introduction 4 | When working with APIs, there are often relations between resources or constraints on values. 5 | The property on one resource may refer to the `id` of another resource. 6 | The value for a certain property may have to be unique within a certain scope. 7 | Perhaps an endpoint path contains parameters that must match values that are defined outside the API itself. 8 | 9 | These types of relations and limitations cannot be described / modeled within the openapi document. 10 | To support automatic validation of API endpoints where such relations apply, OpenApiLibCore supports the usage of a custom mappings file. 11 | 12 | ## Taking a custom mappings file into use 13 | To take a custom mappings file into use, the absolute path to it has to be passed to OpenApiLibCore as the `mappings_path` parameter: 14 | 15 | ```robot framework 16 | *** Settings *** 17 | Library OpenApiLibCore 18 | ... source=http://localhost:8000/openapi.json 19 | ... origin=http://localhost:8000 20 | ... mappings_path=${ROOT}/tests/custom_user_mappings.py 21 | ... 22 | ``` 23 | 24 | > Note: An absolute path is required. 25 | > In the example above, `${ROOT}` is a global variable that holds the absolute path to the repository root. 26 | 27 | ## The custom mappings file 28 | Just like custom Robot Framework libraries, the mappings file has to be implemented in Python. 29 | Since this Python file is imported by the OpenApiLibCore, it has to follow a fixed format (more technically, implement a certain interface). 30 | The (almost) bare minimum implementation of a mappings.py file looks like this: 31 | 32 | ```python 33 | from OpenApiLibCore import ( 34 | IGNORE, 35 | Dto, 36 | IdDependency, 37 | IdReference, 38 | PathPropertiesConstraint, 39 | PropertyValueConstraint, 40 | UniquePropertyValueConstraint, 41 | ) 42 | 43 | 44 | ID_MAPPING = { 45 | "/myspecialendpoint", "special_thing_id", 46 | } 47 | 48 | 49 | class MyDtoThatDoesNothing(Dto): 50 | @staticmethod 51 | def get_relations(): 52 | relations = [] 53 | return relations 54 | 55 | 56 | DTO_MAPPING = { 57 | ("/myspecialendpoint", "post"): MyDtoThatDoesNothing 58 | } 59 | 60 | ``` 61 | 62 | There are 4 main parts in this mappings file: 63 | 64 | 1. The import section. 65 | Here the classes needed to implement custom mappings are imported. 66 | This section can just be copied without changes. 67 | 2. The `ID_MAPPING` "constant" definition / assignment. 68 | 3. The section defining the mapping Dtos. 69 | More on this later. 70 | 4. The `DTO_MAPPING` "constant" definition / assignment. 71 | 72 | 73 | ## The ID_MAPPING and DTO_MAPPING 74 | When a custom mappings file is used, the OpenApiLibCore will attempt to import it and then import `DTO_MAPPING` and `ID_MAPPING` from it. 75 | For this reason, the exact same name must be used in a custom mappings file (capitilization matters). 76 | 77 | ### The ID_MAPPING 78 | The `ID_MAPPING` is a dictionary with a string as its key and either a string or a tuple of string and a callable as its value. The callable must take a string as its argument and return a string. 79 | 80 | The `ID_MAPPING` is used to specify what the unique identifier property of a resource at the given path is, if different from the `default_id_property_name` (see library parameters). 81 | 82 | In some situations, the identifier of the resource is not url-safe (e.g. containing a `/`). 83 | To support this type of resource identifier, a transformer can be provided: 84 | 85 | ```python 86 | def my_transformer(identifier_name: str) -> str: 87 | return identifier_name.replace("/", "_") 88 | 89 | 90 | ID_MAPPING = { 91 | "/myspecialendpoint": ("special_thing_id", my_transformer), 92 | } 93 | 94 | ``` 95 | 96 | ### The DTO_MAPPING 97 | The `DTO_MAPPING` is a dictionary with a tuple as its key and a mappings Dto as its value. 98 | The tuple must be in the form `("endpoint_from_the_paths_section", "method_supported_by_the_endpoint")`. 99 | The `endpoint_from_the_paths_section` must be exactly as found in the openapi document. 100 | The `method_supported_by_the_endpoint` must be one of the methods supported by the endpoint and must be in lowercase. 101 | 102 | 103 | ## Dto mapping classes 104 | As can be seen from the import section above, a number of classes are available to deal with relations between resources and / or constraints on properties. 105 | Each of these classes is designed to handle a relation or constraint commonly seen in REST APIs. 106 | 107 | --- 108 | 109 | To explain the different mapping classes, we'll use the following example: 110 | 111 | Imagine we have an API endpoint `/employees` where we can create (`post`) a new Employee resource. 112 | The Employee has a number of required properties; name, employee_number, wagegroup_id, and date_of_birth. 113 | 114 | There is also the the `/wagegroups` endpoint where a Wagegroup resource can be created. 115 | This Wagegroup also has a number of required properties: name and hourly rate. 116 | 117 | --- 118 | 119 | ### `IdDependency` 120 | > *The value for this propery must the the `id` of another resource* 121 | 122 | To add an Employee, a `wagegroup_id` is required, the `id` of a Wagegroup resource that is already present in the system. 123 | 124 | Since a typical REST API generates this `id` for a new resource and returns that `id` as part of the `post` response, the required `wagegroup_id` can be obtained by posting a new Wagegroup. This relation can be implemented as follows: 125 | 126 | ```python 127 | class EmployeeDto(Dto): 128 | @staticmethod 129 | def get_relations(): 130 | relations = [ 131 | IdDependency( 132 | property_name="wagegroup_id", 133 | get_path="/wagegroups", 134 | error_code=451, 135 | ), 136 | ] 137 | return relations 138 | 139 | DTO_MAPPING = { 140 | ("/employees", "post"): EmployeeDto 141 | } 142 | ``` 143 | 144 | Notice that the `get_path` of the `IdDependency` is not named `post_path` instead. 145 | This is deliberate for two reasons: 146 | 147 | 1. The purpose is getting an `id` 148 | 2. If the `post` operation is not supported on the provided path, a `get` operation is performed instead. 149 | It is assumed that such a `get` will yield a list of resources and that each of these resources has an `id` that is valid for the desired `post` operation. 150 | 151 | Also note the `error_code` of the `IdDependency`. 152 | If a `post` is attempted with a value for the `wagegroup_id` that does not exist, the API should return an `error_code` response. 153 | This `error_code` should be described as one of the `responses` in the openapi document for the `post` operation of the `/employees` path. 154 | 155 | --- 156 | 157 | ### `IdReference` 158 | > *This resource may not be DELETED if another resource refers to it* 159 | 160 | If an Employee has been added to the system, this Employee refers to the `id` of a Wagegroup for its required `employee_number` property. 161 | 162 | Now let's say there is also the `/wagegroups/${wagegroup_id}` endpoint that supports the `delete` operation. 163 | If the Wagegroup refered to the Employee would be deleted, the Employee would be left with an invalid reference for one of its required properties. 164 | To prevent this, an API typically returns an `error_code` when such a `delete` operation is attempted on a resource that is refered to in this fashion. 165 | This `error_code` should be described as one of the `responses` in the openapi document for the `delete` operation of the `/wagegroups/${wagegroup_id}` path. 166 | 167 | To verify that the specified `error_code` indeed occurs when attempting to `delete` the Wagegroup, we can implement the following dependency: 168 | 169 | ```python 170 | class WagegroupDto(Dto): 171 | @staticmethod 172 | def get_relations(): 173 | relations = [ 174 | IdReference( 175 | property_name="wagegroup_id", 176 | post_path="/employees", 177 | error_code=406, 178 | ), 179 | ] 180 | return relations 181 | 182 | DTO_MAPPING = { 183 | ("/wagegroups/{wagegroup_id}", "delete"): WagegroupDto 184 | } 185 | ``` 186 | 187 | --- 188 | 189 | ### `UniquePropertyValueConstraint` 190 | > *The value of this property must be unique within its scope* 191 | 192 | In a lot of systems, there is data that should be unique; an employee number, the email address of an employee, the domain name for the employee, etc. 193 | Often those values are automatically generated based on other data, but for some data, an "available value" must be chosen by hand. 194 | 195 | In our example, the required `employee_number` must be chosen from the "free" numbers. 196 | When a number is chosen that is already in use, the API should return the `error_code` specified in the openapi document for the operation (typically `post`, `put` and `patch`) on the endpoint. 197 | 198 | To verify that the specified `error_code` occurs when attempting to `post` an Employee with an `employee_number` that is already in use, we can implement the following dependency: 199 | 200 | ```python 201 | class EmployeeDto(Dto): 202 | @staticmethod 203 | def get_relations(): 204 | relations = [ 205 | UniquePropertyValueConstraint( 206 | property_name="employee_number", 207 | value=42, 208 | error_code=422, 209 | ), 210 | ] 211 | return relations 212 | 213 | DTO_MAPPING = { 214 | ("/employees", "post"): EmployeeDto, 215 | ("/employees/${employee_id}", "put"): EmployeeDto, 216 | ("/employees/${employee_id}", "patch"): EmployeeDto, 217 | } 218 | ``` 219 | 220 | Note how this example reuses the `EmployeeDto` to model the uniqueness constraint for all the operations (`post`, `put` and `patch`) that all relate to the same `employee_number`. 221 | 222 | --- 223 | 224 | ### `PropertyValueConstraint` 225 | > *Use one of these values for this property* 226 | 227 | The OpenApiLibCore uses the `type` information in the openapi document to generate random data of the correct type to perform the operations that need it. 228 | While this works in many situations (e.g. a random `string` for a `name`), there can be additional restrictions to a value that cannot be specified in an openapi document. 229 | 230 | In our example, the `date_of_birth` must be a string in a specific format, e.g. 1995-03-27. 231 | This type of constraint can be modeled as follows: 232 | 233 | ```python 234 | class EmployeeDto(Dto): 235 | @staticmethod 236 | def get_relations(): 237 | relations = [ 238 | PropertyValueConstraint( 239 | property_name="date_of_birth", 240 | values=["1995-03-27", "1980-10-02"], 241 | error_code=422, 242 | ), 243 | ] 244 | return relations 245 | 246 | DTO_MAPPING = { 247 | ("/employees", "post"): EmployeeDto, 248 | ("/employees/${employee_id}", "put"): EmployeeDto, 249 | ("/employees/${employee_id}", "patch"): EmployeeDto, 250 | } 251 | ``` 252 | 253 | Now in addition, there could also be the restriction that the Employee must be 18 years or older. 254 | To support additional restrictions like these, the `PropertyValueConstraint` supports two additional properties: `error_value` and `invalid_value_error_code`: 255 | 256 | ```python 257 | class EmployeeDto(Dto): 258 | @staticmethod 259 | def get_relations(): 260 | relations = [ 261 | PropertyValueConstraint( 262 | property_name="date_of_birth", 263 | values=["1995-03-27", "1980-10-02"], 264 | error_code=422, 265 | invalid_value="2020-02-20", 266 | invalid_value_error_code=403, 267 | ), 268 | ] 269 | return relations 270 | 271 | DTO_MAPPING = { 272 | ("/employees", "post"): EmployeeDto, 273 | ("/employees/${employee_id}", "put"): EmployeeDto, 274 | ("/employees/${employee_id}", "patch"): EmployeeDto, 275 | } 276 | ``` 277 | 278 | So now if an incorrectly formatted date is send a 422 response is expected, but when `2020-02-20` is send the expected repsonse is 403. 279 | 280 | In some API implementations, there may be a property that will always return a specific error code if it's value is not valid. 281 | This means that sending e.g. an invalid type of value will not result in the default error code for the API (typically 422 or 400). 282 | This situation can be handled by use of the special `IGNORE` value (see below for other uses): 283 | 284 | ```python 285 | class EmployeeDto(Dto): 286 | @staticmethod 287 | def get_relations(): 288 | relations = [ 289 | PropertyValueConstraint( 290 | property_name="date_of_birth", 291 | values=["1995-03-27", "1980-10-02"], 292 | error_code=403, 293 | invalid_value=IGNORE, 294 | invalid_value_error_code=422, 295 | ), 296 | ] 297 | return relations 298 | 299 | DTO_MAPPING = { 300 | ("/employees", "post"): EmployeeDto, 301 | ("/employees/${employee_id}", "put"): EmployeeDto, 302 | ("/employees/${employee_id}", "patch"): EmployeeDto, 303 | } 304 | ``` 305 | 306 | Note that while this configuration will prevent failing test cases generated by OpenApiDriver, it does not explicitly check for business logic errors anymore (younger than 18 in this example). 307 | 308 | --- 309 | 310 | ### `PathPropertiesConstraint` 311 | > *Just use this for the `path`* 312 | 313 | To be able to automatically perform endpoint validations, the OpenApiLibCore has to construct the `url` for the resource from the `path` as found in the openapi document. 314 | Often, such a `path` contains a reference to a resource id, e.g. `/employees/${employee_id}`. 315 | When such an `id` is needed, the OpenApiLibCore tries to obtain a valid `id` by taking these steps: 316 | 317 | 1. Attempt a `post` on the "parent endpoint" and extract the `id` from the response. 318 | In our example: perform a `post` request on the `/employees` endpoint and get the `id` from the response. 319 | 2. If 1. fails, perform a `get` request on the `/employees` endpoint. It is assumed that this will return a list of Employee objects with an `id`. 320 | One item from the returned list is picked at rondom and its `id` is used. 321 | 322 | This mechanism relies on the standard REST structure and patterns. 323 | 324 | Unfortunately, this structure / pattern does not apply to every endpoint, not every path parameter refers to a resource id. 325 | Imagine we want to extend the API from our example with an endpoint that returns all the Employees that have their birthday at a given date: 326 | `/birthdays/${month}/${date}`. 327 | It should be clear that the OpenApiLibCore won't be able to acquire a valid `month` and `date`. The `PathPropertiesConstraint` can be used in this case: 328 | 329 | ```python 330 | class BirthdaysDto(Dto): 331 | @staticmethod 332 | def get_relations(): 333 | relations = [ 334 | PathPropertiesConstraint(path="/birthdays/03/27"), 335 | ] 336 | return relations 337 | 338 | DTO_MAPPING = { 339 | ("/birthdays/{month}/{date}", "get"): BirthdaysDto 340 | } 341 | ``` 342 | 343 | --- 344 | 345 | ### `IGNORE` 346 | > *Never send this query parameter as part of a request* 347 | 348 | Some optional query parameters have a range of valid values that depend on one or more path parameters. 349 | Since path parameters are part of a url, they cannot be optional or empty so to extend the path parameters with optional parameters, query parameters can be used. 350 | 351 | To illustrate this, let's imagine an API where the energy label for a building can be requested: `/energylabel/${zipcode}/${home_number}`. 352 | Some addresses however have an address extension, e.g. 1234AB 42 2.C. 353 | The extension may not be limited to a fixed pattern / range and if an address has an extension, in many cases the address without an extension part is invalid. 354 | 355 | To prevent OpenApiLibCore from generating invalid combinations of path and query parameters in this type of endpoint, the `IGNORE` special value can be used to ensure the related query parameter is never send in a request. 356 | 357 | ```python 358 | class EnergyLabelDto(Dto): 359 | @staticmethod 360 | def get_parameter_relations(): 361 | relations = [ 362 | PropertyValueConstraint( 363 | property_name="address_extension", 364 | values=[IGNORE], 365 | error_code=422, 366 | ), 367 | ] 368 | return relations 369 | 370 | @staticmethod 371 | def get_relations(: 372 | relations = [ 373 | PathPropertiesConstraint(path="/energy_label/1111AA/10"), 374 | ] 375 | return relations 376 | 377 | DTO_MAPPING = { 378 | ("/energy_label/{zipcode}/{home_number}", "get"): EnergyLabelDto 379 | } 380 | ``` 381 | 382 | Note that in this example, the `get_parameter_relations()` method is implemented. 383 | This method works mostly the same as the `get_relations()` method but applies to headers and query parameters. 384 | 385 | --- 386 | 387 | ## Type annotations 388 | 389 | An additional import to support type annotations is also available: `Relation`. 390 | A fully typed example can be found 391 | [here](https://github.com/MarketSquare/robotframework-openapi-libcore/blob/main/tests/user_implemented/custom_user_mappings.py). 392 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name="robotframework-openapi-libcore" 3 | version = "1.11.0" 4 | description = "A Robot Framework library to facilitate library development for 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-openapi-libcore" 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 = "OpenApiLibCore", from = "src" }, 21 | ] 22 | include = ["*.libspec"] 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.8" 26 | robotframework = ">=4" 27 | requests = "^2.27" 28 | prance = {version = "^23.6", extras = ["CLI"]} 29 | Faker = ">=11" 30 | rstr = "^3" 31 | openapi-core = "^0.18.0" 32 | 33 | [tool.poetry.group.dev.dependencies] 34 | fastapi = ">=0.95.0" 35 | uvicorn = ">=0.22.0" 36 | invoke = ">=2.0.0" 37 | coverage = {version=">=7.2.5", extras = ["toml"]} 38 | robotframework-stacktrace = ">=0.4.1" 39 | 40 | [tool.poetry.group.formatting.dependencies] 41 | black = ">=22.10.0" 42 | isort = ">=5.10.1" 43 | robotframework-tidy = ">=3.4.0" 44 | 45 | [tool.poetry.group.type-checking.dependencies] 46 | mypy = ">=1.2.0" 47 | pyright = ">=1.1.300" 48 | types-requests = ">=2.28.11" 49 | types-invoke = ">=2.0.0.6" 50 | 51 | [tool.poetry.group.linting.dependencies] 52 | pylint = ">=2.17.2" 53 | ruff = ">=0.0.267" 54 | robotframework-robocop = ">=2.7.0" 55 | 56 | [build-system] 57 | requires = ["poetry-core>=1.0.0"] 58 | build-backend = "poetry.core.masonry.api" 59 | 60 | [tool.coverage.run] 61 | branch = true 62 | parallel = true 63 | source = ["src"] 64 | 65 | [tool.coverage.report] 66 | exclude_lines = [ 67 | "pragma: no cover", 68 | "@abstract" 69 | ] 70 | 71 | [tool.mypy] 72 | plugins = ["pydantic.mypy"] 73 | warn_redundant_casts = true 74 | warn_unused_ignores = true 75 | disallow_any_generics = true 76 | check_untyped_defs = true 77 | disallow_untyped_defs = true 78 | strict = true 79 | show_error_codes = true 80 | 81 | [[tool.mypy.overrides]] 82 | module = [ 83 | "DataDriver.*", 84 | "prance.*", 85 | "robot.*", 86 | "openapi_core.*", 87 | "OpenApiLibCore.*", 88 | "uvicorn", 89 | "invoke", 90 | ] 91 | ignore_missing_imports = true 92 | 93 | [tool.black] 94 | line-length = 88 95 | target-version = ["py38"] 96 | 97 | [tool.isort] 98 | profile = "black" 99 | py_version=38 100 | 101 | [tool.ruff] 102 | line-length = 120 103 | src = ["src/OpenApiDriver"] 104 | 105 | [tool.ruff.lint] 106 | select = ["E", "F", "PL"] 107 | 108 | [tool.pylint.'MESSAGES CONTROL'] 109 | disable = ["logging-fstring-interpolation", "missing-class-docstring"] 110 | 111 | [tool.pylint.'FORMAT CHECKER'] 112 | max-line-length=120 113 | 114 | [tool.pylint.'SIMILARITIES CHECKER'] 115 | ignore-imports="yes" 116 | 117 | [tool.robotidy] 118 | line_length = 120 119 | spacecount = 4 120 | 121 | [tool.robocop] 122 | filetypes = [".robot", ".resource"] 123 | configure = [ 124 | "line-too-long:line_length:120", 125 | "too-many-calls-in-test-case:max_calls:15" 126 | ] 127 | exclude = [ 128 | "missing-doc-suite", 129 | "missing-doc-test-case", 130 | "missing-doc-keyword", 131 | "too-few-calls-in-test-case" 132 | ] 133 | -------------------------------------------------------------------------------- /src/OpenApiLibCore/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The OpenApiLibCore package is intended to be used as a dependency for other 3 | Robot Framework libraries that facilitate the testing of OpenAPI / Swagger APIs. 4 | The following classes and constants are exposed to be used by the library user: 5 | - OpenApiLibCore: The class to be imported in the Robot Framework library. 6 | - IdDependency, IdReference, PathPropertiesConstraint, PropertyValueConstraint, 7 | UniquePropertyValueConstraint: Classes to be subclassed by the library user 8 | when implementing a custom mapping module (advanced use). 9 | - Dto, Relation: Base classes that can be used for type annotations. 10 | - IGNORE: A special constant that can be used as a value in the PropertyValueConstraint. 11 | """ 12 | 13 | from importlib.metadata import version 14 | 15 | from OpenApiLibCore.dto_base import ( 16 | Dto, 17 | IdDependency, 18 | IdReference, 19 | PathPropertiesConstraint, 20 | PropertyValueConstraint, 21 | Relation, 22 | UniquePropertyValueConstraint, 23 | resolve_schema, 24 | ) 25 | from OpenApiLibCore.dto_utils import DefaultDto 26 | from OpenApiLibCore.openapi_libcore import OpenApiLibCore, RequestData, RequestValues 27 | from OpenApiLibCore.value_utils import IGNORE 28 | 29 | try: 30 | __version__ = version("robotframework-openapi-libcore") 31 | except Exception: # pragma: no cover 32 | pass 33 | 34 | __all__ = [ 35 | "Dto", 36 | "IdDependency", 37 | "IdReference", 38 | "PathPropertiesConstraint", 39 | "PropertyValueConstraint", 40 | "Relation", 41 | "UniquePropertyValueConstraint", 42 | "DefaultDto", 43 | "OpenApiLibCore", 44 | "RequestData", 45 | "RequestValues", 46 | "resolve_schema", 47 | "IGNORE", 48 | ] 49 | -------------------------------------------------------------------------------- /src/OpenApiLibCore/dto_base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module holding the (base) classes that can be used by the user of the OpenApiLibCore 3 | to implement custom mappings for dependencies between resources in the API under 4 | test and constraints / restrictions on properties of the resources. 5 | """ 6 | 7 | from abc import ABC 8 | from copy import deepcopy 9 | from dataclasses import dataclass, fields 10 | from logging import getLogger 11 | from random import choice, shuffle 12 | from typing import Any, Dict, List, Optional, Union 13 | from uuid import uuid4 14 | 15 | from OpenApiLibCore import value_utils 16 | 17 | logger = getLogger(__name__) 18 | 19 | NOT_SET = object() 20 | SENTINEL = object() 21 | 22 | 23 | def resolve_schema(schema: Dict[str, Any]) -> Dict[str, Any]: 24 | """ 25 | Helper function to resolve allOf, anyOf and oneOf instances in a schema. 26 | 27 | The schemas are used to generate values for headers, query parameters and json 28 | bodies to be able to make requests. 29 | """ 30 | # Schema is mutable, so deepcopy to prevent mutation of original schema argument 31 | resolved_schema = deepcopy(schema) 32 | 33 | # allOf / anyOf / oneOf may be nested, so recursively resolve the dict-typed values 34 | for key, value in resolved_schema.items(): 35 | if isinstance(value, dict): 36 | resolved_schema[key] = resolve_schema(value) 37 | 38 | # When handling allOf there should no duplicate keys, so the schema parts can 39 | # just be merged after resolving the individual parts 40 | if schema_parts := resolved_schema.pop("allOf", None): 41 | for schema_part in schema_parts: 42 | resolved_part = resolve_schema(schema_part) 43 | resolved_schema = merge_schemas(resolved_schema, resolved_part) 44 | # Handling anyOf and oneOf requires extra logic to deal with the "type" information. 45 | # Some properties / parameters may be of different types and each type may have its 46 | # own restrictions e.g. a parameter that accepts an enum value (string) or an 47 | # integer value within a certain range. 48 | # Since the library needs all this information for different purposes, the 49 | # schema_parts cannot be merged, so a helper property / key "types" is introduced. 50 | any_of = resolved_schema.pop("anyOf", []) 51 | one_of = resolved_schema.pop("oneOf", []) 52 | schema_parts = any_of if any_of else one_of 53 | 54 | for schema_part in schema_parts: 55 | resolved_part = resolve_schema(schema_part) 56 | if isinstance(resolved_part, dict) and "type" in resolved_part.keys(): 57 | if "types" in resolved_schema.keys(): 58 | resolved_schema["types"].append(resolved_part) 59 | else: 60 | resolved_schema["types"] = [resolved_part] 61 | else: 62 | resolved_schema = merge_schemas(resolved_schema, resolved_part) 63 | 64 | return resolved_schema 65 | 66 | 67 | def merge_schemas(first: Dict[str, Any], second: Dict[str, Any]) -> Dict[str, Any]: 68 | """Helper method to merge two schemas, recursively.""" 69 | merged_schema = deepcopy(first) 70 | for key, value in second.items(): 71 | # for existing keys, merge dict and list values, leave others unchanged 72 | if key in merged_schema.keys(): 73 | if isinstance(value, dict): 74 | # if the key holds a dict, merge the values (e.g. 'properties') 75 | merged_schema[key].update(value) 76 | elif isinstance(value, list): 77 | # if the key holds a list, extend the values (e.g. 'required') 78 | merged_schema[key].extend(value) 79 | else: 80 | logger.warning( 81 | f"key '{key}' with value '{merged_schema[key]}' not " 82 | f"updated to '{value}'" 83 | ) 84 | else: 85 | merged_schema[key] = value 86 | return merged_schema 87 | 88 | 89 | class ResourceRelation(ABC): # pylint: disable=too-few-public-methods 90 | """ABC for all resource relations or restrictions within the API.""" 91 | 92 | property_name: str 93 | error_code: int 94 | 95 | 96 | @dataclass 97 | class PathPropertiesConstraint(ResourceRelation): 98 | """The resolved path for the endpoint.""" 99 | 100 | path: str 101 | property_name: str = "id" 102 | error_code: int = 404 103 | 104 | 105 | @dataclass 106 | class PropertyValueConstraint(ResourceRelation): 107 | """The allowed values for property_name.""" 108 | 109 | property_name: str 110 | values: List[Any] 111 | invalid_value: Any = NOT_SET 112 | invalid_value_error_code: int = 422 113 | error_code: int = 422 114 | 115 | 116 | @dataclass 117 | class IdDependency(ResourceRelation): 118 | """The path where a valid id for the property_name can be gotten (using GET).""" 119 | 120 | property_name: str 121 | get_path: str 122 | operation_id: Optional[str] = None 123 | error_code: int = 422 124 | 125 | 126 | @dataclass 127 | class IdReference(ResourceRelation): 128 | """The path where a resource that needs this resource's id can be created (using POST).""" 129 | 130 | property_name: str 131 | post_path: str 132 | error_code: int = 422 133 | 134 | 135 | @dataclass 136 | class UniquePropertyValueConstraint(ResourceRelation): 137 | """The value of the property must be unique within the resource scope.""" 138 | 139 | property_name: str 140 | value: Any 141 | error_code: int = 422 142 | 143 | 144 | Relation = Union[ 145 | IdDependency, 146 | IdReference, 147 | PathPropertiesConstraint, 148 | PropertyValueConstraint, 149 | UniquePropertyValueConstraint, 150 | ] 151 | 152 | 153 | @dataclass 154 | class Dto(ABC): 155 | """Base class for the Dto class.""" 156 | 157 | @staticmethod 158 | def get_parameter_relations() -> List[Relation]: 159 | """Return the list of Relations for the header and query parameters.""" 160 | return [] 161 | 162 | def get_parameter_relations_for_error_code(self, error_code: int) -> List[Relation]: 163 | """Return the list of Relations associated with the given error_code.""" 164 | relations: List[Relation] = [ 165 | r 166 | for r in self.get_parameter_relations() 167 | if r.error_code == error_code 168 | or ( 169 | getattr(r, "invalid_value_error_code", None) == error_code 170 | and getattr(r, "invalid_value", None) != NOT_SET 171 | ) 172 | ] 173 | return relations 174 | 175 | @staticmethod 176 | def get_relations() -> List[Relation]: 177 | """Return the list of Relations for the (json) body.""" 178 | return [] 179 | 180 | def get_relations_for_error_code(self, error_code: int) -> List[Relation]: 181 | """Return the list of Relations associated with the given error_code.""" 182 | relations: List[Relation] = [ 183 | r 184 | for r in self.get_relations() 185 | if r.error_code == error_code 186 | or ( 187 | getattr(r, "invalid_value_error_code", None) == error_code 188 | and getattr(r, "invalid_value", None) != NOT_SET 189 | ) 190 | ] 191 | return relations 192 | 193 | def get_invalidated_data( 194 | self, 195 | schema: Dict[str, Any], 196 | status_code: int, 197 | invalid_property_default_code: int, 198 | ) -> Dict[str, Any]: 199 | """Return a data set with one of the properties set to an invalid value or type.""" 200 | properties: Dict[str, Any] = self.as_dict() 201 | 202 | schema = resolve_schema(schema) 203 | 204 | relations = self.get_relations_for_error_code(error_code=status_code) 205 | # filter PathProperyConstraints since in that case no data can be invalidated 206 | relations = [ 207 | r for r in relations if not isinstance(r, PathPropertiesConstraint) 208 | ] 209 | property_names = [r.property_name for r in relations] 210 | if status_code == invalid_property_default_code: 211 | # add all properties defined in the schema, including optional properties 212 | property_names.extend((schema["properties"].keys())) 213 | # remove duplicates 214 | property_names = list(set(property_names)) 215 | if not property_names: 216 | raise ValueError( 217 | f"No property can be invalidated to cause status_code {status_code}" 218 | ) 219 | # shuffle the property_names so different properties on the Dto are invalidated 220 | # when rerunning the test 221 | shuffle(property_names) 222 | for property_name in property_names: 223 | # if possible, invalidate a constraint but send otherwise valid data 224 | id_dependencies = [ 225 | r 226 | for r in relations 227 | if isinstance(r, IdDependency) and r.property_name == property_name 228 | ] 229 | if id_dependencies: 230 | invalid_value = uuid4().hex 231 | logger.debug( 232 | f"Breaking IdDependency for status_code {status_code}: replacing " 233 | f"{properties[property_name]} with {invalid_value}" 234 | ) 235 | properties[property_name] = invalid_value 236 | return properties 237 | 238 | invalid_value_from_constraint = [ 239 | r.invalid_value 240 | for r in relations 241 | if isinstance(r, PropertyValueConstraint) 242 | and r.property_name == property_name 243 | and r.invalid_value_error_code == status_code 244 | ] 245 | if ( 246 | invalid_value_from_constraint 247 | and invalid_value_from_constraint[0] is not NOT_SET 248 | ): 249 | properties[property_name] = invalid_value_from_constraint[0] 250 | logger.debug( 251 | f"Using invalid_value {invalid_value_from_constraint[0]} to " 252 | f"invalidate property {property_name}" 253 | ) 254 | return properties 255 | 256 | value_schema = schema["properties"][property_name] 257 | value_schema = resolve_schema(value_schema) 258 | 259 | # Filter "type": "null" from the possible types since this indicates an 260 | # optional / nullable property that can only be invalidated by sending 261 | # invalid data of a non-null type 262 | if value_schemas := value_schema.get("types"): 263 | if len(value_schemas) > 1: 264 | value_schemas = [ 265 | schema for schema in value_schemas if schema["type"] != "null" 266 | ] 267 | value_schema = choice(value_schemas) 268 | 269 | # there may not be a current_value when invalidating an optional property 270 | current_value = properties.get(property_name, SENTINEL) 271 | if current_value is SENTINEL: 272 | # the current_value isn't very relevant as long as the type is correct 273 | # so no logic to handle Relations / objects / arrays here 274 | property_type = value_schema["type"] 275 | if property_type == "object": 276 | current_value = {} 277 | elif property_type == "array": 278 | current_value = [] 279 | else: 280 | current_value = value_utils.get_valid_value(value_schema) 281 | 282 | values_from_constraint = [ 283 | r.values[0] 284 | for r in relations 285 | if isinstance(r, PropertyValueConstraint) 286 | and r.property_name == property_name 287 | ] 288 | 289 | invalid_value = value_utils.get_invalid_value( 290 | value_schema=value_schema, 291 | current_value=current_value, 292 | values_from_constraint=values_from_constraint, 293 | ) 294 | properties[property_name] = invalid_value 295 | logger.debug( 296 | f"Property {property_name} changed to {invalid_value} (received from " 297 | f"get_invalid_value)" 298 | ) 299 | return properties 300 | logger.warning("get_invalidated_data returned unchanged properties") 301 | return properties # pragma: no cover 302 | 303 | def as_dict(self) -> Dict[Any, Any]: 304 | """Return the dict representation of the Dto.""" 305 | result = {} 306 | 307 | for field in fields(self): 308 | field_name = field.name 309 | if field_name not in self.__dict__: 310 | continue 311 | original_name = field.metadata["original_property_name"] 312 | result[original_name] = getattr(self, field_name) 313 | 314 | return result 315 | -------------------------------------------------------------------------------- /src/OpenApiLibCore/dto_utils.py: -------------------------------------------------------------------------------- 1 | """Module for helper methods and classes used by the openapi_executors module.""" 2 | 3 | from dataclasses import dataclass 4 | from importlib import import_module 5 | from logging import getLogger 6 | from typing import Callable, Dict, Tuple, Type, Union 7 | 8 | from OpenApiLibCore.dto_base import Dto 9 | 10 | logger = getLogger(__name__) 11 | 12 | 13 | @dataclass 14 | class _DefaultIdPropertyName: 15 | id_property_name: str = "id" 16 | 17 | 18 | DEFAULT_ID_PROPERTY_NAME = _DefaultIdPropertyName() 19 | 20 | 21 | @dataclass 22 | class DefaultDto(Dto): 23 | """A default Dto that can be instantiated.""" 24 | 25 | 26 | # pylint: disable=invalid-name, too-few-public-methods 27 | class get_dto_class: 28 | """Callable class to return Dtos from user-implemented mappings file.""" 29 | 30 | def __init__(self, mappings_module_name: str) -> None: 31 | try: 32 | mappings_module = import_module(mappings_module_name) 33 | self.dto_mapping: Dict[Tuple[str, str], Type[Dto]] = ( 34 | mappings_module.DTO_MAPPING 35 | ) 36 | except (ImportError, AttributeError, ValueError) as exception: 37 | if mappings_module_name != "no mapping": 38 | logger.error(f"DTO_MAPPING was not imported: {exception}") 39 | self.dto_mapping = {} 40 | 41 | def __call__(self, endpoint: str, method: str) -> Type[Dto]: 42 | try: 43 | return self.dto_mapping[(endpoint, method.lower())] 44 | except KeyError: 45 | logger.debug(f"No Dto mapping for {endpoint} {method}.") 46 | return DefaultDto 47 | 48 | 49 | # pylint: disable=invalid-name, too-few-public-methods 50 | class get_id_property_name: 51 | """ 52 | Callable class to return the name of the property that uniquely identifies 53 | the resource from user-implemented mappings file. 54 | """ 55 | 56 | def __init__(self, mappings_module_name: str) -> None: 57 | try: 58 | mappings_module = import_module(mappings_module_name) 59 | self.id_mapping: Dict[ 60 | str, 61 | Union[ 62 | str, 63 | Tuple[ 64 | str, Callable[[Union[str, int, float]], Union[str, int, float]] 65 | ], 66 | ], 67 | ] = mappings_module.ID_MAPPING 68 | except (ImportError, AttributeError, ValueError) as exception: 69 | if mappings_module_name != "no mapping": 70 | logger.error(f"ID_MAPPING was not imported: {exception}") 71 | self.id_mapping = {} 72 | 73 | def __call__( 74 | self, endpoint: str 75 | ) -> Union[ 76 | str, Tuple[str, Callable[[Union[str, int, float]], Union[str, int, float]]] 77 | ]: 78 | try: 79 | return self.id_mapping[endpoint] 80 | except KeyError: 81 | default_id_name = DEFAULT_ID_PROPERTY_NAME.id_property_name 82 | logger.debug( 83 | f"No id mapping for {endpoint} ('{default_id_name}' will be used)" 84 | ) 85 | return default_id_name 86 | -------------------------------------------------------------------------------- /src/OpenApiLibCore/oas_cache.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | 3 | from openapi_core import Spec 4 | from prance import ResolvingParser 5 | 6 | PARSER_CACHE: Dict[str, Tuple[ResolvingParser, Spec]] = {} 7 | -------------------------------------------------------------------------------- /src/OpenApiLibCore/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarketSquare/robotframework-openapi-libcore/7d99f3fb5e9f3e1fc0e3eba01afaa5681f29ec08/src/OpenApiLibCore/py.typed -------------------------------------------------------------------------------- /src/OpenApiLibCore/value_utils.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code=no-any-return 2 | """Utility module with functions to handle OpenAPI value types and restrictions.""" 3 | import base64 4 | import datetime 5 | from copy import deepcopy 6 | from logging import getLogger 7 | from random import choice, randint, uniform 8 | from typing import Any, Callable, Dict, List, Optional, Union 9 | 10 | import faker 11 | import rstr 12 | 13 | JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None] 14 | 15 | logger = getLogger(__name__) 16 | 17 | IGNORE = object() 18 | 19 | 20 | class LocalizedFaker: 21 | """Class to support setting a locale post-init.""" 22 | 23 | # pylint: disable=missing-function-docstring 24 | def __init__(self) -> None: 25 | self.fake = faker.Faker() 26 | 27 | def set_locale(self, locale: Union[str, List[str]]) -> None: 28 | """Update the fake attribute with a Faker instance with the provided locale.""" 29 | self.fake = faker.Faker(locale) 30 | 31 | @property 32 | def date(self) -> Callable[[], str]: 33 | return self.fake.date 34 | 35 | @property 36 | def date_time(self) -> Callable[[], datetime.datetime]: 37 | return self.fake.date_time 38 | 39 | @property 40 | def password(self) -> Callable[[], str]: 41 | return self.fake.password 42 | 43 | @property 44 | def binary(self) -> Callable[[], bytes]: 45 | return self.fake.binary 46 | 47 | @property 48 | def email(self) -> Callable[[], str]: 49 | return self.fake.safe_email 50 | 51 | @property 52 | def uuid(self) -> Callable[[], str]: 53 | return self.fake.uuid4 54 | 55 | @property 56 | def uri(self) -> Callable[[], str]: 57 | return self.fake.uri 58 | 59 | @property 60 | def url(self) -> Callable[[], str]: 61 | return self.fake.url 62 | 63 | @property 64 | def hostname(self) -> Callable[[], str]: 65 | return self.fake.hostname 66 | 67 | @property 68 | def ipv4(self) -> Callable[[], str]: 69 | return self.fake.ipv4 70 | 71 | @property 72 | def ipv6(self) -> Callable[[], str]: 73 | return self.fake.ipv6 74 | 75 | @property 76 | def name(self) -> Callable[[], str]: 77 | return self.fake.name 78 | 79 | @property 80 | def text(self) -> Callable[[], str]: 81 | return self.fake.text 82 | 83 | @property 84 | def description(self) -> Callable[[], str]: 85 | return self.fake.text 86 | 87 | 88 | FAKE = LocalizedFaker() 89 | 90 | 91 | def json_type_name_of_python_type(python_type: Any) -> str: 92 | """Return the JSON type name for supported Python types.""" 93 | if python_type == str: 94 | return "string" 95 | if python_type == bool: 96 | return "boolean" 97 | if python_type == int: 98 | return "integer" 99 | if python_type == float: 100 | return "number" 101 | if python_type == list: 102 | return "array" 103 | if python_type == dict: 104 | return "object" 105 | if python_type == type(None): 106 | return "null" 107 | raise ValueError(f"No json type mapping for Python type {python_type} available.") 108 | 109 | 110 | def python_type_by_json_type_name(type_name: str) -> Any: 111 | """Return the Python type based on the JSON type name.""" 112 | if type_name == "string": 113 | return str 114 | if type_name == "boolean": 115 | return bool 116 | if type_name == "integer": 117 | return int 118 | if type_name == "number": 119 | return float 120 | if type_name == "array": 121 | return list 122 | if type_name == "object": 123 | return dict 124 | if type_name == "null": 125 | return type(None) 126 | raise ValueError(f"No Python type mapping for JSON type '{type_name}' available.") 127 | 128 | 129 | def get_valid_value(value_schema: Dict[str, Any]) -> Any: 130 | """Return a random value that is valid under the provided value_schema.""" 131 | value_schema = deepcopy(value_schema) 132 | 133 | if value_schema.get("types"): 134 | value_schema = choice(value_schema["types"]) 135 | 136 | if (from_const := value_schema.get("const")) is not None: 137 | return from_const 138 | if from_enum := value_schema.get("enum"): 139 | return choice(from_enum) 140 | 141 | value_type = value_schema["type"] 142 | 143 | if value_type == "null": 144 | return None 145 | if value_type == "boolean": 146 | return FAKE.fake.boolean() 147 | if value_type == "integer": 148 | return get_random_int(value_schema=value_schema) 149 | if value_type == "number": 150 | return get_random_float(value_schema=value_schema) 151 | if value_type == "string": 152 | return get_random_string(value_schema=value_schema) 153 | if value_type == "array": 154 | return get_random_array(value_schema=value_schema) 155 | raise NotImplementedError(f"Type '{value_type}' is currently not supported") 156 | 157 | 158 | def get_invalid_value( 159 | value_schema: Dict[str, Any], 160 | current_value: Any, 161 | values_from_constraint: Optional[List[Any]] = None, 162 | ) -> Any: 163 | """Return a random value that violates the provided value_schema.""" 164 | value_schema = deepcopy(value_schema) 165 | 166 | if value_schemas := value_schema.get("types"): 167 | if len(value_schemas) > 1: 168 | value_schemas = [ 169 | schema for schema in value_schemas if schema["type"] != "null" 170 | ] 171 | value_schema = choice(value_schemas) 172 | 173 | invalid_value: Any = None 174 | value_type = value_schema["type"] 175 | 176 | if not isinstance(current_value, python_type_by_json_type_name(value_type)): 177 | current_value = get_valid_value(value_schema=value_schema) 178 | 179 | if ( 180 | values_from_constraint 181 | and ( 182 | invalid_value := get_invalid_value_from_constraint( 183 | values_from_constraint=values_from_constraint, 184 | value_type=value_type, 185 | ) 186 | ) 187 | is not None 188 | ): 189 | return invalid_value 190 | # If an enum is possible, combine the values from the enum to invalidate the value 191 | if enum_values := value_schema.get("enum"): 192 | if ( 193 | invalid_value := get_invalid_value_from_enum( 194 | values=enum_values, value_type=value_type 195 | ) 196 | ) is not None: 197 | return invalid_value 198 | # Violate min / max values or length if possible 199 | if ( 200 | invalid_value := get_value_out_of_bounds( 201 | value_schema=value_schema, current_value=current_value 202 | ) 203 | ) is not None: 204 | return invalid_value 205 | # No value constraints or min / max ranges to violate, so change the data type 206 | if value_type == "string": 207 | # Since int / float / bool can always be cast to sting, change 208 | # the string to a nested object. 209 | # An array gets exploded in query strings, "null" is then often invalid 210 | return [{"invalid": [None, False]}, "null", None, True] 211 | logger.debug(f"property type changed from {value_type} to random string") 212 | return FAKE.uuid() 213 | 214 | 215 | def get_random_int(value_schema: Dict[str, Any]) -> int: 216 | """Generate a random int within the min/max range of the schema, if specified.""" 217 | # Use int32 integers if "format" does not specify int64 218 | property_format = value_schema.get("format", "int32") 219 | if property_format == "int64": 220 | min_int = -9223372036854775808 221 | max_int = 9223372036854775807 222 | else: 223 | min_int = -2147483648 224 | max_int = 2147483647 225 | # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum 226 | # OAS 3.1: exclusiveMinimum/Maximum is an integer 227 | minimum = value_schema.get("minimum", min_int) 228 | maximum = value_schema.get("maximum", max_int) 229 | if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None: 230 | if isinstance(exclusive_minimum, bool): 231 | if exclusive_minimum: 232 | minimum += 1 233 | else: 234 | minimum = exclusive_minimum + 1 235 | if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None: 236 | if isinstance(exclusive_maximum, bool): 237 | if exclusive_maximum: 238 | maximum -= 1 239 | else: 240 | maximum = exclusive_maximum - 1 241 | return randint(minimum, maximum) 242 | 243 | 244 | def get_random_float(value_schema: Dict[str, Any]) -> float: 245 | """Generate a random float within the min/max range of the schema, if specified.""" 246 | # Python floats are already double precision, so no check for "format" 247 | minimum = value_schema.get("minimum") 248 | maximum = value_schema.get("maximum") 249 | if minimum is None: 250 | if maximum is None: 251 | minimum = -1.0 252 | maximum = 1.0 253 | else: 254 | minimum = maximum - 1.0 255 | else: 256 | if maximum is None: 257 | maximum = minimum + 1.0 258 | if maximum < minimum: 259 | raise ValueError(f"maximum of {maximum} is less than minimum of {minimum}") 260 | 261 | # For simplicity's sake, exclude both boundaries if one boundary is exclusive 262 | exclude_boundaries = False 263 | 264 | exclusive_minimum = value_schema.get("exclusiveMinimum", False) 265 | exclusive_maximum = value_schema.get("exclusiveMaximum", False) 266 | # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum 267 | # OAS 3.1: exclusiveMinimum/Maximum is an integer or number 268 | if not isinstance(exclusive_minimum, bool): 269 | exclude_boundaries = True 270 | minimum = exclusive_minimum 271 | else: 272 | exclude_boundaries = exclusive_minimum 273 | if not isinstance(exclusive_maximum, bool): 274 | exclude_boundaries = True 275 | maximum = exclusive_maximum 276 | else: 277 | exclude_boundaries = exclusive_minimum or exclusive_maximum 278 | 279 | if exclude_boundaries and minimum == maximum: 280 | raise ValueError( 281 | f"maximum of {maximum} is equal to minimum of {minimum} and " 282 | f"exclusiveMinimum or exclusiveMaximum is specified" 283 | ) 284 | 285 | while exclude_boundaries: 286 | result = uniform(minimum, maximum) 287 | if minimum < result < maximum: # pragma: no cover 288 | return result 289 | return uniform(minimum, maximum) 290 | 291 | 292 | def get_random_string(value_schema: Dict[str, Any]) -> Union[bytes, str]: 293 | """Generate a random string within the min/max length in the schema, if specified.""" 294 | # if a pattern is provided, format and min/max length can be ignored 295 | if pattern := value_schema.get("pattern"): 296 | return rstr.xeger(pattern) 297 | minimum = value_schema.get("minLength", 0) 298 | maximum = value_schema.get("maxLength", 36) 299 | if minimum > maximum: 300 | maximum = minimum 301 | format_ = value_schema.get("format", "uuid") 302 | # byte is a special case due to the required encoding 303 | if format_ == "byte": 304 | data = FAKE.uuid() 305 | return base64.b64encode(data.encode("utf-8")) 306 | value = fake_string(string_format=format_) 307 | while len(value) < minimum: 308 | # use fake.name() to ensure the returned string uses the provided locale 309 | value = value + FAKE.name() 310 | if len(value) > maximum: 311 | value = value[:maximum] 312 | return value 313 | 314 | 315 | def fake_string(string_format: str) -> str: 316 | """ 317 | Generate a random string based on the provided format if the format is supported. 318 | """ 319 | # format names may contain -, which is invalid in Python naming 320 | string_format = string_format.replace("-", "_") 321 | fake_generator = getattr(FAKE, string_format, FAKE.uuid) 322 | value: str = fake_generator() 323 | if isinstance(value, datetime.datetime): 324 | return value.strftime("%Y-%m-%dT%H:%M:%SZ") 325 | return value 326 | 327 | 328 | def get_random_array(value_schema: Dict[str, Any]) -> List[Any]: 329 | """Generate a list with random elements as specified by the schema.""" 330 | minimum = value_schema.get("minItems", 0) 331 | maximum = value_schema.get("maxItems", 1) 332 | if minimum > maximum: 333 | maximum = minimum 334 | items_schema = value_schema["items"] 335 | value = [] 336 | for _ in range(maximum): 337 | item_value = get_valid_value(items_schema) 338 | value.append(item_value) 339 | return value 340 | 341 | 342 | def get_invalid_value_from_constraint( 343 | values_from_constraint: List[Any], value_type: str 344 | ) -> Any: 345 | """ 346 | Return a value of the same type as the values in the values_from_constraints that 347 | is not in the values_from_constraints, if possible. Otherwise returns None. 348 | """ 349 | # if IGNORE is in the values_from_constraints, the parameter needs to be 350 | # ignored for an OK response so leaving the value at it's original value 351 | # should result in the specified error response 352 | if IGNORE in values_from_constraint: 353 | return IGNORE 354 | # if the value is forced True or False, return the opposite to invalidate 355 | if len(values_from_constraint) == 1 and value_type == "boolean": 356 | return not values_from_constraint[0] 357 | # for unsupported types or empty constraints lists return None 358 | if ( 359 | value_type not in ["string", "integer", "number", "array", "object"] 360 | or not values_from_constraint 361 | ): 362 | return None 363 | 364 | values_from_constraint = deepcopy(values_from_constraint) 365 | # for objects, keep the keys intact but update the values 366 | if value_type == "object": 367 | valid_object = values_from_constraint.pop() 368 | invalid_object = {} 369 | for key, value in valid_object.items(): 370 | python_type_of_value = type(value) 371 | json_type_of_value = json_type_name_of_python_type(python_type_of_value) 372 | invalid_object[key] = get_invalid_value_from_constraint( 373 | values_from_constraint=[value], 374 | value_type=json_type_of_value, 375 | ) 376 | return invalid_object 377 | 378 | # for arrays, update each value in the array to a value of the same type 379 | if value_type == "array": 380 | valid_array = values_from_constraint.pop() 381 | invalid_array = [] 382 | for value in valid_array: 383 | python_type_of_value = type(value) 384 | json_type_of_value = json_type_name_of_python_type(python_type_of_value) 385 | invalid_value = get_invalid_value_from_constraint( 386 | values_from_constraint=[value], 387 | value_type=json_type_of_value, 388 | ) 389 | invalid_array.append(invalid_value) 390 | return invalid_array 391 | 392 | invalid_values = 2 * values_from_constraint 393 | invalid_value = invalid_values.pop() 394 | if value_type in ["integer", "number"]: 395 | for value in invalid_values: 396 | invalid_value = abs(invalid_value) + abs(value) 397 | if not invalid_value: 398 | invalid_value += 1 399 | return invalid_value 400 | for value in invalid_values: 401 | invalid_value = invalid_value + value 402 | # None for empty string 403 | return invalid_value if invalid_value else None 404 | 405 | 406 | def get_invalid_value_from_enum(values: List[Any], value_type: str) -> Any: 407 | """Return a value not in the enum by combining the enum values.""" 408 | if value_type == "string": 409 | invalid_value: Any = "" 410 | elif value_type in ["integer", "number"]: 411 | invalid_value = 0 412 | elif value_type == "array": 413 | invalid_value = [] 414 | elif value_type == "object": 415 | # force creation of a new object since we will be modifying it 416 | invalid_value = {**values[0]} 417 | else: 418 | logger.warning(f"Cannot invalidate enum value with type {value_type}") 419 | return None 420 | for value in values: 421 | # repeat each addition to ensure single-item enums are invalidated 422 | if value_type in ["integer", "number"]: 423 | invalid_value += abs(value) + abs(value) 424 | if value_type == "string": 425 | invalid_value += value + value 426 | if value_type == "array": 427 | invalid_value.extend(value) 428 | invalid_value.extend(value) 429 | # objects are a special case, since they must be of the same type / class 430 | # invalid_value.update(value) will end up with the last value in the list, 431 | # which is a valid value, so another approach is needed 432 | if value_type == "object": 433 | for key in invalid_value.keys(): 434 | invalid_value[key] = value.get(key) 435 | if invalid_value not in values: 436 | return invalid_value 437 | return invalid_value 438 | 439 | 440 | def get_value_out_of_bounds(value_schema: Dict[str, Any], current_value: Any) -> Any: 441 | """ 442 | Return a value just outside the value or length range if specified in the 443 | provided schema, otherwise None is returned. 444 | """ 445 | value_type = value_schema["type"] 446 | 447 | if value_type in ["integer", "number"]: 448 | if (minimum := value_schema.get("minimum")) is not None: 449 | if value_schema.get("exclusiveMinimum") is True: 450 | return minimum 451 | return minimum - 1 452 | if (maximum := value_schema.get("maximum")) is not None: 453 | if value_schema.get("exclusiveMaximum") is True: 454 | return maximum 455 | return maximum + 1 456 | # In OAS 3.1 exclusveMinimum/Maximum are no longer boolean but instead integer 457 | # or number and minimum/maximum should not be used with exclusiveMinimum/Maximum 458 | if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None: 459 | return exclusive_minimum 460 | if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None: 461 | return exclusive_maximum 462 | if value_type == "array": 463 | if minimum := value_schema.get("minItems", 0) > 0: 464 | return current_value[0 : minimum - 1] 465 | if (maximum := value_schema.get("maxItems")) is not None: 466 | invalid_value = current_value if current_value else ["x"] 467 | while len(invalid_value) <= maximum: 468 | invalid_value.append(choice(invalid_value)) 469 | return invalid_value 470 | if value_type == "string": 471 | # if there is a minimum length, send 1 character less 472 | if minimum := value_schema.get("minLength", 0): 473 | return current_value[0 : minimum - 1] 474 | # if there is a maximum length, send 1 character more 475 | if maximum := value_schema.get("maxLength"): 476 | invalid_value = current_value if current_value else "x" 477 | # add random characters from the current value to prevent adding new characters 478 | while len(invalid_value) <= maximum: 479 | invalid_value += choice(invalid_value) 480 | return invalid_value 481 | return None 482 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-function-docstring, missing-module-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 OpenApiLibCore import openapi_libcore 10 | 11 | ROOT = pathlib.Path(__file__).parent.resolve().as_posix() 12 | VERSION = version("robotframework-openapi-libcore") 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/OpenApiLibCore", 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 | json_file = f"{ROOT}/tests/files/petstore_openapi.json" 90 | source = f"OpenApiLibCore::{json_file}" 91 | target = f"{ROOT}/docs/openapi_libcore.html" 92 | cmd = [ 93 | "python", 94 | "-m", 95 | "robot.libdoc", 96 | f"-v {VERSION}", 97 | source, 98 | target, 99 | ] 100 | subprocess.run(" ".join(cmd), shell=True, check=False) 101 | 102 | 103 | @task 104 | def libspec(context: Context) -> None: 105 | json_file = f"{ROOT}/tests/files/petstore_openapi.json" 106 | source = f"OpenApiLibCore::{json_file}" 107 | target = f"{ROOT}/src/OpenApiLibCore/openapi_libcore.libspec" 108 | cmd = [ 109 | "python", 110 | "-m", 111 | "robot.libdoc", 112 | f"-v {VERSION}", 113 | source, 114 | target, 115 | ] 116 | subprocess.run(" ".join(cmd), shell=True, check=False) 117 | 118 | 119 | @task 120 | def readme(context: Context) -> None: 121 | front_matter = """---\n---\n""" 122 | with open(f"{ROOT}/docs/README.md", "w", encoding="utf-8") as readme_file: 123 | doc_string = openapi_libcore.__doc__ 124 | readme_file.write(front_matter) 125 | readme_file.write(str(doc_string).replace("\\", "\\\\").replace("\\\\*", "\\*")) 126 | 127 | 128 | @task(format_code, libdoc, libspec, readme) 129 | def build(context: Context) -> None: 130 | subprocess.run("poetry build", shell=True, check=False) 131 | -------------------------------------------------------------------------------- /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 | "version": "1.0.6" 7 | }, 8 | "paths": { 9 | "/pet": { 10 | "post": { 11 | "operationId": "addPet", 12 | "requestBody": { 13 | "description": "Create a new pet in the store", 14 | "content": { 15 | "application/json": { 16 | "schema": { 17 | "$ref": "#/components/schemas/Pet" 18 | } 19 | } 20 | }, 21 | "required": true 22 | }, 23 | "responses": { 24 | "200": { 25 | "description": "Successful operation", 26 | "content": { 27 | "application/json": { 28 | "schema": { 29 | "$ref": "#/components/schemas/Pet" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | }, 38 | "components": { 39 | "schemas": { 40 | "Pet": { 41 | "required": [ 42 | "name" 43 | ], 44 | "type": "object", 45 | "properties": { 46 | "id": { 47 | "type": "integer", 48 | "format": "int64", 49 | "example": 10 50 | }, 51 | "name": { 52 | "type": "string", 53 | "example": "doggie" 54 | }, 55 | "status": { 56 | "type": "string", 57 | "description": "pet status in the store", 58 | "enum": [ 59 | "available", 60 | "pending", 61 | "sold" 62 | ] 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /tests/files/schema_with_allof.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Media API 4 | description: Hypermedia API used to demonstrate composite schema definition using OpenAPI 3.x. 5 | version: 1.0.0 6 | termsOfService: none 7 | paths: 8 | /hypermedia/{title}: 9 | get: 10 | summary: Get hypermedia information about a specific title 11 | description: get information about a media, including metadata, reviews, images, editor's cut and trailers, etc. 12 | Demonstrates in-line composite schema definition of the response body. 13 | operationId: getHypermedia 14 | parameters: 15 | - name: title 16 | in: path 17 | required: true 18 | description: the unique title of the media. 19 | schema: 20 | type: string 21 | responses: 22 | '200': 23 | description: the media title has been found 24 | content: 25 | application/json: 26 | schema: 27 | # Strategy that references an intermediate composite schema 28 | #$ref: '#/components/schemas/Hypermedia' 29 | # Strategy that defines a composite schema in-line 30 | allOf: 31 | - $ref: '#/components/schemas/MediaMetadata' 32 | - $ref: '#/components/schemas/Images' 33 | - $ref: '#/components/schemas/Videos' 34 | - $ref: '#/components/schemas/Reviews' 35 | - $ref: '#/components/schemas/Tags' 36 | example: 37 | /hypermedia: 38 | post: 39 | summary: Create hypermedia resource. 40 | description: The initial create can be added upon once the work is established. 41 | Demonstrates in-line composite schema definition of the request body. 42 | operationId: createHypermedia 43 | requestBody: 44 | description: this is a description 45 | content: 46 | application/json: 47 | schema: 48 | allOf: 49 | - $ref: '#/components/schemas/MediaMetadata' 50 | - $ref: '#/components/schemas/Tags' 51 | responses: 52 | '200': 53 | description: this is a description 54 | components: 55 | schemas: 56 | MediaMetadata: 57 | title: mediaMetadata 58 | type: object 59 | description: Facts about the media work in its entirety. 60 | properties: 61 | isan: 62 | description: The International Standard Audiovisual Number unique identifier. 63 | type: string 64 | title: 65 | description: The media title. 66 | type: string 67 | format: name 68 | published: 69 | description: The publication date of the work. 70 | type: string 71 | format: date 72 | Images: 73 | title: images 74 | type: object 75 | description: A collection of still images related to the work 76 | properties: 77 | images: 78 | type: array 79 | items: 80 | $ref: '#/components/schemas/Image' 81 | additionalProperties: false 82 | Videos: 83 | title: video 84 | type: object 85 | description: A collection of videos related to the work. 86 | properties: 87 | videos: 88 | type: array 89 | items: 90 | $ref: '#/components/schemas/Video' 91 | Reviews: 92 | title: reviews 93 | type: object 94 | description: The reviews of the work. 95 | properties: 96 | reviews: 97 | type: array 98 | items: 99 | type: object 100 | properties: 101 | alias: 102 | type: string 103 | rating: 104 | type: integer 105 | enum: 106 | - 1 107 | - 2 108 | - 3 109 | - 4 110 | - 5 111 | comment: 112 | type: string 113 | Tags: 114 | title: tags 115 | type: object 116 | description: Category tags assigned to the work, for searching purposes. 117 | properties: 118 | tags: 119 | type: array 120 | items: 121 | type: string 122 | # Including Hypermedia intermediate schema to play with composite schema definitions. 123 | Hypermedia: 124 | title: hypermedia 125 | type: object 126 | allOf: 127 | - $ref: '#/components/schemas/MediaMetadata' 128 | - $ref: '#/components/schemas/Images' 129 | #- $ref: '#/components/schemas/Video' 130 | #- $ref: '#/components/schemas/Tags' 131 | #- $ref: '#/components/schemas/Reviews' 132 | Image: 133 | title: image 134 | type: object 135 | properties: 136 | size: 137 | type: integer 138 | dimensions: 139 | type: object 140 | properties: 141 | width: 142 | type: integer 143 | height: 144 | type: integer 145 | image: 146 | type: string 147 | format: binary 148 | additionalProperties: false 149 | Video: 150 | title: video 151 | type: object 152 | properties: 153 | type: 154 | type: string 155 | enum: 156 | - trailer 157 | - full-length 158 | - editors-cut 159 | - clip 160 | length: 161 | type: integer 162 | description: 163 | type: string 164 | yearPublished: 165 | type: string 166 | pattern: '[0-9]{4}' 167 | video: 168 | type: string 169 | format: binary 170 | additionalProperties: false -------------------------------------------------------------------------------- /tests/files/schema_with_readOnly.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "Test readOnly", 5 | "version": "1.0.0" 6 | }, 7 | "servers": [ 8 | ], 9 | "paths": { 10 | "/api/location": { 11 | "post": { 12 | "tags": [ 13 | "Location" 14 | ], 15 | "summary": "Create location", 16 | "operationId": "createLocation", 17 | "requestBody": { 18 | "content": { 19 | "application/json": { 20 | "schema": { 21 | "$ref": "#/components/schemas/CreateLocationModel" 22 | } 23 | } 24 | }, 25 | "required": true 26 | }, 27 | "responses": { 28 | "400": { 29 | "description": "Bad Request", 30 | "content": { 31 | "application/json": { 32 | "schema": { 33 | "$ref": "#/components/schemas/CreateLocationResultModel" 34 | } 35 | } 36 | } 37 | }, 38 | "201": { 39 | "description": "Created", 40 | "content": { 41 | "application/json": { 42 | "schema": { 43 | "$ref": "#/components/schemas/CreateLocationResultModel" 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | }, 52 | "components": { 53 | "schemas": { 54 | "CreateLocationModel": { 55 | "required": [ 56 | "locationId", 57 | "timezone" 58 | ], 59 | "type": "object", 60 | "properties": { 61 | "id": { 62 | "type": "string", 63 | "readOnly": true 64 | }, 65 | "locationId": { 66 | "type": "integer", 67 | "description": "Location must not exist yet", 68 | "format": "int64" 69 | }, 70 | "timezone": { 71 | "type": "string" 72 | } 73 | } 74 | }, 75 | "CreateLocationResultModel": { 76 | "type": "object", 77 | "properties": { 78 | "succeeded": { 79 | "type": "boolean" 80 | }, 81 | "failures": { 82 | "type": "array", 83 | "items": { 84 | "$ref": "#/components/schemas/ErrorModelCreateLocationErrorType" 85 | } 86 | }, 87 | "result": { 88 | "$ref": "#/components/schemas/CreateLocationModel" 89 | } 90 | } 91 | }, 92 | "ErrorModelCreateLocationErrorType": { 93 | "type": "object", 94 | "properties": { 95 | "key": { 96 | "type": "string", 97 | "enum": [ 98 | "MANDATORY_FIELD", 99 | "LOCATION_ALREADY_EXISTS" 100 | ] 101 | }, 102 | "fieldName": { 103 | "type": "string" 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /tests/rf_cli.args: -------------------------------------------------------------------------------- 1 | --loglevel TRACE:DEBUG 2 | --listener RobotStackTracer -------------------------------------------------------------------------------- /tests/server/testserver.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-module-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.model_dump(), 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: 318 | raise HTTPException(status_code=404, detail="Employee not found") 319 | stored_employee_data = EMPLOYEES[employee_id] 320 | employee_update_data = employee.model_dump(exclude_unset=True) 321 | 322 | wagegroup_id = employee_update_data.get("wagegroup_id", None) 323 | if wagegroup_id and wagegroup_id not in WAGE_GROUPS: 324 | raise HTTPException( 325 | status_code=451, detail=f"Wage group with id {wagegroup_id} does not exist." 326 | ) 327 | 328 | today = datetime.date.today() 329 | if date_of_birth := employee_update_data.get("date_of_birth", None): 330 | employee_age = today - date_of_birth 331 | if employee_age.days < 18 * 365: 332 | raise HTTPException( 333 | status_code=403, detail="An employee must be at least 18 years old." 334 | ) 335 | 336 | updated_employee = stored_employee_data.model_copy(update=employee_update_data) 337 | EMPLOYEES[employee_id] = updated_employee 338 | return updated_employee 339 | 340 | 341 | @app.get("/available_employees", status_code=200, response_model=List[EmployeeDetails]) 342 | def get_available_employees(weekday: WeekDay = Query(...)) -> List[EmployeeDetails]: 343 | return [ 344 | e for e in EMPLOYEES.values() if getattr(e, "parttime_day", None) != weekday 345 | ] 346 | -------------------------------------------------------------------------------- /tests/suites/test_authorized_request.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${ROOT}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${ROOT}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Authorized Request Without Authorization 16 | ${response}= Authorized Request url=${ORIGIN}/ method=get 17 | Should Be Equal As Integers ${response.status_code} 200 18 | 19 | # Test Authorized Request With Username And Password 20 | # ${response}= Authorized Request url=${origin}/ method=get 21 | # Should Be Equal As Integers ${response.status_code} 200 22 | 23 | # Test Authorized Request With Requests Auth Object 24 | # ${response}= Authorized Request url=${origin}/ method=get 25 | # Should Be Equal As Integers ${response.status_code} 200 26 | 27 | # Test Authorized Request With Authorization Token 28 | # ${response}= Authorized Request url=${origin}/ method=get 29 | # Should Be Equal As Integers ${response.status_code} 200 30 | -------------------------------------------------------------------------------- /tests/suites/test_default_id_property_name.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Valid Id For Endpoint Returns Id For Id Defined In ID_MAPPING 16 | ${id}= Get Valid Id For Endpoint endpoint=/wagegroups method=post 17 | Length Should Be ${id} 36 18 | 19 | Test Get Valid Id For Endpoint Raises For Resource With Non-default Id 20 | Run Keyword And Expect Error Failed to get a valid id using* 21 | ... Get Valid Id For Endpoint endpoint=/available_employees method=get 22 | -------------------------------------------------------------------------------- /tests/suites/test_ensure_in_use.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | ... default_id_property_name=identification 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Ensure In Use With Single Id In Url 17 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=get 18 | Ensure In Use url=${url} resource_relation=${ID_REFERENCE} 19 | 20 | # Test Ensure In Use With Multiple Ids In Url 21 | # ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=get 22 | # Ensure In Use url=${url} resource_relation=${ID_REFERENCE} 23 | 24 | Test Ensure In Use Raises When No Id In Url 25 | ${url}= Get Valid Url endpoint=/wagegroups method=post 26 | Run Keyword And Expect Error ValueError: The provided url* 27 | ... Ensure In Use url=${url} resource_relation=${ID_REFERENCE} 28 | 29 | Test Ensure In Use Raises When Post Fails 30 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=get 31 | Run Keyword And Expect Error HTTPError: 405 Client Error* 32 | ... Ensure In Use url=${url} resource_relation=${INVALID_ID_REFERENCE} 33 | -------------------------------------------------------------------------------- /tests/suites/test_extra_headers.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Variables ${root}/tests/variables.py 3 | Library OpenApiLibCore 4 | ... source=${ORIGIN}/openapi.json 5 | ... origin=${ORIGIN} 6 | ... base_path=${EMPTY} 7 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 8 | ... security_token=secret 9 | ... extra_headers=${EXTRA_HEADERS} 10 | 11 | 12 | *** Variables *** 13 | ${ORIGIN}= http://localhost:8000 14 | 15 | 16 | *** Test Cases *** 17 | Test Authorized Request With Security Token And Extra Headers 18 | ${request_data}= Get Request Data endpoint=/secret_message method=get 19 | ${response}= Authorized Request 20 | ... url=${ORIGIN}/secret_message method=get headers=${request_data.headers} 21 | Should Be Equal As Integers ${response.status_code} 200 22 | Should Be True $EXTRA_HEADERS.items() <= $response.request.headers.items() 23 | ${TOKEN_HEADER}= Create Dictionary Authorization=secret 24 | Should Be True $TOKEN_HEADER.items() <= $response.request.headers.items() 25 | -------------------------------------------------------------------------------- /tests/suites/test_faker_locale.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${root}/tests/files/schema_with_allof.yaml 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... faker_locale=zh_CN 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Request Data For Schema With allOf 16 | ${request_data}= Get Request Data endpoint=/hypermedia method=post 17 | # this regex should match all characters in the simplified Chinese character set 18 | Should Match Regexp ${request_data.dto.title} ^[\u4E00-\u9FA5]+$ 19 | -------------------------------------------------------------------------------- /tests/suites/test_get_ids_for_endpoint.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | ... default_id_property_name=identification 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Ids From Url That Returns Single Resource 17 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=post 18 | ${ids}= Get Ids From Url url=${url} 19 | Length Should Be item=${ids} length=1 20 | 21 | Test Get Ids From Url That Returns List Of Resources 22 | # Create an Employee resource so the returned list is not empty 23 | Get Valid Url endpoint=/employees/{employee_id} method=get 24 | ${url}= Get Valid Url endpoint=/employees method=get 25 | ${ids}= Get Ids From Url url=${url} 26 | ${number_of_ids}= Get Length item=${ids} 27 | Should Be True $number_of_ids > 0 28 | 29 | # Test Get Ids From Url That Returns Object Without Id But With Items 30 | -------------------------------------------------------------------------------- /tests/suites/test_get_invalid_json_data.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OperatingSystem 3 | Library OpenApiLibCore 4 | ... source=${ORIGIN}/openapi.json 5 | ... origin=${ORIGIN} 6 | ... base_path=${EMPTY} 7 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Invalid Json Data Raises If Data Cannot Be Invalidated 17 | ${request_data}= Get Request Data endpoint=/ method=get 18 | Run Keyword And Expect Error ValueError: Failed to invalidate: no data_relations and empty schema. 19 | ... Get Invalid Json Data 20 | ... url=none 21 | ... method=none 22 | ... status_code=999 23 | ... request_data=${request_data} 24 | 25 | ${request_data}= Get Request Data endpoint=/employees method=post 26 | Run Keyword And Expect Error ValueError: No property can be invalidated to cause status_code 999 27 | ... Get Invalid Json Data 28 | ... url=none 29 | ... method=none 30 | ... status_code=999 31 | ... request_data=${request_data} 32 | 33 | Test Get Invalid Json Data Based On Schema 34 | ${request_data}= Get Request Data endpoint=/events/ method=post 35 | Should Be Empty ${request_data.dto.get_relations_for_error_code(422)} 36 | ${invalid_json}= Get Invalid Json Data 37 | ... url=none 38 | ... method=none 39 | ... status_code=422 40 | ... request_data=${request_data} 41 | Should Not Be Equal ${invalid_json} ${request_data.dto} 42 | ${response}= Authorized Request 43 | ... url=${ORIGIN}/events/ method=post json_data=${invalid_json} 44 | Should Be Equal As Integers ${response.status_code} 422 45 | 46 | Test Get Invalid Json Data For UniquePropertyValueConstraint 47 | ${request_data}= Get Request Data endpoint=/wagegroups method=post 48 | ${invalid_json}= Get Invalid Json Data 49 | ... url=${ORIGIN}/wagegroups 50 | ... method=post 51 | ... status_code=418 52 | ... request_data=${request_data} 53 | Should Not Be Equal ${invalid_json} ${request_data.dto} 54 | ${response}= Authorized Request 55 | ... url=${ORIGIN}/wagegroups method=post json_data=${invalid_json} 56 | Should Be Equal As Integers ${response.status_code} 418 57 | 58 | Test Get Invalid Json Data For IdReference 59 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=delete 60 | ${request_data}= Get Request Data endpoint=/wagegroups/{wagegroup_id} method=delete 61 | ${invalid_json}= Get Invalid Json Data 62 | ... url=${url} 63 | ... method=delete 64 | ... status_code=406 65 | ... request_data=${request_data} 66 | Should Not Be Equal ${invalid_json} ${request_data.dto} 67 | ${response}= Authorized Request 68 | ... url=${url} method=delete json_data=${invalid_json} 69 | Should Be Equal As Integers ${response.status_code} 406 70 | 71 | Test Get Invalid Json Data For IdDependency 72 | ${url}= Get Valid Url endpoint=/employees method=post 73 | ${request_data}= Get Request Data endpoint=/employees method=post 74 | ${invalid_json}= Get Invalid Json Data 75 | ... url=${url} 76 | ... method=post 77 | ... status_code=451 78 | ... request_data=${request_data} 79 | Should Not Be Equal ${invalid_json} ${request_data.dto} 80 | ${response}= Authorized Request 81 | ... url=${url} method=post json_data=${invalid_json} 82 | Should Be Equal As Integers ${response.status_code} 451 83 | 84 | Test Get Invalid Json Data For Dto With Other Relations 85 | ${request_data}= Get Request Data endpoint=/employees method=post 86 | ${invalid_json}= Get Invalid Json Data 87 | ... url=${ORIGIN}/employees 88 | ... method=post 89 | ... status_code=403 90 | ... request_data=${request_data} 91 | Should Not Be Equal ${invalid_json} ${request_data.dto} 92 | ${response}= Authorized Request 93 | ... url=${ORIGIN}/employees method=post json_data=${invalid_json} 94 | Should Be Equal As Integers ${response.status_code} 403 95 | 96 | Test Get Invalid Json Data Can Invalidate Missing Optional Parameters 97 | ${url}= Get Valid Url endpoint=/employees/{emplyee_id} method=patch 98 | ${request_data}= Get Request Data endpoint=/employees/{emplyee_id} method=patch 99 | Evaluate ${request_data.dto.__dict__.clear()} is None 100 | ${invalid_json}= Get Invalid Json Data 101 | ... url=${url} 102 | ... method=patch 103 | ... status_code=422 104 | ... request_data=${request_data} 105 | Should Not Be Equal ${invalid_json} ${request_data.dto.as_dict()} 106 | ${response}= Authorized Request 107 | ... url=${url} method=patch json_data=${invalid_json} 108 | ${expected_status_codes}= Create List ${403} ${422} ${451} 109 | Should Contain ${expected_status_codes} ${response.status_code} 110 | -------------------------------------------------------------------------------- /tests/suites/test_get_invalidated_parameters.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library String 3 | Library OpenApiLibCore 4 | ... source=${ORIGIN}/openapi.json 5 | ... origin=${ORIGIN} 6 | ... base_path=${EMPTY} 7 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Invalidated Parameters Raises For Empty Parameters List 17 | ${request_data}= Get Request Data endpoint=/secret_message method=get 18 | Evaluate ${request_data.parameters.clear()} is None 19 | Run Keyword And Expect Error ValueError: No params or headers to invalidate. 20 | ... Get Invalidated Parameters 21 | ... status_code=422 22 | ... request_data=${request_data} 23 | 24 | Test Get Invalidated Parameters Raises For Mismatched Parameters List 25 | ${request_data}= Get Request Data endpoint=/secret_message method=get 26 | Evaluate ${request_data.parameters.clear()} is None 27 | Evaluate ${request_data.parameters.append({"name": "dummy"})} is None 28 | Run Keyword And Expect Error ValueError: No parameter can be changed to cause status_code 401. 29 | ... Get Invalidated Parameters 30 | ... status_code=401 31 | ... request_data=${request_data} 32 | 33 | Test Get Invalidated Parameters Raises For Status Code That Cannot Be Invalidated 34 | ${request_data}= Get Request Data endpoint=/secret_message method=get 35 | Run Keyword And Expect Error ValueError: No relations to cause status_code 200 found. 36 | ... Get Invalidated Parameters 37 | ... status_code=200 38 | ... request_data=${request_data} 39 | 40 | Test Get Invalidated Parameters Raises For Headers That Cannot Be Invalidated 41 | ${request_data}= Get Request Data endpoint=/ method=get 42 | Run Keyword And Expect Error ValueError: None of the query parameters and headers can be invalidated. 43 | ... Get Invalidated Parameters 44 | ... status_code=422 45 | ... request_data=${request_data} 46 | 47 | Test Get Invalidated Parameters For Invalid Propery Default Response 48 | ${request_data}= Get Request Data endpoint=/secret_message method=get 49 | ${invalidated}= Get Invalidated Parameters 50 | ... status_code=422 51 | ... request_data=${request_data} 52 | ${secret_code}= Set Variable ${invalidated[1].get("secret-code")} 53 | Length Should Be ${secret_code} 36 54 | 55 | Test Get Invalidated Parameters For PropertyValueConstraint 56 | ${request_data}= Get Request Data endpoint=/secret_message method=get 57 | ${invalidated}= Get Invalidated Parameters 58 | ... status_code=401 59 | ... request_data=${request_data} 60 | ${secret_code}= Set Variable ${invalidated[1].get("secret-code")} 61 | Should Be True int($secret_code) != 42 62 | 63 | ${request_data}= Get Request Data endpoint=/secret_message method=get 64 | ${invalidated}= Get Invalidated Parameters 65 | ... status_code=403 66 | ... request_data=${request_data} 67 | ${seal}= Set Variable ${invalidated[1].get("seal")} 68 | Should Not Be Equal ${seal} ${NONE} 69 | 70 | Test Get Invalidated Parameters Adds Optional Parameter If Not Provided 71 | ${request_data}= Get Request Data endpoint=/secret_message method=get 72 | Evaluate ${request_data.headers.clear()} is None 73 | ${invalidated}= Get Invalidated Parameters 74 | ... status_code=422 75 | ... request_data=${request_data} 76 | ${headers}= Set Variable ${invalidated[1]} 77 | Length Should Be ${headers} 1 78 | 79 | ${request_data}= Get Request Data endpoint=/energy_label/{zipcode}/{home_number} method=get 80 | Evaluate ${request_data.params.clear()} is None 81 | ${invalidated}= Get Invalidated Parameters 82 | ... status_code=422 83 | ... request_data=${request_data} 84 | ${extension}= Set Variable ${invalidated[0].get("extension")} 85 | Length Should Be ${extension} 0 86 | -------------------------------------------------------------------------------- /tests/suites/test_get_invalidated_url.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | ... default_id_property_name=identification 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Invalidated Url Raises For Endpoint Not In OpenApi Document 17 | Run Keyword And Expect Error ValueError: /dummy not found in paths section of the OpenAPI document. 18 | ... Get Invalidated Url valid_url=${ORIGIN}/dummy 19 | 20 | Test Get Invalidated Url Raises For Endpoint That Cannot Be Invalidated 21 | Run Keyword And Expect Error ValueError: /employees could not be invalidated. 22 | ... Get Invalidated Url valid_url=${ORIGIN}/employees 23 | 24 | Test Get Invalidated Url For Endpoint Ending With Path Id 25 | ${url}= Get Valid Url endpoint=/employees/{employee_id} method=get 26 | ${invalidated}= Get Invalidated Url valid_url=${url} 27 | Should Not Be Equal ${url} ${invalidated} 28 | Should Start With ${invalidated} http://localhost:8000/employees/ 29 | 30 | Test Get Invalidated Url For Endpoint Not Ending With Path Id 31 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id}/employees method=get 32 | ${invalidated}= Get Invalidated Url valid_url=${url} 33 | Should Not Be Equal ${url} ${invalidated} 34 | Should Start With ${invalidated} http://localhost:8000/wagegroups/ 35 | Should End With ${invalidated} /employees 36 | 37 | Test Get Invalidated Url For Endpoint With Multiple Path Ids 38 | ${url}= Get Valid Url endpoint=/energy_label/{zipcode}/{home_number} method=get 39 | ${invalidated}= Get Invalidated Url valid_url=${url} 40 | Should Not Be Equal ${url} ${invalidated} 41 | Should Start With ${invalidated} http://localhost:8000/energy_label/1111AA/ 42 | -------------------------------------------------------------------------------- /tests/suites/test_get_json_data_for_dto_class.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Json Data For Dto Class With DefaultDto 16 | ${request_data}= Get Request Data endpoint=/wagegroups method=post 17 | Get Json Data For Dto Class 18 | ... schema=${request_data.dto_schema} 19 | ... dto_class=${DEFAULT_DTO} 20 | ... operation_id=dummy 21 | 22 | Test Get Json Data For Dto Class With IGNORE Constrained 23 | ${request_data}= Get Request Data endpoint=/wagegroups method=post 24 | Get Json Data For Dto Class 25 | ... schema=${request_data.dto_schema} 26 | ... dto_class=${WAGEGROUP_DTO} 27 | ... operation_id=dummy 28 | 29 | Test Get Json Data For Dto Class With Single DependantId 30 | ${request_data}= Get Request Data endpoint=/employees method=post 31 | Get Json Data For Dto Class 32 | ... schema=${request_data.dto_schema} 33 | ... dto_class=${EMPLOYEE_DTO} 34 | ... operation_id=dummy 35 | 36 | # Test Get Json Data For Dto Class With Multiple DependantIds 37 | # ${request_data}= Get Request Data endpoint=/employees method=post 38 | # Get Json Data For Dto Class 39 | # ... schema=${request_data.dto_schema} 40 | # ... dto_class=${EMPLOYEE_DTO} 41 | # ... operation_id=dummy 42 | 43 | Test Get Json Data For Dto Class With Array And Object 44 | ${request_data}= Get Request Data endpoint=/events/ method=post 45 | Get Json Data For Dto Class 46 | ... schema=${request_data.dto_schema} 47 | ... dto_class=${DEFAULT_DTO} 48 | ... operation_id=dummy 49 | -------------------------------------------------------------------------------- /tests/suites/test_get_json_data_with_conflict.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Json Data With Conflict Raises For No UniquePropertyValueConstraint 16 | ${url}= Get Valid Url endpoint=/wagegroups method=post 17 | Run Keyword And Expect Error ValueError: No UniquePropertyValueConstraint* 18 | ... Get Json Data With Conflict 19 | ... url=${url} 20 | ... method=post 21 | ... dto=${DEFAULT_DTO()} 22 | ... conflict_status_code=418 23 | 24 | Test Get Json Data With Conflict For Post Request 25 | ${url}= Get Valid Url endpoint=/wagegroups method=post 26 | ${request_data}= Get Request Data endpoint=/wagegroups method=post 27 | ${invalid_data}= Get Json Data With Conflict 28 | ... url=${url} 29 | ... method=post 30 | ... dto=${request_data.dto} 31 | ... conflict_status_code=418 32 | Should Not Be Empty ${invalid_data} 33 | 34 | Test Get Json Data With Conflict For Put Request 35 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=put 36 | ${request_data}= Get Request Data endpoint=/wagegroups/{wagegroup_id} method=put 37 | ${invalid_json}= Get Json Data With Conflict 38 | ... url=${url} 39 | ... method=put 40 | ... dto=${request_data.dto} 41 | ... conflict_status_code=418 42 | ${response}= Authorized Request 43 | ... url=${url} method=put json_data=${invalid_json} 44 | Should Be Equal As Integers ${response.status_code} 418 45 | 46 | # Test Get Json Data With Conflict For Patch Request 47 | # ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=put 48 | # ${request_data}= Get Request Data endpoint=/wagegroups/{wagegroup_id} method=put 49 | # ${invalid_json}= Get Json Data With Conflict 50 | # ... url=${url} 51 | # ... method=put 52 | # ... dto=${request_data.dto} 53 | # ... conflict_status_code=418 54 | # ${response}= Authorized Request 55 | # ... url=${url} method=put json_data=${invalid_json} 56 | # Should Be Equal As Integers ${response.status_code} 418 57 | -------------------------------------------------------------------------------- /tests/suites/test_get_parameterized_endpoint_from_url.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | ... default_id_property_name=identification 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Parameterized Endpoint From Url Raises For Invalid Endpoint 17 | Run KeyWord And Expect Error ValueError: /dummy not found in paths section of the OpenAPI document. 18 | ... Get Parameterized Endpoint From Url url=${ORIGIN}/dummy 19 | 20 | Test Get Parameterized Endpoint From Url With No Id 21 | ${url}= Get Valid Url endpoint=/events/ method=get 22 | ${endpoint}= Get Parameterized Endpoint From Url url=${url} 23 | Should Be Equal ${endpoint} /events/ 24 | 25 | Test Get Parameterized Endpoint From Url With Single Id 26 | ${url}= Get Valid Url endpoint=/employees/{employee_id} method=get 27 | ${endpoint}= Get Parameterized Endpoint From Url url=${url} 28 | Should Be Equal ${endpoint} /employees/{employee_id} 29 | 30 | # Test Get Parameterized Endpoint From Url With Multiple Ids 31 | # ${url}= Get Valid Url endpoint=/events/ method=get 32 | # Get Parameterized Endpoint From Url url=${url} 33 | -------------------------------------------------------------------------------- /tests/suites/test_get_request_data.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Request Data For Invalid Method On Endpoint 16 | ${request_data}= Get Request Data endpoint=/events/ method=delete 17 | ${dict}= Create Dictionary 18 | ${list}= Create List 19 | Should Be Equal ${request_data.dto} ${DEFAULT_DTO()} 20 | Should Be Equal ${request_data.dto_schema} ${dict} 21 | Should Be Equal ${request_data.parameters} ${list} 22 | Should Be Equal ${request_data.params} ${dict} 23 | Should Be Equal ${request_data.headers} ${dict} 24 | 25 | Test Get Request Data For Endpoint With RequestBody 26 | ${request_data}= Get Request Data endpoint=/employees method=post 27 | ${dict}= Create Dictionary 28 | ${list}= Create List 29 | ${birthdays}= Create List 1970-07-07 1980-08-08 1990-09-09 30 | ${parttime_days}= Create List Monday Tuesday Wednesday Thursday Friday ${NONE} 31 | Length Should Be ${request_data.dto.name} 36 32 | Length Should Be ${request_data.dto.wagegroup_id} 36 33 | Should Contain ${birthdays} ${request_data.dto.date_of_birth} 34 | Should Contain ${parttime_days} ${request_data.dto.parttime_day} 35 | Should Not Be Empty ${request_data.dto_schema} 36 | Should Be Equal ${request_data.parameters} ${list} 37 | Should Be Equal ${request_data.params} ${dict} 38 | Should Be Equal ${request_data.headers} ${dict} 39 | 40 | Test Get Request Data For Endpoint Without RequestBody But With DtoClass 41 | ${request_data}= Get Request Data endpoint=/wagegroups/{wagegroup_id} method=delete 42 | ${dict}= Create Dictionary 43 | Should Be Equal As Strings ${request_data.dto} delete_wagegroup_wagegroups__wagegroup_id__delete() 44 | Should Be Equal ${request_data.dto_schema} ${dict} 45 | Should Not Be Empty ${request_data.parameters} 46 | Should Be Equal ${request_data.params} ${dict} 47 | Should Be Equal ${request_data.headers} ${dict} 48 | 49 | # Test Get Request Data For Endpoint With RequestBody With Only Ignored Properties 50 | # ${request_data}= Get Request Data endpoint=/wagegroups/{wagegroup_id} method=delete 51 | # ${dict}= Create Dictionary 52 | # ${list}= Create List 53 | # Should Be Equal As Strings ${request_data.dto} delete_wagegroup_wagegroups__wagegroup_id__delete() 54 | # Should Be Equal ${request_data.dto_schema} ${dict} 55 | # Should Not Be Empty ${request_data.parameters} 56 | # Should Be Equal ${request_data.params} ${dict} 57 | # Should Be Equal ${request_data.headers} ${dict} 58 | -------------------------------------------------------------------------------- /tests/suites/test_get_valid_id_for_endpoint.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | ... default_id_property_name=identification 8 | Variables ${root}/tests/variables.py 9 | 10 | 11 | *** Variables *** 12 | ${ORIGIN}= http://localhost:8000 13 | 14 | 15 | *** Test Cases *** 16 | Test Get Valid Id For Endpoint Raises For Endpoint Without Id In Path 17 | Run Keyword And Expect Error Failed to get a valid id from* 18 | ... Get Valid Id For Endpoint endpoint=/events/ method=get 19 | 20 | Test Get Valid Id For Endpoint Raises For Endpoint With No Post Operation And No Resources 21 | Run Keyword And Expect Error Failed to get a valid id using GET on* 22 | ... Get Valid Id For Endpoint endpoint=/secret_message method=get 23 | 24 | Test Get Valid Id For Endpoint Returns Id For Resource Created By Post Operation 25 | ${id}= Get Valid Id For Endpoint endpoint=/wagegroups/{wagegroup_id} method=get 26 | Length Should Be ${id} 36 27 | 28 | Test Get Valid Id For Endpoint Returns Random Id From Array Endpoint With No Post Operation 29 | ${url}= Get Valid Url endpoint=/employees method=post 30 | ${request_data}= Get Request Data endpoint=/employees method=post 31 | Authorized Request 32 | ... url=${url} 33 | ... method=post 34 | ... json_data=${request_data.get_required_properties_dict()} 35 | ${id}= Get Valid Id For Endpoint endpoint=/available_employees method=get 36 | Length Should Be ${id} 32 37 | 38 | # Test Get Valid Id For Endpoint By Href 39 | 40 | # Test Get Valid Id For Endpoint Raises For Post Operation That Returns Array 41 | -------------------------------------------------------------------------------- /tests/suites/test_get_valid_url.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Get Valid Url Raises For Invalid Endpoint 16 | Run Keyword And Expect Error ValueError: /dummy not found in paths section of the OpenAPI document. 17 | ... Get Valid Url endpoint=/dummy method=get 18 | 19 | Test Get Valid Url With Unsupported Method 20 | ${url}= Get Valid Url endpoint=/events/ method=patch 21 | Should Be Equal ${url} ${ORIGIN}/events/ 22 | 23 | Test Get Valid Url With Id 24 | ${url}= Get Valid Url endpoint=/wagegroups/{wagegroup_id} method=get 25 | Should Contain container=${url} item=${ORIGIN}/wagegroups/ 26 | 27 | Test Get Valid Url By PathPropertiesContraint 28 | ${url}= Get Valid Url endpoint=/energy_label/{zipcode}/{home_number} method=get 29 | Should Be Equal As Strings ${url} ${ORIGIN}/energy_label/1111AA/10 30 | -------------------------------------------------------------------------------- /tests/suites/test_readonly.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ROOT}/tests/files/schema_with_readOnly.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${ROOT}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${ROOT}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test ReadOnly Is Filtered From Request Data 16 | ${request_data}= Get Request Data endpoint=/api/location method=post 17 | ${json_data}= Set Variable ${request_data.dto.as_dict()} 18 | Should Not Contain ${json_data} id 19 | Should Contain ${json_data} locationId 20 | Should Contain ${json_data} timezone 21 | -------------------------------------------------------------------------------- /tests/suites/test_request_data_class.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${ORIGIN}/openapi.json 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | ... mappings_path=${root}/tests/user_implemented/custom_user_mappings.py 7 | Variables ${root}/tests/variables.py 8 | 9 | 10 | *** Variables *** 11 | ${ORIGIN}= http://localhost:8000 12 | 13 | 14 | *** Test Cases *** 15 | Test Has Optional Properties 16 | ${request_data}= Get Request Data endpoint=/employees method=get 17 | Should Be Equal ${request_data.has_optional_properties} ${FALSE} 18 | 19 | ${request_data}= Get Request Data endpoint=/employees method=post 20 | Should Be Equal ${request_data.has_optional_properties} ${TRUE} 21 | 22 | Test Has Optional Params 23 | ${request_data}= Get Request Data endpoint=/available_employees method=get 24 | Should Be Equal ${request_data.has_optional_params} ${FALSE} 25 | 26 | ${request_data}= Get Request Data endpoint=/energy_label/{zipcode}/{home_number} method=get 27 | Should Be Equal ${request_data.has_optional_params} ${TRUE} 28 | 29 | Test Has Optional Headers 30 | ${request_data}= Get Request Data endpoint=/employees method=get 31 | Should Be Equal ${request_data.has_optional_headers} ${FALSE} 32 | 33 | ${request_data}= Get Request Data endpoint=/ method=get 34 | Should Be Equal ${request_data.has_optional_headers} ${TRUE} 35 | 36 | Test Params That Can Be Invalidated 37 | ${request_data}= Get Request Data endpoint=/available_employees method=get 38 | ${params}= Set Variable ${request_data.params_that_can_be_invalidated} 39 | Should Contain ${params} weekday 40 | 41 | ${request_data}= Get Request Data endpoint=/energy_label/{zipcode}/{home_number} method=get 42 | ${params}= Set Variable ${request_data.params_that_can_be_invalidated} 43 | Should Contain ${params} extension 44 | 45 | ${request_data}= Get Request Data endpoint=/events/ method=get 46 | ${params}= Set Variable ${request_data.params_that_can_be_invalidated} 47 | Should Be Empty ${params} 48 | 49 | Test Headers That Can Be Invalidated 50 | ${request_data}= Get Request Data endpoint=/ method=get 51 | ${headers}= Set Variable ${request_data.headers_that_can_be_invalidated} 52 | Should Be Empty ${headers} 53 | 54 | ${request_data}= Get Request Data endpoint=/secret_message method=get 55 | ${headers}= Set Variable ${request_data.headers_that_can_be_invalidated} 56 | Should Contain ${headers} secret-code 57 | 58 | Test Get Required Properties Dict 59 | ${request_data}= Get Request Data endpoint=/employees method=post 60 | Should Contain ${request_data.dto.as_dict()} parttime_day 61 | Should Not Be Empty ${request_data.dto.name} 62 | ${required_properties}= Set Variable ${request_data.get_required_properties_dict()} 63 | Should Not Contain ${required_properties} parttime_day 64 | Should Contain ${required_properties} name 65 | 66 | Test Get Required Params 67 | ${request_data}= Get Request Data endpoint=/available_employees method=get 68 | Should Not Be Empty ${request_data.params.get("weekday")} 69 | ${required_params}= Set Variable ${request_data.get_required_params()} 70 | Should Contain ${required_params} weekday 71 | 72 | ${request_data}= Get Request Data endpoint=/energy_label/{zipcode}/{home_number} method=get 73 | Should Contain ${request_data.params} extension 74 | ${required_params}= Set Variable ${request_data.get_required_params()} 75 | Should Be Empty ${required_params} 76 | 77 | Test Get Required Headers 78 | ${request_data}= Get Request Data endpoint=/secret_message method=get 79 | Should Be Equal As Integers ${request_data.headers.get("secret-code")} 42 80 | ${required_headers}= Set Variable ${request_data.get_required_headers()} 81 | Should Contain ${required_headers} secret-code 82 | Should Not Contain ${required_headers} seal 83 | 84 | ${request_data}= Get Request Data endpoint=/ method=get 85 | Should Not Be Empty ${request_data.headers.get("name-from-header")} 86 | Should Not Be Empty ${request_data.headers.get("title")} 87 | ${required_headers}= Set Variable ${request_data.get_required_headers()} 88 | Should Be Empty ${required_headers} 89 | -------------------------------------------------------------------------------- /tests/suites/test_schema_variations.robot: -------------------------------------------------------------------------------- 1 | *** Settings *** 2 | Library OpenApiLibCore 3 | ... source=${root}/tests/files/schema_with_allof.yaml 4 | ... origin=${ORIGIN} 5 | ... base_path=${EMPTY} 6 | Variables ${root}/tests/variables.py 7 | 8 | 9 | *** Variables *** 10 | ${ORIGIN}= http://localhost:8000 11 | 12 | 13 | *** Test Cases *** 14 | Test Get Request Data For Schema With allOf 15 | ${request_data}= Get Request Data endpoint=/hypermedia method=post 16 | ${dict}= Create Dictionary 17 | ${list}= Create List 18 | ${list_of_dict}= Create List ${dict} 19 | Length Should Be ${request_data.dto.isan} 36 20 | Length Should Be ${request_data.dto.published} 10 21 | Should Be Equal ${request_data.dto.tags} ${list_of_dict} 22 | Length Should Be ${request_data.dto_schema} 4 23 | Length Should Be ${request_data.dto_schema.get("properties")} 4 24 | Should Be Equal ${request_data.parameters} ${list} 25 | Should Be Equal ${request_data.params} ${dict} 26 | Should Be Equal ${request_data.headers} ${dict} 27 | -------------------------------------------------------------------------------- /tests/unittests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarketSquare/robotframework-openapi-libcore/7d99f3fb5e9f3e1fc0e3eba01afaa5681f29ec08/tests/unittests/__init__.py -------------------------------------------------------------------------------- /tests/unittests/test_dto_utils.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import pathlib 3 | import sys 4 | import unittest 5 | 6 | from OpenApiLibCore import ( 7 | Dto, 8 | IdDependency, 9 | IdReference, 10 | PathPropertiesConstraint, 11 | PropertyValueConstraint, 12 | UniquePropertyValueConstraint, 13 | dto_utils, 14 | ) 15 | 16 | unittest_folder = pathlib.Path(__file__).parent.resolve() 17 | mappings_path = unittest_folder.parent / "user_implemented" / "custom_user_mappings.py" 18 | 19 | 20 | class TestDefaultDto(unittest.TestCase): 21 | def test_can_init(self): 22 | default_dto = dto_utils.DefaultDto() 23 | self.assertIsInstance(default_dto, Dto) 24 | 25 | 26 | class TestGetDtoClass(unittest.TestCase): 27 | mappings_module_name = "" 28 | 29 | @classmethod 30 | def setUpClass(cls) -> None: 31 | if mappings_path.is_file(): 32 | mappings_folder = str(mappings_path.parent) 33 | sys.path.append(mappings_folder) 34 | cls.mappings_module_name = mappings_path.stem 35 | print(f"added {mappings_folder} to path") 36 | 37 | @classmethod 38 | def tearDownClass(cls) -> None: 39 | if mappings_path.is_file(): 40 | print(f"removed {sys.path.pop()} from path") 41 | 42 | def test_no_mapping(self): 43 | get_dto_class_instance = dto_utils.get_dto_class("dummy") 44 | self.assertDictEqual(get_dto_class_instance.dto_mapping, {}) 45 | 46 | def test_valid_mapping(self): 47 | get_dto_class_instance = dto_utils.get_dto_class(self.mappings_module_name) 48 | self.assertIsInstance(get_dto_class_instance.dto_mapping, dict) 49 | self.assertGreater(len(get_dto_class_instance.dto_mapping.keys()), 0) 50 | 51 | def mapped_returns_dto_instance(self): 52 | get_dto_class_instance = dto_utils.get_dto_class(self.mappings_module_name) 53 | keys = get_dto_class_instance.dto_mapping.keys() 54 | for key in keys: 55 | self.assertIsInstance(key, tuple) 56 | self.assertEqual(len(key), 2) 57 | self.assertIsInstance( 58 | get_dto_class_instance(key), 59 | ( 60 | IdDependency, 61 | IdReference, 62 | PathPropertiesConstraint, 63 | PropertyValueConstraint, 64 | UniquePropertyValueConstraint, 65 | ), 66 | ) 67 | 68 | def unmapped_returns_defaultdto(self): 69 | get_dto_class_instance = dto_utils.get_dto_class(self.mappings_module_name) 70 | self.assertIsInstance( 71 | get_dto_class_instance(("dummy", "post")), dto_utils.DefaultDto 72 | ) 73 | 74 | 75 | if __name__ == "__main__": 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /tests/unittests/test_get_safe_key.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore.openapi_libcore import get_safe_key 5 | 6 | 7 | class TestGetSafeKey(unittest.TestCase): 8 | def test_get_safe_key(self) -> None: 9 | self.assertEqual(get_safe_key("99"), "_99") 10 | self.assertEqual(get_safe_key("date-time"), "date_time") 11 | self.assertEqual(get_safe_key("key@value"), "key_value") 12 | 13 | 14 | if __name__ == "__main__": 15 | unittest.main() 16 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarketSquare/robotframework-openapi-libcore/7d99f3fb5e9f3e1fc0e3eba01afaa5681f29ec08/tests/unittests/value_utils/__init__.py -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_get_invalid_value.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestGetInvalidValue(unittest.TestCase): 8 | def test_invalid_from_constraint(self) -> None: 9 | current_value = "irrelevant" 10 | values_from_constraints = [42] 11 | 12 | value_schema = {"type": "integer"} 13 | value = value_utils.get_invalid_value( 14 | value_schema=value_schema, 15 | current_value=current_value, 16 | values_from_constraint=values_from_constraints, 17 | ) 18 | self.assertNotIn(value, values_from_constraints) 19 | self.assertIsInstance(value, int) 20 | 21 | value_schema = {"type": "null"} 22 | value = value_utils.get_invalid_value( 23 | value_schema=value_schema, 24 | current_value=current_value, 25 | values_from_constraint=values_from_constraints, 26 | ) 27 | self.assertIsInstance(value, str) 28 | 29 | def test_invalid_from_enum(self) -> None: 30 | enum_values = [0.1, 0.3] 31 | current_value = "irrelevant" 32 | 33 | value_schema = {"type": "number", "enum": enum_values} 34 | value = value_utils.get_invalid_value( 35 | value_schema=value_schema, 36 | current_value=current_value, 37 | ) 38 | self.assertNotIn(value, enum_values) 39 | self.assertIsInstance(value, float) 40 | 41 | value_schema = {"type": "null", "enum": enum_values} 42 | value = value_utils.get_invalid_value( 43 | value_schema=value_schema, 44 | current_value=current_value, 45 | ) 46 | self.assertIsInstance(value, str) 47 | 48 | def test_invalid_from_bounds(self) -> None: 49 | min_length = 7 50 | current_value = "long enough" 51 | value_schema = {"type": "string", "minLength": min_length} 52 | value = value_utils.get_invalid_value( 53 | value_schema=value_schema, 54 | current_value=current_value, 55 | ) 56 | self.assertLess(len(value), min_length) 57 | self.assertIsInstance(value, str) 58 | 59 | value_schema = {"type": "string", "minLength": min_length} 60 | value = value_utils.get_invalid_value( 61 | value_schema=value_schema, 62 | current_value=current_value, 63 | ) 64 | self.assertIsInstance(value, str) 65 | 66 | def test_invalid_string(self) -> None: 67 | # Type "null" is ignore in invalidation since it signals 'nullable' and the 68 | # goal is to generate an invalid value. 69 | value_schema = {"types": [{"type": "string"}, {"type": "null"}]} 70 | current_value = "irrelevant" 71 | value = value_utils.get_invalid_value( 72 | value_schema=value_schema, 73 | current_value=current_value, 74 | ) 75 | self.assertNotIsInstance(value, str) 76 | 77 | def test_only_null_in_types(self) -> None: 78 | value_schema = {"types": [{"type": "null"}]} 79 | value = value_utils.get_invalid_value( 80 | value_schema=value_schema, 81 | current_value=None, 82 | ) 83 | self.assertIsInstance(value, str) 84 | 85 | 86 | if __name__ == "__main__": 87 | unittest.main() 88 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_get_valid_value.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestGetValidValue(unittest.TestCase): 8 | def test_const(self) -> None: 9 | magic_number = 42 10 | # If "const" is in the schema, anything else is ignored 11 | schema = {"const": magic_number, "type": "string", "minimum": magic_number + 1} 12 | value = value_utils.get_valid_value(schema) 13 | self.assertEqual(value, magic_number) 14 | 15 | def test_enum(self) -> None: 16 | # Enum values are returned directly from the list, anything else is ignored 17 | schema = {"enum": ["foo", "bar"], "type": "number", "minimum": -1} 18 | value = value_utils.get_valid_value(schema) 19 | self.assertIn(value, ["foo", "bar"]) 20 | 21 | def test_bool(self) -> None: 22 | schema = {"type": "boolean"} 23 | value = value_utils.get_valid_value(schema) 24 | self.assertIsInstance(value, bool) 25 | 26 | def test_integer(self) -> None: 27 | schema = {"type": "integer"} 28 | value = value_utils.get_valid_value(schema) 29 | self.assertIsInstance(value, int) 30 | 31 | def test_number(self) -> None: 32 | schema = {"type": "number"} 33 | value = value_utils.get_valid_value(schema) 34 | self.assertIsInstance(value, float) 35 | 36 | def test_string(self) -> None: 37 | schema = {"type": "string"} 38 | value = value_utils.get_valid_value(schema) 39 | self.assertIsInstance(value, str) 40 | 41 | def test_bool_array(self) -> None: 42 | schema = {"type": "array", "items": {"type": "boolean"}} 43 | value = value_utils.get_valid_value(schema) 44 | self.assertIsInstance(value, list) 45 | self.assertIsInstance(value[0], bool) 46 | 47 | def test_int_array(self) -> None: 48 | schema = {"type": "array", "items": {"type": "integer"}} 49 | value = value_utils.get_valid_value(schema) 50 | self.assertIsInstance(value, list) 51 | self.assertIsInstance(value[0], int) 52 | 53 | def test_number_array(self) -> None: 54 | schema = {"type": "array", "items": {"type": "number"}} 55 | value = value_utils.get_valid_value(schema) 56 | self.assertIsInstance(value, list) 57 | self.assertIsInstance(value[0], float) 58 | 59 | def test_string_array(self) -> None: 60 | schema = {"type": "array", "items": {"type": "string"}} 61 | value = value_utils.get_valid_value(schema) 62 | self.assertIsInstance(value, list) 63 | self.assertIsInstance(value[0], str) 64 | 65 | def test_raises(self) -> None: 66 | schema = {"type": "object"} 67 | self.assertRaises(NotImplementedError, value_utils.get_valid_value, schema) 68 | 69 | 70 | if __name__ == "__main__": 71 | unittest.main() 72 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_invalid_value_from_constraint.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | from typing import Any, List 4 | 5 | from OpenApiLibCore import IGNORE, value_utils 6 | 7 | 8 | class TestInvalidValueFromConstraint(unittest.TestCase): 9 | def test_ignore(self) -> None: 10 | values = [42, IGNORE] 11 | value = value_utils.get_invalid_value_from_constraint( 12 | values_from_constraint=values, 13 | value_type="irrelevant", 14 | ) 15 | self.assertEqual(value, IGNORE) 16 | 17 | def test_unsupported(self) -> None: 18 | values = [{"red": 255, "green": 255, "blue": 255}] 19 | value = value_utils.get_invalid_value_from_constraint( 20 | values_from_constraint=values, 21 | value_type="dummy", 22 | ) 23 | self.assertEqual(value, None) 24 | 25 | def test_bool(self) -> None: 26 | values = [True] 27 | value = value_utils.get_invalid_value_from_constraint( 28 | values_from_constraint=values, 29 | value_type="boolean", 30 | ) 31 | self.assertNotIn(value, values) 32 | self.assertIsInstance(value, bool) 33 | 34 | values = [False] 35 | value = value_utils.get_invalid_value_from_constraint( 36 | values_from_constraint=values, 37 | value_type="boolean", 38 | ) 39 | self.assertNotIn(value, values) 40 | self.assertIsInstance(value, bool) 41 | 42 | values = [True, False] 43 | value = value_utils.get_invalid_value_from_constraint( 44 | values_from_constraint=values, 45 | value_type="boolean", 46 | ) 47 | self.assertEqual(value, None) 48 | 49 | def test_string(self) -> None: 50 | values = ["foo"] 51 | value = value_utils.get_invalid_value_from_constraint( 52 | values_from_constraint=values, 53 | value_type="string", 54 | ) 55 | self.assertNotIn(value, values) 56 | self.assertIsInstance(value, str) 57 | 58 | values = ["foo", "bar", "baz"] 59 | value = value_utils.get_invalid_value_from_constraint( 60 | values_from_constraint=values, 61 | value_type="string", 62 | ) 63 | self.assertNotIn(value, values) 64 | self.assertIsInstance(value, str) 65 | 66 | values = [""] 67 | value = value_utils.get_invalid_value_from_constraint( 68 | values_from_constraint=values, 69 | value_type="string", 70 | ) 71 | self.assertEqual(value, None) 72 | 73 | def test_integer(self) -> None: 74 | values = [0] 75 | value = value_utils.get_invalid_value_from_constraint( 76 | values_from_constraint=values, 77 | value_type="integer", 78 | ) 79 | self.assertNotIn(value, values) 80 | self.assertIsInstance(value, int) 81 | 82 | values = [-3, 0, 3] 83 | value = value_utils.get_invalid_value_from_constraint( 84 | values_from_constraint=values, 85 | value_type="integer", 86 | ) 87 | self.assertNotIn(value, values) 88 | self.assertIsInstance(value, int) 89 | 90 | def test_number(self) -> None: 91 | values = [0.0] 92 | value = value_utils.get_invalid_value_from_constraint( 93 | values_from_constraint=values, 94 | value_type="number", 95 | ) 96 | self.assertNotIn(value, values) 97 | self.assertIsInstance(value, float) 98 | 99 | values = [-0.1, 0.0, 0.1] 100 | value = value_utils.get_invalid_value_from_constraint( 101 | values_from_constraint=values, 102 | value_type="number", 103 | ) 104 | self.assertNotIn(value, values) 105 | self.assertIsInstance(value, float) 106 | 107 | def test_array(self) -> None: 108 | values: List[Any] = [[42]] 109 | value = value_utils.get_invalid_value_from_constraint( 110 | values_from_constraint=values, 111 | value_type="array", 112 | ) 113 | self.assertNotIn(value, values) 114 | 115 | values = [["spam"], ["ham", "eggs"]] 116 | value = value_utils.get_invalid_value_from_constraint( 117 | values_from_constraint=values, 118 | value_type="array", 119 | ) 120 | self.assertNotIn(value, values) 121 | 122 | values = [] 123 | value = value_utils.get_invalid_value_from_constraint( 124 | values_from_constraint=values, 125 | value_type="array", 126 | ) 127 | self.assertEqual(value, None) 128 | 129 | values = [[], []] 130 | value = value_utils.get_invalid_value_from_constraint( 131 | values_from_constraint=values, 132 | value_type="array", 133 | ) 134 | self.assertEqual(value, []) 135 | 136 | def test_object(self) -> None: 137 | values = [{"red": 255, "green": 255, "blue": 255}] 138 | value = value_utils.get_invalid_value_from_constraint( 139 | values_from_constraint=values, 140 | value_type="object", 141 | ) 142 | self.assertNotEqual(value, values[0]) 143 | self.assertIsInstance(value, dict) 144 | 145 | 146 | if __name__ == "__main__": 147 | unittest.main() 148 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_invalid_value_from_enum.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestInvalidValueFromEnum(unittest.TestCase): 8 | def test_string(self) -> None: 9 | value_list = ["foo", "bar"] 10 | result = value_utils.get_invalid_value_from_enum( 11 | values=value_list, 12 | value_type="string", 13 | ) 14 | self.assertNotIn(result, value_list) 15 | 16 | def test_integer(self) -> None: 17 | value_list = [-1, 0, 1] 18 | result = value_utils.get_invalid_value_from_enum( 19 | values=value_list, 20 | value_type="integer", 21 | ) 22 | self.assertNotIn(result, value_list) 23 | 24 | def test_float(self) -> None: 25 | value_list = [-0.1, 0, 0.1] 26 | result = value_utils.get_invalid_value_from_enum( 27 | values=value_list, 28 | value_type="integer", 29 | ) 30 | self.assertNotIn(result, value_list) 31 | 32 | def test_array(self) -> None: 33 | value_list = [["foo", "bar", "baz"], ["spam", "ham", "eggs"]] 34 | result = value_utils.get_invalid_value_from_enum( 35 | values=value_list, 36 | value_type="array", 37 | ) 38 | self.assertNotIn(result, value_list) 39 | 40 | def test_object(self) -> None: 41 | value_list = [ 42 | { 43 | "red": 255, 44 | "blue": 0, 45 | "green": 0, 46 | }, 47 | { 48 | "red": 0, 49 | "blue": 255, 50 | "green": 0, 51 | }, 52 | { 53 | "red": 0, 54 | "blue": 0, 55 | "green": 255, 56 | }, 57 | ] 58 | result = value_utils.get_invalid_value_from_enum( 59 | values=value_list, 60 | value_type="object", 61 | ) 62 | self.assertNotIn(result, value_list) 63 | 64 | def test_unsupported(self) -> None: 65 | value_list = [True, False] 66 | result = value_utils.get_invalid_value_from_enum( 67 | values=value_list, 68 | value_type="bool", 69 | ) 70 | self.assertEqual(result, None) 71 | 72 | 73 | if __name__ == "__main__": 74 | unittest.main() 75 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_localized_faker.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import datetime 3 | import unittest 4 | 5 | from OpenApiLibCore import value_utils 6 | 7 | 8 | class TestLocalizedFaker(unittest.TestCase): 9 | def test_default_locale(self) -> None: 10 | default_faker = value_utils.LocalizedFaker() 11 | self.assertEqual(default_faker.fake.locales, ["en_US"]) 12 | 13 | def test_set_locale(self) -> None: 14 | faker = value_utils.LocalizedFaker() 15 | 16 | faker.set_locale("nl_NL") 17 | self.assertEqual(faker.fake.locales, ["nl_NL"]) 18 | 19 | faker.set_locale(["ar_AA", "zh_TW"]) 20 | self.assertEqual(faker.fake.locales, ["ar_AA", "zh_TW"]) 21 | 22 | def test_custom_provider_types(self) -> None: 23 | faker = value_utils.LocalizedFaker() 24 | 25 | self.assertIsInstance(faker.date(), str) 26 | self.assertIsInstance(faker.date_time(), datetime.datetime) 27 | self.assertIsInstance(faker.password(), str) 28 | self.assertIsInstance(faker.binary(), bytes) 29 | self.assertIsInstance(faker.email(), str) 30 | self.assertIsInstance(faker.uuid(), str) 31 | self.assertIsInstance(faker.uri(), str) 32 | self.assertIsInstance(faker.url(), str) 33 | self.assertIsInstance(faker.hostname(), str) 34 | self.assertIsInstance(faker.ipv4(), str) 35 | self.assertIsInstance(faker.ipv6(), str) 36 | self.assertIsInstance(faker.name(), str) 37 | self.assertIsInstance(faker.text(), str) 38 | self.assertIsInstance(faker.description(), str) 39 | 40 | 41 | if __name__ == "__main__": 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_random_array.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | from typing import Any, Dict 4 | 5 | from OpenApiLibCore import value_utils 6 | 7 | 8 | class TestRandomArray(unittest.TestCase): 9 | def test_default_min_max(self) -> None: 10 | schema: Dict[str, Any] = {"items": {"type": "string"}} 11 | value = value_utils.get_random_array(schema) 12 | self.assertEqual(len(value), 1) 13 | 14 | schema = {"maxItems": 0, "items": {"type": "string"}} 15 | value = value_utils.get_random_array(schema) 16 | self.assertEqual(value, []) 17 | 18 | def test_min_max(self) -> None: 19 | schema = {"maxItems": 3, "items": {"type": "string"}} 20 | value = value_utils.get_random_array(schema) 21 | self.assertEqual(len(value), 3) 22 | 23 | schema = {"minItems": 5, "items": {"type": "string"}} 24 | value = value_utils.get_random_array(schema) 25 | self.assertEqual(len(value), 5) 26 | 27 | schema = {"minItems": 7, "maxItems": 5, "items": {"type": "string"}} 28 | value = value_utils.get_random_array(schema) 29 | self.assertEqual(len(value), 7) 30 | 31 | 32 | if __name__ == "__main__": 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_random_float.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | from sys import float_info 4 | from typing import Any, Dict 5 | 6 | from OpenApiLibCore import value_utils 7 | 8 | EPSILON = float_info.epsilon 9 | 10 | 11 | class TestRandomFloat(unittest.TestCase): 12 | def test_default_min_max(self) -> None: 13 | schema: Dict[str, Any] = {} 14 | value = value_utils.get_random_float(schema) 15 | self.assertGreaterEqual(value, -1.0) 16 | self.assertLessEqual(value, 1.0) 17 | 18 | schema = {"minimum": -2.0} 19 | value = value_utils.get_random_float(schema) 20 | self.assertGreaterEqual(value, -2.0) 21 | self.assertLessEqual(value, -1.0) 22 | 23 | schema = {"maximum": -2.0} 24 | value = value_utils.get_random_float(schema) 25 | self.assertGreaterEqual(value, -3.0) 26 | self.assertLessEqual(value, -2.0) 27 | 28 | def test_exclusive_min_max_oas_3_0(self) -> None: 29 | schema = { 30 | "minimum": 1.0 - EPSILON, 31 | "maximum": 1.0 + EPSILON, 32 | "exclusiveMinimum": True, 33 | } 34 | value = value_utils.get_random_float(schema) 35 | self.assertAlmostEqual(value, 1.0) 36 | 37 | schema = { 38 | "minimum": -1.0 - EPSILON, 39 | "maximum": -1.0 + EPSILON, 40 | "exclusiveMaximum": True, 41 | } 42 | value = value_utils.get_random_float(schema) 43 | self.assertAlmostEqual(value, -1.0) 44 | 45 | def test_exclusive_min_max_oas_3_1(self) -> None: 46 | schema = { 47 | "exclusiveMinimum": 1.0 - EPSILON, 48 | "maximum": 1.0 + EPSILON, 49 | } 50 | value = value_utils.get_random_float(schema) 51 | self.assertAlmostEqual(value, 1.0) 52 | 53 | schema = { 54 | "minimum": -1.0 - EPSILON, 55 | "exclusiveMaximum": -1.0 + EPSILON, 56 | } 57 | value = value_utils.get_random_float(schema) 58 | self.assertAlmostEqual(value, -1.0) 59 | 60 | def test_raises(self) -> None: 61 | schema = {"minimum": 1.0 + EPSILON, "maximum": 1.0} 62 | self.assertRaises(ValueError, value_utils.get_random_float, schema) 63 | 64 | schema = {"minimum": -1.0, "maximum": -1.0 - EPSILON} 65 | self.assertRaises(ValueError, value_utils.get_random_float, schema) 66 | 67 | schema = {"minimum": 1.0, "maximum": 1.0, "exclusiveMinimum": True} 68 | self.assertRaises(ValueError, value_utils.get_random_float, schema) 69 | 70 | schema = {"minimum": 1.0, "maximum": 1.0, "exclusiveMaximum": True} 71 | self.assertRaises(ValueError, value_utils.get_random_float, schema) 72 | 73 | def test_min_max(self) -> None: 74 | schema = {"minimum": 1.1, "maximum": 1.1} 75 | value = value_utils.get_random_float(schema) 76 | self.assertEqual(value, 1.1) 77 | 78 | schema = {"minimum": -1.1, "maximum": -1.1} 79 | value = value_utils.get_random_float(schema) 80 | self.assertEqual(value, -1.1) 81 | 82 | schema = {"minimum": 2.1, "maximum": 2.2, "exclusiveMinimum": True} 83 | value = value_utils.get_random_float(schema) 84 | self.assertGreater(value, 2.1) 85 | self.assertLess(value, 2.2) 86 | 87 | schema = {"minimum": -0.2, "maximum": -0.1, "exclusiveMaximum": True} 88 | value = value_utils.get_random_float(schema) 89 | self.assertGreater(value, -0.2) 90 | self.assertLess(value, -0.1) 91 | 92 | 93 | if __name__ == "__main__": 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_random_integer.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestRandomInteger(unittest.TestCase): 8 | def test_default_min_max(self) -> None: 9 | schema = {"maximum": -2147483648} 10 | value = value_utils.get_random_int(schema) 11 | self.assertEqual(value, -2147483648) 12 | 13 | schema = {"minimum": 2147483647} 14 | value = value_utils.get_random_int(schema) 15 | self.assertEqual(value, 2147483647) 16 | 17 | def test_exclusive_min_max_oas_3_0(self) -> None: 18 | schema = {"maximum": -2147483648, "exclusiveMaximum": False} 19 | value = value_utils.get_random_int(schema) 20 | self.assertEqual(value, -2147483648) 21 | 22 | schema = {"minimum": 2147483647, "exclusiveMinimum": False} 23 | value = value_utils.get_random_int(schema) 24 | self.assertEqual(value, 2147483647) 25 | 26 | schema = {"maximum": -2147483647, "exclusiveMaximum": True} 27 | value = value_utils.get_random_int(schema) 28 | self.assertEqual(value, -2147483648) 29 | 30 | schema = {"minimum": 2147483646, "exclusiveMinimum": True} 31 | value = value_utils.get_random_int(schema) 32 | self.assertEqual(value, 2147483647) 33 | 34 | def test_exclusive_min_max_oas_3_1(self) -> None: 35 | schema = {"exclusiveMaximum": -2147483647} 36 | value = value_utils.get_random_int(schema) 37 | self.assertEqual(value, -2147483648) 38 | 39 | schema = {"exclusiveMinimum": 2147483646} 40 | value = value_utils.get_random_int(schema) 41 | self.assertEqual(value, 2147483647) 42 | 43 | def test_min_max(self) -> None: 44 | schema = {"minimum": 42, "maximum": 42} 45 | value = value_utils.get_random_int(schema) 46 | self.assertEqual(value, 42) 47 | 48 | schema = {"minimum": -42, "maximum": -42} 49 | value = value_utils.get_random_int(schema) 50 | self.assertEqual(value, -42) 51 | 52 | schema = { 53 | "minimum": 41, 54 | "maximum": 43, 55 | "exclusiveMinimum": True, 56 | "exclusiveMaximum": True, 57 | } 58 | value = value_utils.get_random_int(schema) 59 | self.assertEqual(value, 42) 60 | 61 | schema = { 62 | "minimum": -43, 63 | "maximum": -41, 64 | "exclusiveMinimum": True, 65 | "exclusiveMaximum": True, 66 | } 67 | value = value_utils.get_random_int(schema) 68 | self.assertEqual(value, -42) 69 | 70 | def test_int64(self) -> None: 71 | schema = {"maximum": -9223372036854775808, "format": "int64"} 72 | value = value_utils.get_random_int(schema) 73 | self.assertEqual(value, -9223372036854775808) 74 | 75 | schema = {"minimum": 9223372036854775807, "format": "int64"} 76 | value = value_utils.get_random_int(schema) 77 | self.assertEqual(value, 9223372036854775807) 78 | 79 | 80 | if __name__ == "__main__": 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_random_string.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestRandomString(unittest.TestCase): 8 | def test_default_min_max(self) -> None: 9 | schema = {"maxLength": 0} 10 | value = value_utils.get_random_string(schema) 11 | self.assertEqual(value, "") 12 | 13 | schema = {"minLength": 36} 14 | value = value_utils.get_random_string(schema) 15 | self.assertEqual(len(value), 36) 16 | 17 | def test_min_max(self) -> None: 18 | schema = {"minLength": 42, "maxLength": 42} 19 | value = value_utils.get_random_string(schema) 20 | self.assertEqual(len(value), 42) 21 | 22 | schema = {"minLength": 42} 23 | value = value_utils.get_random_string(schema) 24 | self.assertEqual(len(value), 42) 25 | 26 | def test_datetime(self) -> None: 27 | schema = {"format": "date-time"} 28 | value = value_utils.get_random_string(schema) 29 | matcher = r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z)?)$" 30 | self.assertRegex(value, matcher) 31 | 32 | def test_pattern(self) -> None: 33 | pattern = r"^[1-9][0-9]{3} ?(?!sa|sd|ss|SA|SD|SS)[A-Za-z]{2}$" 34 | schema = {"pattern": pattern} 35 | value = value_utils.get_random_string(schema) 36 | self.assertRegex(value, pattern) 37 | 38 | def test_byte(self) -> None: 39 | schema = {"format": "byte"} 40 | value = value_utils.get_random_string(schema) 41 | self.assertIsInstance(value, bytes) 42 | 43 | 44 | if __name__ == "__main__": 45 | unittest.main() 46 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_type_name_mappers.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestTypeNameMappers(unittest.TestCase): 8 | def test_json_type_name_of_python_types(self) -> None: 9 | mapper = value_utils.json_type_name_of_python_type 10 | self.assertEqual(mapper(str), "string") 11 | self.assertEqual(mapper(bool), "boolean") 12 | self.assertEqual(mapper(int), "integer") 13 | self.assertEqual(mapper(float), "number") 14 | self.assertEqual(mapper(list), "array") 15 | self.assertEqual(mapper(dict), "object") 16 | self.assertEqual(mapper(type(None)), "null") 17 | 18 | def test_python_type_by_json_type_name(self) -> None: 19 | mapper = value_utils.python_type_by_json_type_name 20 | self.assertEqual(mapper("string"), str) 21 | self.assertEqual(mapper("boolean"), bool) 22 | self.assertEqual(mapper("integer"), int) 23 | self.assertEqual(mapper("number"), float) 24 | self.assertEqual(mapper("array"), list) 25 | self.assertEqual(mapper("object"), dict) 26 | self.assertEqual(mapper("null"), type(None)) 27 | 28 | def test_mappers_raise_for_unknown_mappings(self) -> None: 29 | self.assertRaises( 30 | ValueError, value_utils.json_type_name_of_python_type, type(self) 31 | ) 32 | self.assertRaises( 33 | ValueError, value_utils.python_type_by_json_type_name, "undefined" 34 | ) 35 | 36 | 37 | if __name__ == "__main__": 38 | unittest.main() 39 | -------------------------------------------------------------------------------- /tests/unittests/value_utils/test_value_out_of_bounds.py: -------------------------------------------------------------------------------- 1 | # pylint: disable="missing-class-docstring", "missing-function-docstring" 2 | import unittest 3 | 4 | from OpenApiLibCore import value_utils 5 | 6 | 7 | class TestValueOutOfBounds(unittest.TestCase): 8 | def test_minimum_integer(self) -> None: 9 | minimum = -42 10 | value_schema = {"type": "integer", "minimum": minimum} 11 | current_value = "irrelevant" 12 | value = value_utils.get_value_out_of_bounds( 13 | value_schema=value_schema, 14 | current_value=current_value, 15 | ) 16 | self.assertLess(value, minimum) 17 | self.assertIsInstance(value, int) 18 | 19 | minimum = 3 20 | value_schema = {"type": "integer", "minimum": minimum} 21 | current_value = "irrelevant" 22 | value = value_utils.get_value_out_of_bounds( 23 | value_schema=value_schema, 24 | current_value=current_value, 25 | ) 26 | self.assertLess(value, minimum) 27 | self.assertIsInstance(value, int) 28 | 29 | def test_minimum_number(self) -> None: 30 | minimum = -0.6 31 | value_schema = {"type": "integer", "minimum": minimum} 32 | current_value = "irrelevant" 33 | value = value_utils.get_value_out_of_bounds( 34 | value_schema=value_schema, 35 | current_value=current_value, 36 | ) 37 | self.assertLess(value, minimum) 38 | self.assertIsInstance(value, float) 39 | 40 | minimum = 3.14159 41 | value_schema = {"type": "integer", "minimum": minimum} 42 | current_value = "irrelevant" 43 | value = value_utils.get_value_out_of_bounds( 44 | value_schema=value_schema, 45 | current_value=current_value, 46 | ) 47 | self.assertLess(value, minimum) 48 | self.assertIsInstance(value, float) 49 | 50 | def test_maximum_integer(self) -> None: 51 | maximum = -42 52 | value_schema = {"type": "integer", "maximum": maximum} 53 | current_value = "irrelevant" 54 | value = value_utils.get_value_out_of_bounds( 55 | value_schema=value_schema, 56 | current_value=current_value, 57 | ) 58 | self.assertGreater(value, maximum) 59 | self.assertIsInstance(value, int) 60 | 61 | maximum = 3 62 | value_schema = {"type": "integer", "maximum": maximum} 63 | current_value = "irrelevant" 64 | value = value_utils.get_value_out_of_bounds( 65 | value_schema=value_schema, 66 | current_value=current_value, 67 | ) 68 | self.assertGreater(value, maximum) 69 | self.assertIsInstance(value, int) 70 | 71 | def test_maximum_number(self) -> None: 72 | maximum = -0.6 73 | value_schema = {"type": "integer", "maximum": maximum} 74 | current_value = "irrelevant" 75 | value = value_utils.get_value_out_of_bounds( 76 | value_schema=value_schema, 77 | current_value=current_value, 78 | ) 79 | self.assertGreater(value, maximum) 80 | self.assertIsInstance(value, float) 81 | 82 | maximum = 3.14159 83 | value_schema = {"type": "integer", "maximum": maximum} 84 | current_value = "irrelevant" 85 | value = value_utils.get_value_out_of_bounds( 86 | value_schema=value_schema, 87 | current_value=current_value, 88 | ) 89 | self.assertGreater(value, maximum) 90 | self.assertIsInstance(value, float) 91 | 92 | def test_exclusive_minimum_integer_oas_3_0(self) -> None: 93 | minimum = -42 94 | value_schema = {"type": "integer", "minimum": minimum, "exclusiveMinimum": True} 95 | current_value = "irrelevant" 96 | value = value_utils.get_value_out_of_bounds( 97 | value_schema=value_schema, 98 | current_value=current_value, 99 | ) 100 | self.assertEqual(value, minimum) 101 | self.assertIsInstance(value, int) 102 | 103 | def test_exclusive_minimum_integer_oas_3_1(self) -> None: 104 | minimum = -42 105 | value_schema = {"type": "integer", "exclusiveMinimum": minimum} 106 | current_value = "irrelevant" 107 | value = value_utils.get_value_out_of_bounds( 108 | value_schema=value_schema, 109 | current_value=current_value, 110 | ) 111 | self.assertEqual(value, minimum) 112 | self.assertIsInstance(value, int) 113 | 114 | def test_exclusive_maximum_integer_oas_3_0(self) -> None: 115 | maximum = -42 116 | value_schema = {"type": "integer", "maximum": maximum, "exclusiveMaximum": True} 117 | current_value = "irrelevant" 118 | value = value_utils.get_value_out_of_bounds( 119 | value_schema=value_schema, 120 | current_value=current_value, 121 | ) 122 | self.assertEqual(value, maximum) 123 | self.assertIsInstance(value, int) 124 | 125 | def test_exclusive_maximum_integer_oas_3_1(self) -> None: 126 | maximum = -42 127 | value_schema = {"type": "integer", "exclusiveMaximum": maximum} 128 | current_value = "irrelevant" 129 | value = value_utils.get_value_out_of_bounds( 130 | value_schema=value_schema, 131 | current_value=current_value, 132 | ) 133 | self.assertEqual(value, maximum) 134 | self.assertIsInstance(value, int) 135 | 136 | def test_exclusive_minimum_number_oas_3_0(self) -> None: 137 | minimum = 3.14159 138 | value_schema = {"type": "integer", "minimum": minimum, "exclusiveMinimum": True} 139 | current_value = "irrelevant" 140 | value = value_utils.get_value_out_of_bounds( 141 | value_schema=value_schema, 142 | current_value=current_value, 143 | ) 144 | self.assertEqual(value, minimum) 145 | self.assertIsInstance(value, float) 146 | 147 | def test_exclusive_minimum_number_oas_3_1(self) -> None: 148 | minimum = 3.14159 149 | value_schema = {"type": "integer", "exclusiveMinimum": minimum} 150 | current_value = "irrelevant" 151 | value = value_utils.get_value_out_of_bounds( 152 | value_schema=value_schema, 153 | current_value=current_value, 154 | ) 155 | self.assertEqual(value, minimum) 156 | self.assertIsInstance(value, float) 157 | 158 | def test_exclusive_maximum_number_oas_3_0(self) -> None: 159 | maximum = -273.15 160 | value_schema = {"type": "integer", "maximum": maximum, "exclusiveMaximum": True} 161 | current_value = "irrelevant" 162 | value = value_utils.get_value_out_of_bounds( 163 | value_schema=value_schema, 164 | current_value=current_value, 165 | ) 166 | self.assertEqual(value, maximum) 167 | self.assertIsInstance(value, float) 168 | 169 | def test_exclusive_maximum_number_oas_3_1(self) -> None: 170 | maximum = -273.15 171 | value_schema = {"type": "integer", "exclusiveMaximum": maximum} 172 | current_value = "irrelevant" 173 | value = value_utils.get_value_out_of_bounds( 174 | value_schema=value_schema, 175 | current_value=current_value, 176 | ) 177 | self.assertEqual(value, maximum) 178 | self.assertIsInstance(value, float) 179 | 180 | def test_minimum_length(self) -> None: 181 | minimum = 1 182 | value_schema = {"type": "string", "minLength": minimum} 183 | current_value = "irrelevant" 184 | value = value_utils.get_value_out_of_bounds( 185 | value_schema=value_schema, 186 | current_value=current_value, 187 | ) 188 | self.assertLess(len(value), minimum) 189 | self.assertIsInstance(value, str) 190 | 191 | def test_maximum_length(self) -> None: 192 | maximum = 7 193 | value_schema = {"type": "string", "maxLength": maximum} 194 | current_value = "valid" 195 | value = value_utils.get_value_out_of_bounds( 196 | value_schema=value_schema, 197 | current_value=current_value, 198 | ) 199 | self.assertGreater(len(value), maximum) 200 | self.assertIsInstance(value, str) 201 | 202 | maximum = 7 203 | value_schema = {"type": "string", "maxLength": maximum} 204 | current_value = "" 205 | value = value_utils.get_value_out_of_bounds( 206 | value_schema=value_schema, 207 | current_value=current_value, 208 | ) 209 | self.assertGreater(len(value), maximum) 210 | self.assertIsInstance(value, str) 211 | 212 | def test_minimum_length_zero(self) -> None: 213 | minimum = 0 214 | value_schema = {"type": "string", "minLength": minimum} 215 | current_value = "irrelevant" 216 | value = value_utils.get_value_out_of_bounds( 217 | value_schema=value_schema, 218 | current_value=current_value, 219 | ) 220 | self.assertEqual(value, None) 221 | 222 | def test_maximum_length_zero(self) -> None: 223 | maximum = 0 224 | value_schema = {"type": "string", "maxLength": maximum} 225 | current_value = "irrelevant" 226 | value = value_utils.get_value_out_of_bounds( 227 | value_schema=value_schema, 228 | current_value=current_value, 229 | ) 230 | self.assertEqual(value, None) 231 | 232 | def test_min_items(self) -> None: 233 | minimum = 1 234 | value_schema = { 235 | "type": "array", 236 | "minItems": minimum, 237 | "items": {"type": "string"}, 238 | } 239 | current_value = ["irrelevant"] 240 | value = value_utils.get_value_out_of_bounds( 241 | value_schema=value_schema, 242 | current_value=current_value, 243 | ) 244 | self.assertLess(len(value), minimum) 245 | self.assertIsInstance(value, list) 246 | 247 | def test_max_items(self) -> None: 248 | maximum = 3 249 | value_schema = { 250 | "type": "array", 251 | "maxItems": maximum, 252 | "items": {"type": "boolean"}, 253 | } 254 | current_value = [True, False] 255 | value = value_utils.get_value_out_of_bounds( 256 | value_schema=value_schema, 257 | current_value=current_value, 258 | ) 259 | self.assertGreater(len(value), maximum) 260 | self.assertIsInstance(value, list) 261 | 262 | def test_min_items_zero(self) -> None: 263 | minimum = 0 264 | value_schema = { 265 | "type": "array", 266 | "minItems": minimum, 267 | "items": {"type": "number"}, 268 | } 269 | current_value = [42] 270 | value = value_utils.get_value_out_of_bounds( 271 | value_schema=value_schema, 272 | current_value=current_value, 273 | ) 274 | self.assertEqual(value, None) 275 | 276 | def test_unbound(self) -> None: 277 | value_schema = {"type": "integer"} 278 | current_value = "irrelvant" 279 | value = value_utils.get_value_out_of_bounds( 280 | value_schema=value_schema, 281 | current_value=current_value, 282 | ) 283 | self.assertEqual(value, None) 284 | 285 | value_schema = {"type": "number"} 286 | current_value = "irrelvant" 287 | value = value_utils.get_value_out_of_bounds( 288 | value_schema=value_schema, 289 | current_value=current_value, 290 | ) 291 | self.assertEqual(value, None) 292 | 293 | value_schema = {"type": "string"} 294 | current_value = "irrelvant" 295 | value = value_utils.get_value_out_of_bounds( 296 | value_schema=value_schema, 297 | current_value=current_value, 298 | ) 299 | self.assertEqual(value, None) 300 | 301 | def test_unsupported(self) -> None: 302 | value_schema = {"type": "boolean"} 303 | current_value = "irrelevant" 304 | value = value_utils.get_value_out_of_bounds( 305 | value_schema=value_schema, 306 | current_value=current_value, 307 | ) 308 | self.assertEqual(value, None) 309 | 310 | 311 | if __name__ == "__main__": 312 | unittest.main() 313 | -------------------------------------------------------------------------------- /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 Dict, List 2 | 3 | from OpenApiLibCore import ( 4 | IGNORE, 5 | DefaultDto, 6 | Dto, 7 | IdDependency, 8 | IdReference, 9 | PropertyValueConstraint, 10 | Relation, 11 | UniquePropertyValueConstraint, 12 | ) 13 | 14 | 15 | class WagegroupDto(Dto): 16 | @staticmethod 17 | def get_relations() -> List[Relation]: 18 | relations: List[Relation] = [ 19 | UniquePropertyValueConstraint( 20 | property_name="id", 21 | value="Teapot", 22 | error_code=418, 23 | ), 24 | IdReference( 25 | property_name="wagegroup_id", 26 | post_path="/employees", 27 | error_code=406, 28 | ), 29 | PropertyValueConstraint( 30 | property_name="overtime_percentage", 31 | values=[IGNORE], 32 | invalid_value=110, 33 | invalid_value_error_code=422, 34 | ), 35 | PropertyValueConstraint( 36 | property_name="hourly_rate", 37 | values=[80.50, 90.95, 99.99], 38 | ), 39 | ] 40 | return relations 41 | 42 | 43 | class EmployeeDto(Dto): 44 | @staticmethod 45 | def get_relations() -> List[Relation]: 46 | relations: List[Relation] = [ 47 | IdDependency( 48 | property_name="wagegroup_id", 49 | get_path="/wagegroups", 50 | error_code=451, 51 | ), 52 | PropertyValueConstraint( 53 | property_name="date_of_birth", 54 | values=["1970-07-07", "1980-08-08", "1990-09-09"], 55 | invalid_value="2020-02-20", 56 | invalid_value_error_code=403, 57 | error_code=422, 58 | ), 59 | ] 60 | return relations 61 | 62 | 63 | def get_variables(): 64 | """Automatically called by Robot Framework to load variables.""" 65 | id_reference = IdReference( 66 | property_name="wagegroup_id", 67 | post_path="/employees", 68 | error_code=406, 69 | ) 70 | invalid_id_reference = IdReference( 71 | property_name="wagegroup_id", 72 | post_path="/employees/{employee_id}", 73 | error_code=406, 74 | ) 75 | wagegroup_dto = WagegroupDto 76 | employee_dto = EmployeeDto 77 | default_dto = DefaultDto 78 | extra_headers: Dict[str, str] = {"foo": "bar", "eggs": "bacon"} 79 | return { 80 | "ID_REFERENCE": id_reference, 81 | "INVALID_ID_REFERENCE": invalid_id_reference, 82 | "DEFAULT_DTO": default_dto, 83 | "WAGEGROUP_DTO": wagegroup_dto, 84 | "EMPLOYEE_DTO": employee_dto, 85 | "EXTRA_HEADERS": extra_headers, 86 | } 87 | --------------------------------------------------------------------------------