├── .codecov.yml ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── testing.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── manage.py ├── openapi_tester ├── __init__.py ├── case_testers.py ├── clients.py ├── constants.py ├── exceptions.py ├── loaders.py ├── py.typed ├── schema_tester.py ├── utils.py └── validators.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── test_project ├── __init__.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── swagger │ │ ├── __init__.py │ │ ├── auto_schemas.py │ │ ├── responses.py │ │ └── schemas.py │ └── views │ │ ├── __init__.py │ │ ├── animals.py │ │ ├── cars.py │ │ ├── exempt_endpoint.py │ │ ├── i18n.py │ │ ├── items.py │ │ ├── names.py │ │ ├── pets.py │ │ ├── products.py │ │ ├── snake_cased_response.py │ │ ├── trucks.py │ │ └── vehicles.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_names_name.py │ └── __init__.py ├── models.py ├── settings.py ├── urls.py ├── views.py └── wsgi.py └── tests ├── __init__.py ├── schema_converter.py ├── schemas ├── any_of_one_of_test_schema.yaml ├── manual_reference_schema.json ├── manual_reference_schema.yaml ├── openapi_v2_reference_schema.yaml ├── openapi_v3_reference_schema.yaml ├── reference_yasg_schema.json ├── reference_yasg_schema.yaml ├── sample-schemas │ ├── api-example.yaml │ ├── content_types.yaml │ ├── external-apis │ │ ├── anpr-dashboard.yaml │ │ ├── cercaareeprotette.e015.servizirl.it.yaml │ │ ├── fatture-e-corrispettivi.yaml │ │ ├── fatture-e-corrispettivi.yml │ │ ├── geodati.gov.it.yaml │ │ ├── istat-sdmx-from-wadl.json │ │ ├── istat-sdmx-rest.yaml │ │ ├── muoversi2015.e015.servizirl.it.yaml │ │ ├── ows01-agenzia-entrate.yaml │ │ ├── petstore-v3.yaml │ │ ├── siopeplus.yaml │ │ └── v2 │ │ │ └── api.corrispettivi.agenziaentrate.gov.it.yml │ ├── marketplace-catalog.yml │ ├── metadata.yaml │ └── observations.yaml ├── spectactular_reference_schema.json └── spectactular_reference_schema.yaml ├── test_case_validators.py ├── test_clients.py ├── test_django_framework.py ├── test_errors.py ├── test_loaders.py ├── test_schema_tester.py ├── test_utils.py ├── test_validators.py └── utils.py /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.codecov.io/docs/codecovyml-reference 2 | 3 | codecov: 4 | require_ci_to_pass: true 5 | 6 | coverage: 7 | precision: 1 8 | round: down 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | patch: false 14 | changes: false 15 | 16 | comment: 17 | layout: "diff,files" 18 | require_changes: true 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | reviewers: 9 | - sondrelg 10 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | build-and-publish-test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: snok/.github/workflows/publish@main 12 | with: 13 | overwrite-repository: true 14 | repository-url: https://test.pypi.org/legacy/ 15 | token: ${{ secrets.TEST_PYPI_TOKEN }} 16 | build-and-publish: 17 | needs: build-and-publish-test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: snok/.github/workflows/publish@main 21 | with: 22 | token: ${{ secrets.PYPI_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | linting: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.11.2" 17 | - uses: actions/cache@v3 18 | id: cache 19 | with: 20 | path: | 21 | .venv 22 | ~/.cache/pre-commit 23 | key: venv-3 # increment to reset 24 | - run: | 25 | python -m venv .venv --upgrade-deps 26 | source .venv/bin/activate 27 | pip install pre-commit 28 | if: steps.cache.outputs.cache-hit != 'true' 29 | - run: | 30 | source .venv/bin/activate 31 | pre-commit run --all-files 32 | 33 | test: 34 | runs-on: ubuntu-latest 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: [ "3.7.14", "3.8.14" , "3.9.14", "3.10.7", "3.11.2" ] 39 | django-version: [ "3.2", "4.0", "4.1" ] 40 | exclude: 41 | # Django v4 dropped 3.7 support 42 | - django-version: 4.0 43 | python-version: 3.7.14 44 | - django-version: 4.1 45 | python-version: 3.7.14 46 | steps: 47 | - uses: actions/checkout@v3 48 | - uses: actions/setup-python@v4 49 | with: 50 | python-version: "${{ matrix.python-version }}" 51 | - uses: snok/install-poetry@v1 52 | with: 53 | virtualenvs-create: false 54 | version: 1.3.2 55 | - uses: actions/cache@v3 56 | id: cache-venv 57 | with: 58 | path: .venv 59 | key: ${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}-6 60 | - run: | 61 | python -m venv .venv 62 | source .venv/bin/activate 63 | pip install wheel setuptools pip -U 64 | poetry install --no-interaction --no-root --extras drf-spectacular --extras drf-yasg 65 | if: steps.cache-venv.outputs.cache-hit != 'true' 66 | - run: | 67 | source .venv/bin/activate 68 | pip install "Django==${{ matrix.django-version }}" 69 | - run: | 70 | source .venv/bin/activate 71 | coverage run -m pytest 72 | coverage xml 73 | coverage report 74 | - uses: actions/upload-artifact@v3 75 | with: 76 | name: coverage-xml 77 | path: coverage.xml 78 | if: matrix.python-version == '3.10.7' && matrix.django-version == '4.1' 79 | 80 | coverage: 81 | needs: test 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v3 85 | - uses: actions/download-artifact@v3 86 | with: 87 | name: coverage-xml 88 | - uses: codecov/codecov-action@v3 89 | with: 90 | file: ./coverage.xml 91 | fail_ci_if_error: true 92 | token: ${{ secrets.CODECOV_TOKEN }} 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test_project/static/* 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | .idea 8 | NOTES.md 9 | test.json 10 | # C extensions 11 | *.so 12 | dumps/ 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | *.iml 135 | .vscode 136 | .DS_Store 137 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.4.0 9 | hooks: 10 | - id: check-case-conflict 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | - id: check-ast 14 | - id: check-json 15 | - id: check-yaml 16 | - id: check-merge-conflict 17 | 18 | - repo: https://github.com/pycqa/flake8 19 | rev: 6.0.0 20 | hooks: 21 | - id: flake8 22 | additional_dependencies: [ 23 | 'flake8-bugbear', 24 | 'flake8-comprehensions', 25 | 'flake8-print', 26 | 'flake8-mutable', 27 | 'flake8-use-fstring', 28 | 'flake8-simplify', 29 | 'flake8-pytest-style', 30 | 'flake8-type-checking==2.3.0', 31 | ] 32 | 33 | - repo: https://github.com/asottile/pyupgrade 34 | rev: v3.8.0 35 | hooks: 36 | - id: pyupgrade 37 | args: [ "--py3-plus", "--py36-plus", "--py37-plus" ] 38 | 39 | - repo: https://github.com/pycqa/isort 40 | rev: 5.12.0 41 | hooks: 42 | - id: isort 43 | 44 | - repo: https://github.com/pre-commit/mirrors-mypy 45 | rev: v1.4.1 46 | hooks: 47 | - id: mypy 48 | additional_dependencies: 49 | - django-stubs 50 | - djangorestframework 51 | - djangorestframework-stubs 52 | - types-PyYAML 53 | - drf-yasg 54 | - drf-spectacular 55 | 56 | - repo: local 57 | hooks: 58 | - id: pylint 59 | name: pylint 60 | entry: pylint 61 | language: python 62 | types: [ python ] 63 | exclude: tests|test_project|manage.py 64 | additional_dependencies: 65 | - django 66 | - djangorestframework 67 | - inflection 68 | - openapi-spec-validator 69 | - prance 70 | - pyYAML 71 | - django-stubs 72 | - djangorestframework-stubs 73 | - drf_yasg 74 | - drf-spectacular 75 | - pylint 76 | - faker 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.7+ 4 | 5 | See Github [releases](https://github.com/snok/drf-openapi-tester/releases) for changelog in newer versions. 6 | 7 | ## v1.3.7 2021-05-20 8 | 9 | * Update dependencies 10 | 11 | ## v1.3.6 2021-05-20 12 | 13 | * Update dependencies 14 | 15 | ## v1.3.5 2020-02-28 16 | 17 | * Rework schema reference logic, to improve handling of nested structures. 18 | 19 | ## v1.3.4 2020-02-27 20 | 21 | * Resolve issue with drf-spectacular's use of "oneOf" to handle null enums. 22 | 23 | ## v1.3.3 2020-02-25 24 | 25 | * Replace Python 3.8+ functools.cache_property with the Django builtin version to ensure Python 3.6 comp. 26 | 27 | ## v1.3.2 2020-02-20 28 | 29 | * Hotfix regression due to pk resolution logic 30 | 31 | ## v1.3.1 2020-02-20 32 | 33 | * Hotfix regression due to pk resolution logic 34 | 35 | ## v1.3.0 2020-02-20 36 | 37 | * Added validators for the "format" keyword, handling the following format values generated by DRF and DRF derived libraries: "uuid", "base64", "email", "uri", "url", "ipv4", "ipv6" and "time" 38 | validator 39 | * Added support for dynamic url parameters (DRF feature) in schemas 40 | * Added an option to pass a map of custom url parameters and values 41 | * Fixed handling of byte format to test for base64 string instead of bytes 42 | * Refactored error messages to be more concise and precise 43 | 44 | 45 | ## v1.2.0 2020-02-14 46 | 47 | * Added validation of writeOnly keys 48 | * Updated openAPI keyword (anyOf, oneOf, allOf) logic 49 | * Resolve minor issues with error formatting (unable to handle bytes) 50 | 51 | ## v1.1.0 2020-02-12 52 | 53 | * Fixed allOf deep object merging 54 | * Fixed handling of non-string path parameters 55 | * Fixed error messages 56 | * Fixed handling of 0 as a float format value 57 | 58 | ## v1.0.0 2020-02-07 59 | 60 | * Now supports `anyOf` 61 | * Adds `additionalProperties` support 62 | * Adds validation for remaining OpenAPI features, including 63 | * `format` 64 | * `enum` 65 | * `pattern` 66 | * `multipleOf` 67 | * `uniqueItems` 68 | * `minLength` and `maxLength` 69 | * `minProperties` and `maxProperties` 70 | * `minimum`, `maximum`, `exclusiveMinimum`, and `exclusiveMaximum` 71 | 72 | ## v0.1.0 2020-01-26 73 | 74 | * Package refactored and renamed from `django-swagger-tester` to `drf-openapi-tester` 75 | * Adds `inflection` for case checking 76 | * Adds `prance` for schema validation 77 | * Adds enum validation 78 | * Adds format validation 79 | * Adds support for `oneOf` and `allOf` 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Sondre Lillebø Gundersen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the authors nor the 15 | names of its contributors may be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/drf-openapi-tester.svg)](https://pypi.org/project/drf-openapi-tester/) 2 | [![Coverage](https://codecov.io/gh/snok/drf-openapi-tester/branch/master/graph/badge.svg)](https://codecov.io/gh/snok/drf-openapi-tester) 3 | [![Python versions](https://img.shields.io/badge/Python-3.7%2B-blue)](https://pypi.org/project/drf-openapi-tester/) 4 | [![Django versions](https://img.shields.io/badge/Django-3.0%2B-blue)](https://pypi.org/project/drf-openapi-tester/) 5 | 6 | 7 | # DRF OpenAPI Tester 8 | 9 | This is a test utility to validate DRF Test Responses against OpenAPI 2 and 3 schema. It has built-in support for: 10 | 11 | - OpenAPI 2/3 yaml or json schema files. 12 | - OpenAPI 2 schemas created with [drf-yasg](https://github.com/axnsan12/drf-yasg). 13 | - OpenAPI 3 schemas created with [drf-spectacular](https://github.com/tfranzel/drf-spectacular). 14 | 15 | > This project is no longer maintained. If you want to use it, consider taking a look at [this fork](https://github.com/maticardenas/drf-openapi-tester/) which might still be. 16 | 17 | ## Installation 18 | 19 | ```shell script 20 | pip install drf-openapi-tester 21 | ``` 22 | 23 | ## Usage 24 | 25 | Instantiate one or more instances of `SchemaTester`: 26 | 27 | ```python 28 | from openapi_tester import SchemaTester 29 | 30 | schema_tester = SchemaTester() 31 | ``` 32 | 33 | If you are using either [drf-yasg](https://github.com/axnsan12/drf-yasg) 34 | or [drf-spectacular](https://github.com/tfranzel/drf-spectacular) this will be auto-detected, and the schema will be 35 | loaded by the `SchemaTester` automatically. 36 | 37 | If you are using schema files, you will need to pass the file path: 38 | 39 | ```python 40 | from openapi_tester import SchemaTester 41 | 42 | # path should be a string 43 | schema_tester = SchemaTester(schema_file_path="./schemas/publishedSpecs.yaml") 44 | ``` 45 | 46 | Once you've instantiated a tester, you can use it to test responses: 47 | 48 | ```python 49 | from openapi_tester.schema_tester import SchemaTester 50 | 51 | schema_tester = SchemaTester() 52 | 53 | 54 | def test_response_documentation(client): 55 | response = client.get('api/v1/test/1') 56 | assert response.status_code == 200 57 | schema_tester.validate_response(response=response) 58 | ``` 59 | 60 | If you are using the Django testing framework, you can create a base `APITestCase` that incorporates schema validation: 61 | 62 | ```python 63 | from rest_framework.response import Response 64 | from rest_framework.test import APITestCase 65 | 66 | from openapi_tester.schema_tester import SchemaTester 67 | 68 | schema_tester = SchemaTester() 69 | 70 | 71 | class BaseAPITestCase(APITestCase): 72 | """ Base test class for api views including schema validation """ 73 | 74 | @staticmethod 75 | def assertResponse(response: Response, **kwargs) -> None: 76 | """ helper to run validate_response and pass kwargs to it """ 77 | schema_tester.validate_response(response=response, **kwargs) 78 | ``` 79 | 80 | Then use it in a test file: 81 | 82 | ```python 83 | from shared.testing import BaseAPITestCase 84 | 85 | 86 | class MyAPITests(BaseAPITestCase): 87 | def test_some_view(self): 88 | response = self.client.get("...") 89 | self.assertResponse(response) 90 | ``` 91 | 92 | ## Options 93 | 94 | You can pass options either globally, when instantiating a `SchemaTester`, or locally, when 95 | invoking `validate_response`: 96 | 97 | ```python 98 | from openapi_tester import SchemaTester, is_camel_case 99 | from tests.utils import my_uuid_4_validator 100 | 101 | schema_test_with_case_validation = SchemaTester( 102 | case_tester=is_camel_case, 103 | ignore_case=["IP"], 104 | validators=[my_uuid_4_validator] 105 | ) 106 | 107 | ``` 108 | 109 | Or 110 | 111 | ```python 112 | from openapi_tester import SchemaTester, is_camel_case 113 | from tests.utils import my_uuid_4_validator 114 | 115 | schema_tester = SchemaTester() 116 | 117 | 118 | def my_test(client): 119 | response = client.get('api/v1/test/1') 120 | assert response.status_code == 200 121 | schema_tester.validate_response( 122 | response=response, 123 | case_tester=is_camel_case, 124 | ignore_case=["IP"], 125 | validators=[my_uuid_4_validator] 126 | ) 127 | ``` 128 | 129 | ### case_tester 130 | 131 | The case tester argument takes a callable that is used to validate the key case of both schemas and responses. If 132 | nothing is passed, case validation is skipped. 133 | 134 | The library currently has 4 built-in case testers: 135 | 136 | - `is_pascal_case` 137 | - `is_snake_case` 138 | - `is_camel_case` 139 | - `is_kebab_case` 140 | 141 | You can use one of these, or your own. 142 | 143 | ### ignore_case 144 | 145 | List of keys to ignore when testing key case. This setting only applies when case_tester is not `None`. 146 | 147 | ### validators 148 | 149 | List of custom validators. A validator is a function that receives two parameters: schema_section and data, and returns 150 | either an error message or `None`, e.g.: 151 | 152 | ```python 153 | from typing import Any, Optional 154 | from uuid import UUID 155 | 156 | 157 | def my_uuid_4_validator(schema_section: dict, data: Any) -> Optional[str]: 158 | schema_format = schema_section.get("format") 159 | if schema_format == "uuid4": 160 | try: 161 | result = UUID(data, version=4) 162 | if not str(result) == str(data): 163 | return f"Expected uuid4, but received {data}" 164 | except ValueError: 165 | return f"Expected uuid4, but received {data}" 166 | return None 167 | ``` 168 | 169 | ### field_key_map 170 | 171 | You can pass an optional dictionary that maps custom url parameter names into values, for situations where this cannot be 172 | inferred by the DRF `EndpointEnumerator`. A concrete use case for this option is when 173 | the [django i18n locale prefixes](https://docs.djangoproject.com/en/3.1/topics/i18n/translation/#language-prefix-in-url-patterns). 174 | 175 | ```python 176 | from openapi_tester import SchemaTester 177 | 178 | schema_tester = SchemaTester(field_key_map={ 179 | "language": "en", 180 | }) 181 | ``` 182 | 183 | ## Schema Validation 184 | 185 | When the SchemaTester loads a schema, it parses it using an 186 | [OpenAPI spec validator](https://github.com/p1c2u/openapi-spec-validator). This validates the schema. 187 | In case of issues with the schema itself, the validator will raise the appropriate error. 188 | 189 | ## Django testing client 190 | 191 | The library includes an `OpenAPIClient`, which extends Django REST framework's 192 | [`APIClient` class](https://www.django-rest-framework.org/api-guide/testing/#apiclient). 193 | If you wish to validate each response against OpenAPI schema when writing 194 | unit tests - `OpenAPIClient` is what you need! 195 | 196 | To use `OpenAPIClient` simply pass `SchemaTester` instance that should be used 197 | to validate responses and then use it like regular Django testing client: 198 | 199 | ```python 200 | schema_tester = SchemaTester() 201 | client = OpenAPIClient(schema_tester=schema_tester) 202 | response = client.get('/api/v1/tests/123/') 203 | ``` 204 | 205 | To force all developers working on the project to use `OpenAPIClient` simply 206 | override the `client` fixture (when using `pytest` with `pytest-django`): 207 | 208 | ```python 209 | from pytest_django.lazy_django import skip_if_no_django 210 | 211 | from openapi_tester.schema_tester import SchemaTester 212 | 213 | 214 | @pytest.fixture 215 | def schema_tester(): 216 | return SchemaTester() 217 | 218 | 219 | @pytest.fixture 220 | def client(schema_tester): 221 | skip_if_no_django() 222 | 223 | from openapi_tester.clients import OpenAPIClient 224 | 225 | return OpenAPIClient(schema_tester=schema_tester) 226 | ``` 227 | 228 | If you are using plain Django test framework, we suggest to create custom 229 | test case implementation and use it instead of original Django one: 230 | 231 | ```python 232 | import functools 233 | 234 | from django.test.testcases import SimpleTestCase 235 | from openapi_tester.clients import OpenAPIClient 236 | from openapi_tester.schema_tester import SchemaTester 237 | 238 | schema_tester = SchemaTester() 239 | 240 | 241 | class MySimpleTestCase(SimpleTestCase): 242 | client_class = OpenAPIClient 243 | # or use `functools.partial` when you want to provide custom 244 | # ``SchemaTester`` instance: 245 | # client_class = functools.partial(OpenAPIClient, schema_tester=schema_tester) 246 | ``` 247 | 248 | This will ensure you all newly implemented views will be validated against 249 | the OpenAPI schema. 250 | 251 | ## Known Issues 252 | 253 | * We are using [prance](https://github.com/jfinkhaeuser/prance) as a schema resolver, and it has some issues with the 254 | resolution of (very) complex OpenAPI 2.0 schemas. If you encounter 255 | issues, [please document them here](https://github.com/snok/drf-openapi-tester/issues/205). 256 | 257 | ## Contributing 258 | 259 | Contributions are welcome. Please see the [contributing guide](https://github.com/snok/.github/blob/main/CONTRIBUTING.md) 260 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | def main() -> None: 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?" 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /openapi_tester/__init__.py: -------------------------------------------------------------------------------- 1 | """ DRF OpenAPI Schema Tester """ 2 | from .case_testers import is_camel_case, is_kebab_case, is_pascal_case, is_snake_case 3 | from .clients import OpenAPIClient 4 | from .loaders import BaseSchemaLoader, DrfSpectacularSchemaLoader, DrfYasgSchemaLoader, StaticSchemaLoader 5 | from .schema_tester import SchemaTester 6 | 7 | __all__ = [ 8 | "BaseSchemaLoader", 9 | "DrfSpectacularSchemaLoader", 10 | "DrfYasgSchemaLoader", 11 | "SchemaTester", 12 | "StaticSchemaLoader", 13 | "is_camel_case", 14 | "is_kebab_case", 15 | "is_pascal_case", 16 | "is_snake_case", 17 | "OpenAPIClient", 18 | ] 19 | -------------------------------------------------------------------------------- /openapi_tester/case_testers.py: -------------------------------------------------------------------------------- 1 | """ Case testers - this module includes helper functions to test key casing """ 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | from inflection import camelize, dasherize, underscore 7 | 8 | from openapi_tester.exceptions import CaseError 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any, Callable 12 | 13 | 14 | def _create_tester(casing: str, handler: Callable[[Any], str]) -> Callable[[str], None]: 15 | """factory function for creating testers""" 16 | 17 | def tester(key: str) -> None: 18 | stripped = key.strip() 19 | if stripped and handler(stripped) != stripped: 20 | raise CaseError(key=key, case=casing, expected=handler(key)) 21 | 22 | return tester 23 | 24 | 25 | def _camelize(string: str) -> str: 26 | return camelize(underscore(string), False) 27 | 28 | 29 | def _pascalize(string: str) -> str: 30 | return camelize(underscore(string)) 31 | 32 | 33 | def _kebabize(string: str) -> str: 34 | return dasherize(underscore(string)) 35 | 36 | 37 | is_camel_case = _create_tester("camelCased", _camelize) 38 | is_kebab_case = _create_tester("kebab-cased", _kebabize) 39 | is_pascal_case = _create_tester("PascalCased", _pascalize) 40 | is_snake_case = _create_tester("snake_cased", underscore) 41 | -------------------------------------------------------------------------------- /openapi_tester/clients.py: -------------------------------------------------------------------------------- 1 | """Subclass of ``APIClient`` using ``SchemaTester`` to validate responses.""" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | from rest_framework.test import APIClient 7 | 8 | from .schema_tester import SchemaTester 9 | 10 | if TYPE_CHECKING: 11 | from rest_framework.response import Response 12 | 13 | 14 | class OpenAPIClient(APIClient): 15 | """``APIClient`` validating responses against OpenAPI schema.""" 16 | 17 | def __init__( 18 | self, 19 | *args, 20 | schema_tester: SchemaTester | None = None, 21 | **kwargs, 22 | ) -> None: 23 | """Initialize ``OpenAPIClient`` instance.""" 24 | super().__init__(*args, **kwargs) 25 | self.schema_tester = schema_tester or self._schema_tester_factory() 26 | 27 | def request(self, **kwargs) -> Response: # type: ignore[override] 28 | """Validate fetched response against given OpenAPI schema.""" 29 | response = super().request(**kwargs) 30 | self.schema_tester.validate_response(response) 31 | return response 32 | 33 | @staticmethod 34 | def _schema_tester_factory() -> SchemaTester: 35 | """Factory of default ``SchemaTester`` instances.""" 36 | return SchemaTester() 37 | -------------------------------------------------------------------------------- /openapi_tester/constants.py: -------------------------------------------------------------------------------- 1 | """ Constants module """ 2 | OPENAPI_PYTHON_MAPPING = { 3 | "boolean": bool.__name__, 4 | "string": str.__name__, 5 | "file": str.__name__, 6 | "array": list.__name__, 7 | "object": dict.__name__, 8 | "integer": int.__name__, 9 | "number": f"{int.__name__} or {float.__name__}", 10 | } 11 | 12 | # Validation errors 13 | VALIDATE_FORMAT_ERROR = 'Expected: {article} "{format}" formatted value\n\nReceived: {received}' 14 | VALIDATE_PATTERN_ERROR = 'The string "{data}" does not match the specified pattern: {pattern}' 15 | INVALID_PATTERN_ERROR = "String pattern is not valid regex: {pattern}" 16 | VALIDATE_ENUM_ERROR = "Expected: a member of the enum {enum}\n\nReceived: {received}" 17 | VALIDATE_TYPE_ERROR = 'Expected: {article} "{type}" type value\n\nReceived: {received}' 18 | VALIDATE_MULTIPLE_OF_ERROR = "The response value {data} should be a multiple of {multiple}" 19 | VALIDATE_MINIMUM_ERROR = "The response value {data} is lower than the specified minimum of {minimum}" 20 | VALIDATE_MAXIMUM_ERROR = "The response value {data} exceeds the maximum allowed value of {maximum}" 21 | VALIDATE_MIN_LENGTH_ERROR = 'The length of "{data}" is shorter than the specified minimum length of {min_length}' 22 | VALIDATE_MAX_LENGTH_ERROR = 'The length of "{data}" exceeds the specified maximum length of {max_length}' 23 | VALIDATE_MIN_ARRAY_LENGTH_ERROR = ( 24 | "The length of the array {data} is shorter than the specified minimum length of {min_length}" 25 | ) 26 | VALIDATE_MAX_ARRAY_LENGTH_ERROR = "The length of the array {data} exceeds the specified maximum length of {max_length}" 27 | VALIDATE_MINIMUM_NUMBER_OF_PROPERTIES_ERROR = ( 28 | "The number of properties in {data} is fewer than the specified minimum number of properties of {min_length}" 29 | ) 30 | VALIDATE_MAXIMUM_NUMBER_OF_PROPERTIES_ERROR = ( 31 | "The number of properties in {data} exceeds the specified maximum number of properties of {max_length}" 32 | ) 33 | VALIDATE_UNIQUE_ITEMS_ERROR = "The array {data} must contain unique items only" 34 | VALIDATE_NONE_ERROR = "Received a null value for a non-nullable schema object" 35 | VALIDATE_MISSING_RESPONSE_KEY_ERROR = 'The following property is missing in the response data: "{missing_key}"' 36 | VALIDATE_EXCESS_RESPONSE_KEY_ERROR = ( 37 | 'The following property was found in the response, but is missing from the schema definition: "{excess_key}"' 38 | ) 39 | VALIDATE_WRITE_ONLY_RESPONSE_KEY_ERROR = ( 40 | 'The following property was found in the response, but is documented as being "writeOnly": "{write_only_key}"' 41 | ) 42 | VALIDATE_ONE_OF_ERROR = "Expected data to match one and only one of the oneOf schema types; found {matches} matches" 43 | VALIDATE_ANY_OF_ERROR = "Expected data to match one or more of the documented anyOf schema types, but found no matches" 44 | UNDOCUMENTED_SCHEMA_SECTION_ERROR = "Error: Unsuccessfully tried to index the OpenAPI schema by `{key}`. {error_addon}" 45 | INIT_ERROR = "Unable to configure loader" 46 | -------------------------------------------------------------------------------- /openapi_tester/exceptions.py: -------------------------------------------------------------------------------- 1 | """ Exceptions Module """ 2 | 3 | 4 | class DocumentationError(AssertionError): 5 | """ 6 | Custom exception raised when package tests fail. 7 | """ 8 | 9 | pass 10 | 11 | 12 | class CaseError(DocumentationError): 13 | """ 14 | Custom exception raised when items are not cased correctly. 15 | """ 16 | 17 | def __init__(self, key: str, case: str, expected: str) -> None: 18 | super().__init__(f"The response key `{key}` is not properly {case}. Expected value: {expected}") 19 | 20 | 21 | class OpenAPISchemaError(Exception): 22 | """ 23 | Custom exception raised for invalid schema specifications. 24 | """ 25 | 26 | pass 27 | 28 | 29 | class UndocumentedSchemaSectionError(OpenAPISchemaError): 30 | """ 31 | Subset of OpenAPISchemaError, raised when we cannot find a single schema section. 32 | """ 33 | 34 | pass 35 | -------------------------------------------------------------------------------- /openapi_tester/loaders.py: -------------------------------------------------------------------------------- 1 | """ Loaders Module """ 2 | from __future__ import annotations 3 | 4 | import difflib 5 | import json 6 | import pathlib 7 | import re 8 | from json import dumps, loads 9 | from typing import TYPE_CHECKING, cast 10 | from urllib.parse import urlparse 11 | 12 | import requests 13 | import yaml 14 | from django.urls import Resolver404, resolve 15 | from django.utils.functional import cached_property 16 | from openapi_spec_validator import openapi_v2_spec_validator, openapi_v30_spec_validator, openapi_v31_spec_validator 17 | from prance.util.resolver import RefResolver 18 | from rest_framework.schemas.generators import BaseSchemaGenerator, EndpointEnumerator 19 | from rest_framework.settings import api_settings 20 | 21 | from openapi_tester.constants import UNDOCUMENTED_SCHEMA_SECTION_ERROR 22 | from openapi_tester.exceptions import UndocumentedSchemaSectionError 23 | 24 | if TYPE_CHECKING: 25 | from typing import Any, Callable 26 | from urllib.parse import ParseResult 27 | 28 | from django.urls import ResolverMatch 29 | from rest_framework.views import APIView 30 | 31 | 32 | def handle_recursion_limit(schema: dict) -> Callable: 33 | """ 34 | We are using a currying pattern to pass schema into the scope of the handler. 35 | """ 36 | 37 | # noinspection PyUnusedLocal 38 | def handler(iteration: int, parse_result: ParseResult, recursions: tuple): # pylint: disable=unused-argument 39 | fragment = parse_result.fragment 40 | keys = [key for key in fragment.split("/") if key] 41 | definition = schema 42 | for key in keys: 43 | definition = definition[key] 44 | return definition 45 | 46 | return handler 47 | 48 | 49 | class BaseSchemaLoader: 50 | """ 51 | Base class for OpenAPI schema loading classes. 52 | 53 | Contains a template of methods that are required from a loader class, and a range of helper methods for interacting 54 | with an OpenAPI schema. 55 | """ 56 | 57 | base_path = "/" 58 | field_key_map: dict[str, str] 59 | schema: dict | None = None 60 | 61 | def __init__(self, field_key_map: dict[str, str] | None = None): 62 | super().__init__() 63 | self.schema: dict | None = None 64 | self.field_key_map = field_key_map or {} 65 | 66 | def load_schema(self) -> dict: 67 | """ 68 | Put logic required to load a schema and return it here. 69 | """ 70 | raise NotImplementedError("The `load_schema` method has to be overwritten.") 71 | 72 | def get_schema(self) -> dict: 73 | """ 74 | Returns OpenAPI schema. 75 | """ 76 | if self.schema: 77 | return self.schema 78 | self.set_schema(self.load_schema()) 79 | return self.get_schema() 80 | 81 | def de_reference_schema(self, schema: dict) -> dict: 82 | url = schema.get("basePath", self.base_path) 83 | recursion_handler = handle_recursion_limit(schema) 84 | resolver = RefResolver( 85 | schema, 86 | recursion_limit_handler=recursion_handler, 87 | recursion_limit=10, 88 | url=url, 89 | ) 90 | resolver.resolve_references() 91 | return resolver.specs 92 | 93 | def normalize_schema_paths(self, schema: dict) -> dict[str, dict]: 94 | normalized_paths: dict[str, dict] = {} 95 | for key, value in schema["paths"].items(): 96 | try: 97 | parameterized_path, _ = self.resolve_path(endpoint_path=key, method=list(value.keys())[0]) 98 | normalized_paths[parameterized_path] = value 99 | except ValueError: 100 | normalized_paths[key] = value 101 | return {**schema, "paths": normalized_paths} 102 | 103 | @staticmethod 104 | def validate_schema(schema: dict): 105 | if "openapi" in schema: 106 | openapi_version_pattern = re.compile(r"^(\d)\.(\d+)") 107 | result = openapi_version_pattern.findall(schema["openapi"]) 108 | if result: 109 | major, minor = result[0] 110 | if (major, minor) == ("3", "0"): 111 | validator = openapi_v30_spec_validator 112 | elif (major, minor) == ("3", "1"): 113 | validator = openapi_v31_spec_validator 114 | else: 115 | raise UndocumentedSchemaSectionError( 116 | UNDOCUMENTED_SCHEMA_SECTION_ERROR.format( 117 | key=schema["openapi"], error_addon="Support might need to be added." 118 | ) 119 | ) 120 | else: 121 | raise UndocumentedSchemaSectionError(UNDOCUMENTED_SCHEMA_SECTION_ERROR.format(key=schema["openapi"])) 122 | else: 123 | validator = openapi_v2_spec_validator 124 | validator.validate(schema) 125 | 126 | def set_schema(self, schema: dict) -> None: 127 | """ 128 | Sets self.schema and self.original_schema. 129 | """ 130 | de_referenced_schema = self.de_reference_schema(schema) 131 | self.validate_schema(de_referenced_schema) 132 | self.schema = self.normalize_schema_paths(de_referenced_schema) 133 | 134 | @cached_property 135 | def endpoints(self) -> list[str]: 136 | """ 137 | Returns a list of endpoint paths. 138 | """ 139 | return list({endpoint[0] for endpoint in EndpointEnumerator().get_api_endpoints()}) 140 | 141 | def resolve_path(self, endpoint_path: str, method: str) -> tuple[str, ResolverMatch]: 142 | """ 143 | Resolves a Django path. 144 | """ 145 | url_object = urlparse(endpoint_path) 146 | parsed_path = url_object.path 147 | if not parsed_path.startswith("/"): 148 | parsed_path = "/" + parsed_path 149 | for key, value in self.field_key_map.items(): 150 | if value != "pk" and key in parsed_path: 151 | parsed_path = parsed_path.replace(f"{{{key}}}", value) 152 | for path in [parsed_path, parsed_path[:-1]]: 153 | try: 154 | resolved_route = resolve(path) 155 | except Resolver404: 156 | continue 157 | else: 158 | for key, value in reversed(list(resolved_route.kwargs.items())): 159 | index = path.rfind(str(value)) 160 | path = f"{path[:index]}{{{key}}}{path[index + len(str(value)):]}" 161 | if "{pk}" in path and api_settings.SCHEMA_COERCE_PATH_PK: # noqa: FS003 162 | path, resolved_route = self.handle_pk_parameter( 163 | resolved_route=resolved_route, path=path, method=method 164 | ) 165 | return path, resolved_route 166 | message = f"Could not resolve path `{endpoint_path}`." 167 | close_matches = difflib.get_close_matches(endpoint_path, self.endpoints) 168 | if close_matches: 169 | message += "\n\nDid you mean one of these?\n\n- " + "\n- ".join(close_matches) 170 | raise ValueError(message) 171 | 172 | @staticmethod 173 | def handle_pk_parameter(resolved_route: ResolverMatch, path: str, method: str) -> tuple[str, ResolverMatch]: 174 | """ 175 | Handle the DRF conversion of params called {pk} into a named parameter based on Model field 176 | """ 177 | coerced_path = BaseSchemaGenerator().coerce_path( 178 | path=path, method=method, view=cast("APIView", resolved_route.func) 179 | ) 180 | pk_field_name = "".join( 181 | entry.replace("+ ", "") for entry in difflib.Differ().compare(path, coerced_path) if "+ " in entry 182 | ) 183 | resolved_route.kwargs[pk_field_name] = resolved_route.kwargs["pk"] 184 | del resolved_route.kwargs["pk"] 185 | return coerced_path, resolved_route 186 | 187 | 188 | class DrfYasgSchemaLoader(BaseSchemaLoader): 189 | """ 190 | Loads OpenAPI schema generated by drf_yasg. 191 | """ 192 | 193 | def __init__(self, field_key_map: dict[str, str] | None = None) -> None: 194 | super().__init__(field_key_map=field_key_map) 195 | from drf_yasg.generators import OpenAPISchemaGenerator 196 | from drf_yasg.openapi import Info 197 | 198 | self.schema_generator = OpenAPISchemaGenerator(info=Info(title="", default_version="")) 199 | 200 | def load_schema(self) -> dict: 201 | """ 202 | Loads generated schema from drf-yasg and returns it as a dict. 203 | """ 204 | odict_schema = self.schema_generator.get_schema(None, True) 205 | return cast("dict", loads(dumps(odict_schema.as_odict()))) 206 | 207 | def resolve_path(self, endpoint_path: str, method: str) -> tuple[str, ResolverMatch]: 208 | de_parameterized_path, resolved_path = super().resolve_path(endpoint_path=endpoint_path, method=method) 209 | path_prefix = self.schema_generator.determine_path_prefix(self.endpoints) 210 | trim_length = len(path_prefix) if path_prefix != "/" else 0 211 | return de_parameterized_path[trim_length:], resolved_path 212 | 213 | 214 | class DrfSpectacularSchemaLoader(BaseSchemaLoader): 215 | """ 216 | Loads OpenAPI schema generated by drf_spectacular. 217 | """ 218 | 219 | def __init__(self, field_key_map: dict[str, str] | None = None) -> None: 220 | super().__init__(field_key_map=field_key_map) 221 | from drf_spectacular.generators import SchemaGenerator 222 | 223 | self.schema_generator = SchemaGenerator() 224 | 225 | def load_schema(self) -> dict: 226 | """ 227 | Loads generated schema from drf_spectacular and returns it as a dict. 228 | """ 229 | return cast("dict", loads(dumps(self.schema_generator.get_schema(public=True)))) 230 | 231 | def resolve_path(self, endpoint_path: str, method: str) -> tuple[str, ResolverMatch]: 232 | from drf_spectacular.settings import spectacular_settings 233 | 234 | de_parameterized_path, resolved_path = super().resolve_path(endpoint_path=endpoint_path, method=method) 235 | return ( 236 | de_parameterized_path[len(spectacular_settings.SCHEMA_PATH_PREFIX or "") :], 237 | resolved_path, 238 | ) 239 | 240 | 241 | class StaticSchemaLoader(BaseSchemaLoader): 242 | """ 243 | Loads OpenAPI schema from a static file. 244 | """ 245 | 246 | def __init__(self, path: str, field_key_map: dict[str, str] | None = None): 247 | super().__init__(field_key_map=field_key_map) 248 | self.path = path if not isinstance(path, pathlib.PosixPath) else str(path) 249 | 250 | def load_schema(self) -> dict[str, Any]: 251 | """ 252 | Loads a static OpenAPI schema from file, and parses it to a python dict. 253 | 254 | :return: Schema contents as a dict 255 | :raises: ImproperlyConfigured 256 | """ 257 | with open(self.path, encoding="utf-8") as file: 258 | content = file.read() 259 | return cast( 260 | "dict", json.loads(content) if ".json" in self.path else yaml.load(content, Loader=yaml.FullLoader) 261 | ) 262 | 263 | 264 | class UrlStaticSchemaLoader(BaseSchemaLoader): 265 | """ 266 | Loads OpenAPI schema from an url static file. 267 | """ 268 | 269 | def __init__(self, url: str, field_key_map: dict[str, str] | None = None): 270 | super().__init__(field_key_map=field_key_map) 271 | self.url = url 272 | 273 | def load_schema(self) -> dict[str, Any]: 274 | """ 275 | Loads a static OpenAPI schema from url, and parses it to a python dict. 276 | 277 | :return: Schema contents as a dict 278 | :raises: ImproperlyConfigured 279 | """ 280 | response = requests.get(self.url, timeout=20) 281 | return cast( 282 | "dict", 283 | ( 284 | json.loads(response.content) 285 | if ".json" in self.url 286 | else yaml.load(response.content, Loader=yaml.FullLoader) 287 | ), 288 | ) 289 | -------------------------------------------------------------------------------- /openapi_tester/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/openapi_tester/py.typed -------------------------------------------------------------------------------- /openapi_tester/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils Module - this file contains utility functions used in multiple places. 3 | """ 4 | from __future__ import annotations 5 | 6 | from copy import deepcopy 7 | from itertools import chain, combinations 8 | from typing import TYPE_CHECKING 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any, Iterator, Sequence 12 | 13 | 14 | def merge_objects(dictionaries: Sequence[dict[str, Any]]) -> dict[str, Any]: 15 | """ 16 | Deeply merge objects. 17 | """ 18 | output: dict[str, Any] = {} 19 | for dictionary in dictionaries: 20 | for key, value in dictionary.items(): 21 | if key not in output: 22 | output[key] = value 23 | continue 24 | current_value = output[key] 25 | if isinstance(current_value, list) and isinstance(value, list): 26 | output[key] = list(chain(output[key], value)) 27 | continue 28 | if isinstance(current_value, dict) and isinstance(value, dict): 29 | output[key] = merge_objects([current_value, value]) 30 | continue 31 | return output 32 | 33 | 34 | def normalize_schema_section(schema_section: dict[str, Any]) -> dict[str, Any]: 35 | """ 36 | Remove allOf and handle edge uses of oneOf. 37 | """ 38 | output: dict[str, Any] = deepcopy(schema_section) 39 | if output.get("allOf"): 40 | all_of = output.pop("allOf") 41 | output = {**output, **merge_objects(all_of)} 42 | if output.get("oneOf") and all(item.get("enum") for item in output["oneOf"]): 43 | # handle the way drf-spectacular is doing enums 44 | one_of = output.pop("oneOf") 45 | output = {**output, **merge_objects(one_of)} 46 | for key, value in output.items(): 47 | if isinstance(value, dict): 48 | output[key] = normalize_schema_section(value) 49 | elif isinstance(value, list): 50 | output[key] = [normalize_schema_section(entry) if isinstance(entry, dict) else entry for entry in value] 51 | return output 52 | 53 | 54 | def lazy_combinations(options_list: Sequence[dict[str, Any]]) -> Iterator[dict]: 55 | """ 56 | Lazily evaluate possible combinations. 57 | """ 58 | for i in range(2, len(options_list) + 1): 59 | for combination in combinations(options_list, i): 60 | yield merge_objects(combination) 61 | -------------------------------------------------------------------------------- /openapi_tester/validators.py: -------------------------------------------------------------------------------- 1 | """ Schema Validators """ 2 | from __future__ import annotations 3 | 4 | import base64 5 | import json 6 | import re 7 | from typing import TYPE_CHECKING 8 | from uuid import UUID 9 | 10 | from django.core.exceptions import ValidationError 11 | from django.core.validators import EmailValidator, URLValidator, validate_ipv4_address, validate_ipv6_address 12 | from django.utils.dateparse import parse_date, parse_datetime, parse_time 13 | 14 | from openapi_tester.constants import ( 15 | INVALID_PATTERN_ERROR, 16 | VALIDATE_ENUM_ERROR, 17 | VALIDATE_FORMAT_ERROR, 18 | VALIDATE_MAX_ARRAY_LENGTH_ERROR, 19 | VALIDATE_MAX_LENGTH_ERROR, 20 | VALIDATE_MAXIMUM_ERROR, 21 | VALIDATE_MAXIMUM_NUMBER_OF_PROPERTIES_ERROR, 22 | VALIDATE_MIN_ARRAY_LENGTH_ERROR, 23 | VALIDATE_MIN_LENGTH_ERROR, 24 | VALIDATE_MINIMUM_ERROR, 25 | VALIDATE_MINIMUM_NUMBER_OF_PROPERTIES_ERROR, 26 | VALIDATE_MULTIPLE_OF_ERROR, 27 | VALIDATE_PATTERN_ERROR, 28 | VALIDATE_TYPE_ERROR, 29 | VALIDATE_UNIQUE_ITEMS_ERROR, 30 | ) 31 | from openapi_tester.exceptions import OpenAPISchemaError 32 | 33 | if TYPE_CHECKING: 34 | from typing import Any, Callable 35 | 36 | 37 | def create_validator(validation_fn: Callable, wrap_as_validator: bool = False) -> Callable[[Any], bool]: 38 | def wrapped(value: Any) -> bool: 39 | try: 40 | return bool(validation_fn(value)) or not wrap_as_validator 41 | except (ValueError, ValidationError): 42 | return False 43 | 44 | return wrapped 45 | 46 | 47 | number_format_validator = create_validator( 48 | lambda x: isinstance(x, float) if x != 0 else isinstance(x, (int, float)), True 49 | ) 50 | 51 | base64_format_validator = create_validator(lambda x: base64.b64encode(base64.b64decode(x, validate=True)) == x) 52 | 53 | VALIDATOR_MAP: dict[str, Callable] = { 54 | # by type 55 | "string": create_validator(lambda x: isinstance(x, str), True), 56 | "file": create_validator(lambda x: isinstance(x, str), True), 57 | "boolean": create_validator(lambda x: isinstance(x, bool), True), 58 | "integer": create_validator(lambda x: isinstance(x, int) and not isinstance(x, bool), True), 59 | "number": create_validator(lambda x: isinstance(x, (float, int)) and not isinstance(x, bool), True), 60 | "object": create_validator(lambda x: isinstance(x, dict), True), 61 | "array": create_validator(lambda x: isinstance(x, list), True), 62 | # by format 63 | "byte": base64_format_validator, 64 | "base64": base64_format_validator, 65 | "date": create_validator(parse_date, True), 66 | "date-time": create_validator(parse_datetime, True), 67 | "double": number_format_validator, 68 | "email": create_validator(EmailValidator()), 69 | "float": number_format_validator, 70 | "ipv4": create_validator(validate_ipv4_address), 71 | "ipv6": create_validator(validate_ipv6_address), 72 | "time": create_validator(parse_time, True), 73 | "uri": create_validator(URLValidator()), 74 | "url": create_validator(URLValidator()), 75 | "uuid": create_validator(UUID), 76 | } 77 | 78 | 79 | def validate_type(schema_section: dict[str, Any], data: Any) -> str | None: 80 | schema_type: str = schema_section.get("type", "object") 81 | if not VALIDATOR_MAP[schema_type](data): 82 | an_articles = ["integer", "object", "array"] 83 | return VALIDATE_TYPE_ERROR.format( 84 | article="a" if schema_type not in an_articles else "an", 85 | type=schema_type, 86 | received=f'"{data}"' if isinstance(data, str) else data, 87 | ) 88 | return None 89 | 90 | 91 | def validate_format(schema_section: dict[str, Any], data: Any) -> str | None: 92 | schema_format: str = schema_section.get("format", "") 93 | if schema_format in VALIDATOR_MAP and not VALIDATOR_MAP[schema_format](data): 94 | return VALIDATE_FORMAT_ERROR.format( 95 | article="an" if format in ["ipv4", "ipv6", "email"] else "a", 96 | format=schema_format, 97 | received=f'"{data}"', 98 | ) 99 | return None 100 | 101 | 102 | def validate_enum(schema_section: dict[str, Any], data: Any) -> str | None: 103 | enum = schema_section.get("enum") 104 | if enum and data not in enum: 105 | return VALIDATE_ENUM_ERROR.format(enum=schema_section["enum"], received=f'"{data}"') 106 | return None 107 | 108 | 109 | def validate_pattern(schema_section: dict[str, Any], data: str) -> str | None: 110 | pattern = schema_section.get("pattern") 111 | if not pattern: 112 | return None 113 | try: 114 | compiled_pattern = re.compile(pattern) 115 | except re.error as e: 116 | raise OpenAPISchemaError(INVALID_PATTERN_ERROR.format(pattern=pattern)) from e 117 | if not compiled_pattern.match(str(data)): 118 | return VALIDATE_PATTERN_ERROR.format(data=data, pattern=pattern) 119 | return None 120 | 121 | 122 | def validate_multiple_of(schema_section: dict[str, Any], data: int | float) -> str | None: 123 | multiple = schema_section.get("multipleOf") 124 | if multiple and data % multiple != 0: 125 | return VALIDATE_MULTIPLE_OF_ERROR.format(data=data, multiple=multiple) 126 | return None 127 | 128 | 129 | def validate_maximum(schema_section: dict[str, Any], data: int | float) -> str | None: 130 | maximum = schema_section.get("maximum") 131 | exclusive_maximum = schema_section.get("exclusiveMaximum") 132 | if maximum and exclusive_maximum and data >= maximum: 133 | return VALIDATE_MAXIMUM_ERROR.format(data=data, maximum=maximum - 1) 134 | if maximum and not exclusive_maximum and data > maximum: 135 | return VALIDATE_MAXIMUM_ERROR.format(data=data, maximum=maximum) 136 | return None 137 | 138 | 139 | def validate_minimum(schema_section: dict[str, Any], data: int | float) -> str | None: 140 | minimum = schema_section.get("minimum") 141 | exclusive_minimum = schema_section.get("exclusiveMinimum") 142 | if minimum and exclusive_minimum and data <= minimum: 143 | return VALIDATE_MINIMUM_ERROR.format(data=data, minimum=minimum + 1) 144 | if minimum and not exclusive_minimum and data < minimum: 145 | return VALIDATE_MINIMUM_ERROR.format(data=data, minimum=minimum) 146 | return None 147 | 148 | 149 | def validate_unique_items(schema_section: dict[str, Any], data: list[Any]) -> str | None: 150 | unique_items = schema_section.get("uniqueItems") 151 | if unique_items: 152 | comparison_data = (json.dumps(item, sort_keys=True) if isinstance(item, dict) else item for item in data) 153 | if len(set(comparison_data)) != len(data): 154 | return VALIDATE_UNIQUE_ITEMS_ERROR.format(data=data) 155 | return None 156 | 157 | 158 | def validate_min_length(schema_section: dict[str, Any], data: str) -> str | None: 159 | min_length: int | None = schema_section.get("minLength") 160 | if min_length and len(data) < min_length: 161 | return VALIDATE_MIN_LENGTH_ERROR.format(data=data, min_length=min_length) 162 | return None 163 | 164 | 165 | def validate_max_length(schema_section: dict[str, Any], data: str) -> str | None: 166 | max_length: int | None = schema_section.get("maxLength") 167 | if max_length and len(data) > max_length: 168 | return VALIDATE_MAX_LENGTH_ERROR.format(data=data, max_length=max_length) 169 | return None 170 | 171 | 172 | def validate_min_items(schema_section: dict[str, Any], data: list) -> str | None: 173 | min_length: int | None = schema_section.get("minItems") 174 | if min_length and len(data) < min_length: 175 | return VALIDATE_MIN_ARRAY_LENGTH_ERROR.format(data=data, min_length=min_length) 176 | return None 177 | 178 | 179 | def validate_max_items(schema_section: dict[str, Any], data: list) -> str | None: 180 | max_length: int | None = schema_section.get("maxItems") 181 | if max_length and len(data) > max_length: 182 | return VALIDATE_MAX_ARRAY_LENGTH_ERROR.format(data=data, max_length=max_length) 183 | return None 184 | 185 | 186 | def validate_min_properties(schema_section: dict[str, Any], data: dict) -> str | None: 187 | min_properties: int | None = schema_section.get("minProperties") 188 | if min_properties and len(data.keys()) < int(min_properties): 189 | return VALIDATE_MINIMUM_NUMBER_OF_PROPERTIES_ERROR.format(data=data, min_length=min_properties) 190 | return None 191 | 192 | 193 | def validate_max_properties(schema_section: dict[str, Any], data: dict) -> str | None: 194 | max_properties: int | None = schema_section.get("maxProperties") 195 | if max_properties and len(data.keys()) > int(max_properties): 196 | return VALIDATE_MAXIMUM_NUMBER_OF_PROPERTIES_ERROR.format(data=data, max_length=max_properties) 197 | return None 198 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "drf-openapi-tester" 3 | version = "2.3.3" 4 | description = "Test utility for validating OpenAPI response documentation" 5 | authors = ["Sondre Lillebø Gundersen ", "Na'aman Hirschfeld "] 6 | license = "BSD-4-Clause" 7 | readme = "README.md" 8 | homepage = "https://github.com/snok/drf-openapi-tester" 9 | repository = "https://github.com/snok/drf-openapi-tester" 10 | documentation = "https://github.com/snok/drf-openapi-tester" 11 | keywords = ["openapi", "swagger", "api", "testing", "schema", "django", "drf"] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Environment :: Web Environment", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: BSD License", 17 | "Natural Language :: English", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.7", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Framework :: Django", 27 | 'Framework :: Django :: 3.0', 28 | 'Framework :: Django :: 3.1', 29 | 'Framework :: Django :: 3.2', 30 | 'Framework :: Django :: 4.0', 31 | 'Framework :: Django :: 4.1', 32 | "Framework :: Pytest", 33 | "Topic :: Software Development", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: Software Development :: Libraries :: Application Frameworks", 36 | "Topic :: Software Development :: Libraries :: Python Modules", 37 | "Topic :: Documentation", 38 | "Topic :: Software Development :: Testing", 39 | "Topic :: Software Development :: Testing :: Unit", 40 | "Topic :: Utilities", 41 | "Typing :: Typed", 42 | ] 43 | include = ["CHANGELOG.md"] 44 | packages = [ 45 | { include = "openapi_tester" }, 46 | ] 47 | 48 | [tool.poetry.dependencies] 49 | python = "^3.7" 50 | django = [ 51 | { version = '^3', python = '<=3.7' }, 52 | { version = '^3 || ^4', python = '>=3.8' }, 53 | ] 54 | djangorestframework = "*" 55 | inflection = "*" 56 | openapi-spec-validator = ">=0.4" 57 | prance = "*" 58 | pyYAML = "*" 59 | drf-spectacular = { version = "*", optional = true } 60 | drf-yasg = { version = "*", optional = true } 61 | 62 | [tool.poetry.extras] 63 | drf-yasg = ["drf-yasg"] 64 | drf-spectacular = ["drf-spectacular"] 65 | 66 | [tool.poetry.dev-dependencies] 67 | coverage = { extras = ["toml"], version = "^6" } 68 | Faker = "*" 69 | pre-commit = "*" 70 | pylint = "*" 71 | pytest = "*" 72 | pytest-django = "*" 73 | 74 | [build-system] 75 | requires = ["poetry>=0.12"] 76 | build-backend = "poetry.masonry.api" 77 | 78 | [tool.black] 79 | line-length = 120 80 | preview = true 81 | quiet = true 82 | include = '\.pyi?$' 83 | 84 | [tool.isort] 85 | profile = "black" 86 | line_length = 120 87 | 88 | [tool.pylint.FORMAT] 89 | max-line-length = 120 90 | 91 | [tool.pylint.MESSAGE_CONTROL] 92 | disable = """ 93 | unsubscriptable-object, 94 | unnecessary-pass, 95 | missing-function-docstring, 96 | import-outside-toplevel, 97 | fixme, 98 | line-too-long, 99 | """ 100 | enable = "useless-suppression" 101 | 102 | [tool.pylint.DESIGN] 103 | max-args = 6 104 | max-returns = 21 105 | max-branches = 20 106 | max-locals = 20 107 | 108 | [tool.pylint.BASIC] 109 | good-names = "_,e,i" 110 | 111 | [tool.coverage.run] 112 | source = [ 113 | "openapi_tester", 114 | ] 115 | omit = [ 116 | "manage.py", 117 | "test_project/*", 118 | ] 119 | branch = true 120 | 121 | [tool.coverage.report] 122 | show_missing = true 123 | skip_covered = true 124 | exclude_lines = [ 125 | "raise NotImplementedError", 126 | "pragma: no cover", 127 | "if TYPE_CHECKING:", 128 | ] 129 | 130 | [tool.pytest.ini_options] 131 | DJANGO_SETTINGS_MODULE = "test_project.settings" 132 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore= 3 | # Docstring at the top of a public module 4 | D100 5 | # Docstring at the top of a public class (method is enough) 6 | D101 7 | # Make docstrings one line if it can fit. 8 | D200 9 | D210 10 | # Missing docstring in __init__ 11 | D107 12 | # Missing docstring in public package 13 | D104 14 | # Whitespace before ':'. Black formats code this way. 15 | E203 16 | # 1 blank line required between summary line and description 17 | D205 18 | # Line break before binary operator. W504 will be hit when this is excluded. 19 | W503 20 | # Handle error cases first 21 | SIM106 22 | enable-extensions = 23 | enable-extensions = TC, TC1 24 | exclude = 25 | .git, 26 | .idea, 27 | __pycache__, 28 | venv, 29 | manage.py, 30 | .venv 31 | max-complexity = 16 32 | max-line-length = 120 33 | per-file-ignores = 34 | openapi_tester/constants.py:FS003 35 | tests/*:FS003 36 | test_project/*:FS003 37 | 38 | [mypy] 39 | show_column_numbers = True 40 | show_error_context = False 41 | ignore_missing_imports = True 42 | warn_unused_ignores = True 43 | warn_no_return = False 44 | warn_redundant_casts = True 45 | plugins = 46 | mypy_drf_plugin.main, 47 | mypy_django_plugin.main 48 | 49 | [mypy.plugins.django-stubs] 50 | django_settings_module = "test_project.settings" 51 | 52 | [mypy_django_plugin] 53 | ignore_missing_model_attributes = True 54 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/test_project/api/__init__.py -------------------------------------------------------------------------------- /test_project/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class VehicleSerializer(serializers.Serializer): 5 | class Meta: 6 | swagger_schema_fields = {"example": {"vehicleType": "truck"}} 7 | 8 | vehicle_type = serializers.CharField(max_length=10) 9 | 10 | 11 | class ItemSerializer(serializers.Serializer): 12 | item_type = serializers.CharField(max_length=10) 13 | 14 | 15 | class CarSerializer(serializers.Serializer): 16 | name = serializers.CharField(max_length=254) 17 | color = serializers.CharField(max_length=254) 18 | height = serializers.CharField(max_length=254) 19 | width = serializers.CharField(max_length=254) 20 | length = serializers.CharField(max_length=254) 21 | -------------------------------------------------------------------------------- /test_project/api/swagger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/test_project/api/swagger/__init__.py -------------------------------------------------------------------------------- /test_project/api/swagger/auto_schemas.py: -------------------------------------------------------------------------------- 1 | from drf_yasg.openapi import TYPE_ARRAY, TYPE_OBJECT, Schema 2 | from drf_yasg.utils import swagger_auto_schema 3 | 4 | from test_project.api.serializers import VehicleSerializer 5 | from test_project.api.swagger.responses import generic_error_response, get_cars_200_response, get_trucks_200_response 6 | from test_project.api.swagger.schemas import generic_string_schema 7 | 8 | 9 | def get_cars_auto_schema(): 10 | return swagger_auto_schema( 11 | operation_id="get_cars", 12 | operation_summary="Lists cars", 13 | operation_description="Lists all cars available in this test-project", 14 | responses={ 15 | "200": get_cars_200_response(), 16 | "400": generic_error_response("Bad input. Error: {e}."), 17 | "401": generic_error_response("Bad credentials. Error: {e}."), 18 | "500": generic_error_response("Unexpected error raised when ..."), 19 | }, 20 | ) 21 | 22 | 23 | def get_other_cars_auto_schema(): 24 | return swagger_auto_schema( 25 | operation_id="get_other_cars", 26 | operation_summary="Lists other cars", 27 | operation_description="Lists all other cars available in this test-project", 28 | responses={ 29 | "200": get_cars_200_response(), 30 | "400": generic_error_response("Bad input. Error: {e}."), 31 | "401": generic_error_response("Bad credentials. Error: {e}."), 32 | "500": generic_error_response("Unexpected error raised when ..."), 33 | }, 34 | ) 35 | 36 | 37 | def get_trucks_auto_schema(): 38 | return swagger_auto_schema( 39 | operation_id="get_trucks", 40 | operation_summary="Lists trucks", 41 | operation_description="Lists all trucks available in this test-project", 42 | responses={ 43 | "200": get_trucks_200_response(), 44 | "400": generic_error_response("Bad input. Error: {e}."), 45 | "401": generic_error_response("Bad credentials. Error: {e}."), 46 | "500": generic_error_response("Unexpected error raised when ..."), 47 | }, 48 | ) 49 | 50 | 51 | def get_other_trucks_auto_schema(): 52 | return swagger_auto_schema( 53 | operation_id="get_other_trucks", 54 | operation_summary="Lists other trucks", 55 | operation_description="Lists all other trucks available in this test-project", 56 | responses={ 57 | "200": get_trucks_200_response(), 58 | "400": generic_error_response("Bad input. Error: {e}."), 59 | "401": generic_error_response("Bad credentials. Error: {e}."), 60 | "500": generic_error_response("Unexpected error raised when ..."), 61 | }, 62 | ) 63 | 64 | 65 | def generate_big_schema(counter, item): 66 | if counter > 100: 67 | return Schema(type=TYPE_ARRAY, items=item) 68 | return generate_big_schema(counter + 1, Schema(type=TYPE_ARRAY, items=item)) 69 | 70 | 71 | def post_vehicle_auto_schema(): 72 | return swagger_auto_schema( 73 | operation_id="create_vehicle", 74 | operation_summary="Creates a new vehicle type", 75 | operation_description="Creates a new vehicle type in the database", 76 | request_body=VehicleSerializer, 77 | responses={ 78 | "201": Schema( 79 | type=TYPE_OBJECT, properties={"success": generic_string_schema("this is a response", "description")} 80 | ), 81 | }, 82 | ) 83 | 84 | 85 | def post_item_auto_schema(): 86 | return swagger_auto_schema( 87 | operation_id="create_item", 88 | operation_summary="Creates a new item type", 89 | operation_description="Creates a new item type in the database", 90 | request_body=Schema(type=TYPE_OBJECT, properties={"itemType": generic_string_schema("truck", "type of item")}), 91 | responses={ 92 | "201": Schema( 93 | type=TYPE_OBJECT, 94 | properties={ 95 | "success": Schema( 96 | type=TYPE_OBJECT, 97 | properties={ 98 | "id": generic_string_schema("14082c78-7a4d-451e-b41f-3ff8ab176939", "unique id"), 99 | "itemType": generic_string_schema("truck", "description"), 100 | }, 101 | ) 102 | }, 103 | ), 104 | }, 105 | ) 106 | 107 | 108 | def get_snake_cased_response(): 109 | return swagger_auto_schema( 110 | operation_id="get_snake_cased_response", 111 | operation_summary="Returns a snake-cased response", 112 | operation_description="..", 113 | responses={ 114 | "200": Schema( 115 | title="Success", 116 | type=TYPE_OBJECT, 117 | properties={ 118 | "this_is_snake_case": generic_string_schema(example="test", description="test"), 119 | }, 120 | ), 121 | }, 122 | ) 123 | 124 | 125 | def animals_auto_schema(): 126 | return swagger_auto_schema( 127 | operation_id="get_animals", 128 | operation_summary="List animals", 129 | operation_description="Lists all animals", 130 | responses={ 131 | "200": Schema( 132 | title="Success", 133 | type=TYPE_ARRAY, 134 | items=Schema( 135 | title="Success", 136 | type=TYPE_OBJECT, 137 | properties={ 138 | "test": generic_string_schema(example="test", description="test"), 139 | "test2": generic_string_schema(example="test2", description="test2"), 140 | }, 141 | ), 142 | ), 143 | "400": generic_error_response("Bad input. Error: {e}."), 144 | "401": generic_error_response("Bad credentials. Error: {e}."), 145 | "500": generic_error_response("Unexpected error raised when ..."), 146 | }, 147 | ) 148 | 149 | 150 | def languages_auto_schema(): 151 | return swagger_auto_schema( 152 | operation_id="list_languages", 153 | operation_summary="List languages", 154 | operation_description="Lists all supported languages", 155 | responses={ 156 | "200": Schema( 157 | title="Success", 158 | type=TYPE_OBJECT, 159 | properties={ 160 | "languages": Schema( 161 | title="Success", 162 | type=TYPE_ARRAY, 163 | items=generic_string_schema(example="French", description="French language"), 164 | ) 165 | }, 166 | ), 167 | }, 168 | ) 169 | -------------------------------------------------------------------------------- /test_project/api/swagger/responses.py: -------------------------------------------------------------------------------- 1 | from drf_yasg.openapi import TYPE_ARRAY, TYPE_OBJECT, Schema 2 | 3 | from .schemas import generic_string_schema 4 | 5 | 6 | def generic_error_response(error_description) -> Schema: 7 | return Schema( 8 | title="Error", 9 | type=TYPE_OBJECT, 10 | properties={"error": generic_string_schema(error_description, "Generic Error response for all API endpoints")}, 11 | ) 12 | 13 | 14 | def get_cars_200_response() -> Schema: 15 | return Schema( 16 | title="Success", 17 | type=TYPE_ARRAY, 18 | items=Schema( 19 | title="Success", 20 | type=TYPE_OBJECT, 21 | properties={ 22 | "name": generic_string_schema(example="Saab", description="A swedish car?"), 23 | "color": generic_string_schema(example="Yellow", description="The color of the car."), 24 | "height": generic_string_schema(example="Medium height", description="How tall the car is."), 25 | "width": generic_string_schema(example="Very wide", description="How wide the car is."), 26 | "length": generic_string_schema(example="2 meters", description="How long the car is."), 27 | }, 28 | ), 29 | ) 30 | 31 | 32 | def get_trucks_200_response() -> Schema: 33 | return Schema( 34 | title="Success", 35 | type=TYPE_ARRAY, 36 | items=Schema( 37 | title="Success", 38 | type=TYPE_OBJECT, 39 | properties={ 40 | "name": generic_string_schema(example="Saab", description="A swedish truck?"), 41 | "color": generic_string_schema(example="Yellow", description="The color of the truck."), 42 | "height": generic_string_schema(example="Medium height", description="How tall the truck is."), 43 | "width": generic_string_schema(example="Very wide", description="How wide the truck is."), 44 | "length": generic_string_schema(example="2 meters", description="How long the truck is."), 45 | }, 46 | ), 47 | ) 48 | -------------------------------------------------------------------------------- /test_project/api/swagger/schemas.py: -------------------------------------------------------------------------------- 1 | from drf_yasg.openapi import TYPE_INTEGER, TYPE_STRING, Schema 2 | 3 | 4 | def generic_string_schema(example, description): 5 | return Schema(type=TYPE_STRING, example=example, description=description) 6 | 7 | 8 | def generic_int_schema(example, description): 9 | return Schema(type=TYPE_INTEGER, example=example, description=description) 10 | -------------------------------------------------------------------------------- /test_project/api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/test_project/api/views/__init__.py -------------------------------------------------------------------------------- /test_project/api/views/animals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from uuid import uuid4 5 | 6 | from rest_framework.response import Response 7 | from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT 8 | from rest_framework.views import APIView 9 | 10 | from test_project.api.swagger.auto_schemas import animals_auto_schema 11 | 12 | if TYPE_CHECKING: 13 | from rest_framework.request import Request 14 | 15 | 16 | class Animals(APIView): 17 | @animals_auto_schema() 18 | def get(self, request: Request, version: int) -> Response: 19 | animals = { 20 | "dog": "very cool", 21 | "monkey": "very cool", 22 | "bird": "mixed reviews", 23 | "spider": "not cool", 24 | "random_uuid": uuid4(), 25 | } 26 | return Response(animals, HTTP_200_OK) 27 | 28 | def delete(self, request: Request, version: int) -> Response: 29 | return Response(status=HTTP_204_NO_CONTENT) 30 | -------------------------------------------------------------------------------- /test_project/api/views/cars.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from drf_spectacular.utils import extend_schema 6 | from rest_framework.response import Response 7 | from rest_framework.views import APIView 8 | 9 | from ..serializers import CarSerializer 10 | from ..swagger.auto_schemas import get_cars_auto_schema, get_other_cars_auto_schema 11 | 12 | if TYPE_CHECKING: 13 | from rest_framework.request import Request 14 | 15 | 16 | class GoodCars(APIView): 17 | @staticmethod 18 | @extend_schema(responses={200: CarSerializer(many=True)}) 19 | @get_cars_auto_schema() 20 | def get(request: Request, version: int, **kwargs) -> Response: 21 | cars = [ 22 | {"name": "Saab", "color": "Yellow", "height": "Medium height", "width": "Very wide", "length": "2 meters"}, 23 | {"name": "Volvo", "color": "Red", "height": "Medium height", "width": "Not wide", "length": "2 meters"}, 24 | {"name": "Tesla", "color": "black", "height": "Medium height", "width": "Wide", "length": "2 meters"}, 25 | ] 26 | return Response(cars, 200) 27 | 28 | @staticmethod 29 | def put(request: Request, version: int) -> Response: 30 | return Response({}) 31 | 32 | @staticmethod 33 | def post(request: Request, version: int) -> Response: 34 | return Response({}) 35 | 36 | @staticmethod 37 | def delete(request: Request, version: int) -> Response: 38 | return Response({}) 39 | 40 | 41 | class BadCars(APIView): 42 | @staticmethod 43 | @extend_schema(responses={200: CarSerializer(many=True)}) 44 | @get_other_cars_auto_schema() 45 | def get(request: Request, version: int, **kwargs) -> Response: 46 | cars = [ 47 | { 48 | "name": "Saab", 49 | "color": "Yellow", 50 | "height": "Medium height", 51 | }, 52 | {"name": "Volvo", "color": "Red", "width": "Not very wide", "length": "2 meters"}, 53 | {"name": "Tesla", "height": "Medium height", "width": "Medium width", "length": "2 meters"}, 54 | ] 55 | return Response(cars, 200) 56 | 57 | @staticmethod 58 | def put(request: Request, version: int) -> Response: 59 | return Response({}) 60 | 61 | @staticmethod 62 | def post(request: Request, version: int) -> Response: 63 | return Response({}) 64 | 65 | @staticmethod 66 | def delete(request: Request, version: int) -> Response: 67 | return Response({}) 68 | -------------------------------------------------------------------------------- /test_project/api/views/exempt_endpoint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from rest_framework.response import Response 6 | from rest_framework.status import HTTP_204_NO_CONTENT 7 | from rest_framework.views import APIView 8 | 9 | if TYPE_CHECKING: 10 | from rest_framework.request import Request 11 | 12 | 13 | class Exempt(APIView): 14 | def get(self, request: Request, version: int) -> Response: 15 | return Response(status=HTTP_204_NO_CONTENT) 16 | -------------------------------------------------------------------------------- /test_project/api/views/i18n.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.utils.translation import gettext as _ 6 | from rest_framework.response import Response 7 | from rest_framework.status import HTTP_200_OK 8 | from rest_framework.views import APIView 9 | 10 | from test_project.api.swagger.auto_schemas import languages_auto_schema 11 | 12 | if TYPE_CHECKING: 13 | from rest_framework.request import Request 14 | 15 | 16 | class Languages(APIView): 17 | @languages_auto_schema() 18 | def get(self, request: Request, version: int) -> Response: 19 | return Response( 20 | {"languages": [_("French"), _("Spanish"), _("Greek"), _("Italian"), _("Portuguese")]}, HTTP_200_OK 21 | ) 22 | -------------------------------------------------------------------------------- /test_project/api/views/items.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from rest_framework.response import Response 4 | from rest_framework.views import APIView 5 | 6 | from test_project.api.serializers import ItemSerializer 7 | from test_project.api.swagger.auto_schemas import post_item_auto_schema 8 | 9 | 10 | class Items(APIView): 11 | @post_item_auto_schema() 12 | def post(self, request, version: int): 13 | serializer = ItemSerializer(data=request.data) 14 | serializer.is_valid(raise_exception=True) 15 | return Response({"success": {"id": uuid.uuid4(), "itemType": serializer.data.get("item_type", "")}}, 201) 16 | -------------------------------------------------------------------------------- /test_project/api/views/names.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.generics import RetrieveAPIView 3 | from rest_framework.viewsets import ReadOnlyModelViewSet 4 | 5 | from test_project.models import Names 6 | 7 | 8 | class NamesSerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = Names 11 | fields = "__all__" 12 | 13 | 14 | class NamesRetrieveView(RetrieveAPIView): 15 | model = Names 16 | serializer_class = NamesSerializer 17 | queryset = Names.objects 18 | 19 | def get_object(self): 20 | return Names.objects.get(custom_id_field=int(self.kwargs["pk"])) 21 | 22 | 23 | class NameViewSet(ReadOnlyModelViewSet): 24 | serializer_class = NamesSerializer 25 | queryset = Names.objects.all() 26 | 27 | 28 | class EmptyNameViewSet(ReadOnlyModelViewSet): 29 | serializer_class = NamesSerializer 30 | queryset = Names.objects.all().none() 31 | -------------------------------------------------------------------------------- /test_project/api/views/pets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from rest_framework.response import Response 6 | from rest_framework.status import HTTP_200_OK 7 | from rest_framework.views import APIView 8 | 9 | if TYPE_CHECKING: 10 | from rest_framework.request import Request 11 | 12 | 13 | class Pet(APIView): 14 | def get(self, request: Request, petId: int) -> Response: 15 | pet = {"name": "doggie", "category": {"id": 1, "name": "Dogs"}, "photoUrls": [], "status": "available"} 16 | return Response(pet, HTTP_200_OK) 17 | -------------------------------------------------------------------------------- /test_project/api/views/products.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from rest_framework.response import Response 6 | from rest_framework.status import HTTP_200_OK 7 | from rest_framework.views import APIView 8 | 9 | if TYPE_CHECKING: 10 | from rest_framework.request import Request 11 | 12 | 13 | class Products(APIView): 14 | def get(self, request: Request, version: int, category_pk: int, subcategory_pk: int) -> Response: 15 | products: dict[int, dict] = { 16 | 1: {1: {}, 2: {}, 3: {}}, 17 | 2: {1: {}, 2: {}, 3: {}}, 18 | 3: {1: {}, 2: {}, 3: {}}, 19 | 4: {1: {}, 2: {}, 3: {}}, 20 | } 21 | return Response(products.get(category_pk, {}).get(subcategory_pk, {}), HTTP_200_OK) 22 | -------------------------------------------------------------------------------- /test_project/api/views/snake_cased_response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from drf_spectacular.utils import extend_schema, inline_serializer 6 | from rest_framework import serializers 7 | from rest_framework.renderers import JSONRenderer 8 | from rest_framework.response import Response 9 | from rest_framework.views import APIView 10 | 11 | from test_project.api.swagger.auto_schemas import get_snake_cased_response 12 | 13 | if TYPE_CHECKING: 14 | from rest_framework.request import Request 15 | 16 | 17 | class SnakeCasedResponse(APIView): 18 | renderer_classes = [JSONRenderer] 19 | 20 | @extend_schema( 21 | responses={ 22 | 200: inline_serializer( 23 | name="SnakeCaseSerializer", many=True, fields={"this_is_snake_case": serializers.CharField()} 24 | ) 25 | } 26 | ) 27 | @get_snake_cased_response() 28 | def get(self, request: Request, version: int, **kwargs) -> Response: 29 | return Response({"this_is_snake_case": "test"}, 200) 30 | -------------------------------------------------------------------------------- /test_project/api/views/trucks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from drf_spectacular.utils import extend_schema 6 | from rest_framework.response import Response 7 | from rest_framework.views import APIView 8 | 9 | from test_project.api.serializers import CarSerializer 10 | from test_project.api.swagger.auto_schemas import get_other_trucks_auto_schema, get_trucks_auto_schema 11 | 12 | if TYPE_CHECKING: 13 | from rest_framework.request import Request 14 | 15 | 16 | class GoodTrucks(APIView): 17 | @staticmethod 18 | @extend_schema(responses={200: CarSerializer(many=True)}) 19 | @get_trucks_auto_schema() 20 | def get(request: Request, version: int) -> Response: 21 | trucks = [ 22 | {"name": "Saab", "color": "Yellow", "height": "Medium height", "width": "Very wide", "length": "2 meters"}, 23 | {"name": "Volvo", "color": "Red", "height": "Medium height", "width": "Not wide", "length": "2 meters"}, 24 | {"name": "Tesla", "color": "black", "height": "Medium height", "width": "Wide", "length": "2 meters"}, 25 | ] 26 | return Response(trucks, 200) 27 | 28 | @staticmethod 29 | def put(request: Request) -> Response: 30 | return Response(status=204) 31 | 32 | @staticmethod 33 | def post(request: Request) -> Response: 34 | return Response(status=204) 35 | 36 | @staticmethod 37 | def delete(request: Request) -> Response: 38 | return Response(status=204) 39 | 40 | 41 | class BadTrucks(APIView): 42 | @staticmethod 43 | @extend_schema(responses={200: CarSerializer(many=True)}) 44 | @get_other_trucks_auto_schema() 45 | def get(request: Request, version: int) -> Response: 46 | trucks = [ 47 | { 48 | "name": "Saab", 49 | "color": "Yellow", 50 | "height": "Medium height", 51 | }, 52 | {"name": "Volvo", "color": "Red", "width": "Not very wide", "length": "2 meters"}, 53 | {"name": "Tesla", "height": "Medium height", "width": "Medium width", "length": "2 meters"}, 54 | ] 55 | return Response(trucks, 200) 56 | 57 | @staticmethod 58 | def put(request: Request, version: int) -> Response: 59 | return Response(status=204) 60 | 61 | @staticmethod 62 | def post(request: Request, version: int) -> Response: 63 | return Response(status=204) 64 | 65 | @staticmethod 66 | def delete(request: Request, version: int) -> Response: 67 | return Response(status=204) 68 | -------------------------------------------------------------------------------- /test_project/api/views/vehicles.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | from rest_framework.views import APIView 3 | 4 | from test_project.api.swagger.auto_schemas import VehicleSerializer, post_vehicle_auto_schema 5 | 6 | 7 | class Vehicles(APIView): 8 | @post_vehicle_auto_schema() 9 | def post(self, request, version: int): 10 | serializer = VehicleSerializer(data=request.data) 11 | serializer.is_valid(raise_exception=True) 12 | return Response({"success": "this is a response"}, 201) 13 | -------------------------------------------------------------------------------- /test_project/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-13 22:11 2 | from __future__ import annotations 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies: list[tuple[str, str]] = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Names", 15 | fields=[ 16 | ("custom_id_field", models.IntegerField(primary_key=True, serialize=False)), 17 | ], 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /test_project/migrations/0002_names_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.6 on 2021-02-26 17:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("test_project", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="names", 14 | name="name", 15 | field=models.CharField( 16 | blank=True, 17 | choices=[("mo", "Moses"), ("moi", "Moishe"), ("mu", "Mush")], 18 | default=None, 19 | max_length=254, 20 | null=True, 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_project/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snok/drf-openapi-tester/8cc933c54620142ef3fadae003d7adba5645fc1e/test_project/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Names(models.Model): 5 | custom_id_field = models.IntegerField(primary_key=True) 6 | name = models.CharField( 7 | max_length=254, 8 | choices=(("mo", "Moses"), ("moi", "Moishe"), ("mu", "Mush")), 9 | default=None, 10 | null=True, 11 | blank=True, 12 | ) 13 | 14 | class Meta: 15 | app_label = "test_project" 16 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | BASE_DIR = Path(__file__).resolve(strict=True).parent 9 | SECRET_KEY = ")t^bn_szx-tce^+lqg(@p8(8jt4c582fr)*ns3s3m0q^3p*$#8" 10 | 11 | DEBUG = True 12 | 13 | ALLOWED_HOSTS: list[str] = [] 14 | 15 | INSTALLED_APPS = [ 16 | "django.contrib.contenttypes", 17 | "django.contrib.admin", 18 | "django.contrib.auth", 19 | "django.contrib.sessions", 20 | "django.contrib.messages", 21 | "django.contrib.staticfiles", 22 | "rest_framework", 23 | "drf_yasg", 24 | "drf_spectacular", 25 | "test_project", 26 | ] 27 | 28 | 29 | MIDDLEWARE = [ 30 | "django.middleware.security.SecurityMiddleware", 31 | "django.middleware.locale.LocaleMiddleware", 32 | "django.contrib.sessions.middleware.SessionMiddleware", 33 | "django.middleware.common.CommonMiddleware", 34 | "django.middleware.csrf.CsrfViewMiddleware", 35 | "django.contrib.auth.middleware.AuthenticationMiddleware", 36 | "django.contrib.messages.middleware.MessageMiddleware", 37 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 38 | ] 39 | 40 | ROOT_URLCONF = "test_project.urls" 41 | TEMPLATES = [ 42 | { 43 | "BACKEND": "django.template.backends.django.DjangoTemplates", 44 | "DIRS": [], 45 | "APP_DIRS": True, 46 | "OPTIONS": { 47 | "context_processors": [ 48 | "django.template.context_processors.debug", 49 | "django.template.context_processors.request", 50 | "django.contrib.auth.context_processors.auth", 51 | "django.contrib.messages.context_processors.messages", 52 | ], 53 | }, 54 | }, 55 | ] 56 | WSGI_APPLICATION = "test_project.wsgi.application" 57 | DATABASES = { 58 | "default": { 59 | "ENGINE": "django.db.backends.sqlite3", 60 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 61 | } 62 | } 63 | 64 | AUTH_PASSWORD_VALIDATORS = [ 65 | {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, 66 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 67 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 68 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 69 | ] 70 | 71 | LANGUAGES = [ 72 | ("de", _("German")), 73 | ("en", _("English")), 74 | ] 75 | 76 | LANGUAGE_CODE = "en" 77 | TIME_ZONE = "UTC" 78 | USE_I18N = True 79 | USE_TZ = True 80 | STATIC_URL = "/test_project/static/" 81 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 82 | 83 | SWAGGER_SETTINGS = { 84 | "DEFAULT_MODEL_RENDERING": "example", 85 | "DEFAULT_INFO": "test_project.urls.swagger_info", 86 | } 87 | 88 | REST_FRAMEWORK = { 89 | "DEFAULT_VERSION": "v1", 90 | "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", 91 | "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), 92 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 93 | } 94 | LOGGING = { 95 | "version": 1, 96 | "disable_existing_loggers": False, 97 | "formatters": { 98 | "simple": {"format": "%(levelname)s -- %(message)s"}, 99 | }, 100 | "handlers": { 101 | "console": { 102 | "class": "logging.StreamHandler", 103 | "formatter": "simple", 104 | }, 105 | }, 106 | "loggers": { 107 | "openapi_tester": { 108 | "handlers": ["console"], 109 | "level": "DEBUG", 110 | } 111 | }, 112 | } 113 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.i18n import i18n_patterns 2 | from django.urls import include, path, re_path 3 | from drf_yasg import openapi 4 | from drf_yasg.views import get_schema_view 5 | from rest_framework import permissions, routers 6 | 7 | from test_project import views 8 | from test_project.api.views.animals import Animals 9 | from test_project.api.views.cars import BadCars, GoodCars 10 | from test_project.api.views.exempt_endpoint import Exempt 11 | from test_project.api.views.i18n import Languages 12 | from test_project.api.views.items import Items 13 | from test_project.api.views.names import EmptyNameViewSet, NamesRetrieveView, NameViewSet 14 | from test_project.api.views.pets import Pet 15 | from test_project.api.views.products import Products 16 | from test_project.api.views.snake_cased_response import SnakeCasedResponse 17 | from test_project.api.views.trucks import BadTrucks, GoodTrucks 18 | from test_project.api.views.vehicles import Vehicles 19 | 20 | router = routers.SimpleRouter() 21 | router.register(r"names", NameViewSet) 22 | 23 | api_urlpatterns = [ 24 | path("api//cars/correct", GoodCars.as_view()), 25 | path("api//cars/incorrect", BadCars.as_view()), 26 | path("api//trucks/correct", GoodTrucks.as_view()), 27 | path("api//trucks/incorrect", BadTrucks.as_view()), 28 | path("api//vehicles", Vehicles.as_view()), 29 | path("api//animals", Animals.as_view()), 30 | path("api//items", Items.as_view()), 31 | path("api//exempt-endpoint", Exempt.as_view()), 32 | path("api///names", NamesRetrieveView.as_view()), 33 | path("api//empty-names", EmptyNameViewSet.as_view({"get": "list"})), 34 | path("api//categories//subcategories//", Products.as_view()), 35 | path("api//snake-case/", SnakeCasedResponse.as_view()), 36 | # ^trailing slash is here on purpose 37 | path("api//router_generated/", include(router.urls)), 38 | re_path(r"api/pet/(?P\d+)", Pet.as_view(), name="get-pet"), 39 | ] 40 | 41 | internationalised_urlpatterns = i18n_patterns( 42 | path("api//i18n", Languages.as_view()), 43 | ) 44 | 45 | swagger_info = openapi.Info( 46 | title="DRF_YASG test project", 47 | default_version="v1", 48 | description="drf_yasg implementation for OpenAPI spec generation.", 49 | contact=openapi.Contact(email=""), 50 | ) 51 | schema_view = get_schema_view( 52 | swagger_info, 53 | patterns=api_urlpatterns + internationalised_urlpatterns, 54 | public=False, 55 | permission_classes=[permissions.AllowAny], 56 | ) 57 | 58 | urlpatterns = [ 59 | path("", views.index), 60 | path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), 61 | path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), 62 | ] 63 | urlpatterns += api_urlpatterns + internationalised_urlpatterns 64 | -------------------------------------------------------------------------------- /test_project/views.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: U100 2 | from typing import Union 3 | 4 | from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect 5 | from django.shortcuts import redirect 6 | from rest_framework.request import Request 7 | 8 | 9 | def index(request: Request) -> Union[HttpResponseRedirect, HttpResponsePermanentRedirect]: 10 | """ 11 | Redirects traffic from / to /swagger. 12 | """ 13 | return redirect("schema-swagger-ui") 14 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings.local") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | example_schema_array = {"type": "array", "items": {"type": "string"}} 2 | example_array = ["string"] 3 | example_schema_integer = {"type": "integer", "minimum": 3, "maximum": 5} 4 | example_integer = 3 5 | example_schema_number = {"type": "number", "minimum": 3, "maximum": 5} 6 | example_number = 3.2 7 | example_schema_object = {"type": "object", "properties": {"value": {"type": "integer"}}, "required": ["value"]} 8 | example_object = {"value": 1} 9 | example_schema_string = {"type": "string", "minLength": 3, "maxLength": 5} 10 | example_string = "str" 11 | example_response_types = [example_array, example_integer, example_number, example_object, example_string] 12 | example_schema_types = [ 13 | example_schema_array, 14 | example_schema_integer, 15 | example_schema_number, 16 | example_schema_object, 17 | example_schema_string, 18 | ] 19 | -------------------------------------------------------------------------------- /tests/schema_converter.py: -------------------------------------------------------------------------------- 1 | """ Schema to Python converter """ 2 | from __future__ import annotations 3 | 4 | import base64 5 | import random 6 | from copy import deepcopy 7 | from datetime import datetime 8 | from typing import TYPE_CHECKING 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any 12 | 13 | from faker import Faker 14 | 15 | from openapi_tester.utils import merge_objects, normalize_schema_section 16 | 17 | 18 | class SchemaToPythonConverter: 19 | """ 20 | This class is used by various test suites. 21 | """ 22 | 23 | result: Any 24 | faker: Faker = Faker() 25 | 26 | def __init__(self, schema: dict): 27 | Faker.seed(0) 28 | self.faker = Faker() 29 | self.result = self.convert_schema(deepcopy(schema)) 30 | 31 | def convert_schema(self, schema: dict[str, Any]) -> Any: 32 | schema_type = schema.get("type", "object") 33 | schema = normalize_schema_section(schema) 34 | if "oneOf" in schema: 35 | one_of = schema.pop("oneOf") 36 | return self.convert_schema({**schema, **random.sample(one_of, 1)[0]}) 37 | if "anyOf" in schema: 38 | any_of = schema.pop("anyOf") 39 | return self.convert_schema( 40 | {**schema, **merge_objects(random.sample(any_of, random.randint(1, len(any_of))))} 41 | ) 42 | if schema_type == "array": 43 | return self.convert_schema_array_to_list(schema) 44 | if schema_type == "object": 45 | return self.convert_schema_object_to_dict(schema) 46 | return self.schema_type_to_mock_value(schema) 47 | 48 | def schema_type_to_mock_value(self, schema_object: dict[str, Any]) -> Any: 49 | faker_handler_map = { 50 | # by type 51 | "array": self.faker.pylist, 52 | "boolean": self.faker.pybool, 53 | "file": self.faker.pystr, 54 | "integer": self.faker.pyint, 55 | "number": self.faker.pyfloat, 56 | "object": self.faker.pydict, 57 | "string": self.faker.pystr, 58 | # by format 59 | "byte": lambda: base64.b64encode(self.faker.pystr().encode("utf-8")).decode("utf-8"), 60 | "date": lambda: datetime.now().date().isoformat(), 61 | "date-time": lambda: datetime.now().isoformat(), 62 | "double": self.faker.pyfloat, 63 | "email": self.faker.email, 64 | "float": self.faker.pyfloat, 65 | "ipv4": self.faker.ipv4, 66 | "ipv6": self.faker.ipv6, 67 | "time": self.faker.time, 68 | "uri": self.faker.uri, 69 | "url": self.faker.url, 70 | "uuid": self.faker.uuid4, 71 | } 72 | schema_format: str = schema_object.get("format", "") 73 | schema_type: str = schema_object.get("type", "") 74 | minimum: int | float | None = schema_object.get("minimum") 75 | maximum: int | float | None = schema_object.get("maximum") 76 | enum: list | None = schema_object.get("enum") 77 | if enum: 78 | return random.sample(enum, 1)[0] 79 | if schema_type in ["integer", "number"] and (minimum is not None or maximum is not None): 80 | if minimum is not None: 81 | minimum += 1 if schema_object.get("exclusiveMinimum") else 0 82 | if maximum is not None: 83 | maximum -= 1 if schema_object.get("exclusiveMaximum") else 0 84 | if minimum is not None or maximum is not None: # pragma: no cover 85 | minimum = minimum or 0 86 | maximum = maximum or minimum * 2 87 | if schema_type == "integer": 88 | return self.faker.pyint(minimum, maximum) 89 | return random.uniform(minimum, maximum) 90 | return ( 91 | faker_handler_map[schema_format]() 92 | if schema_format in faker_handler_map 93 | else faker_handler_map[schema_type]() 94 | ) 95 | 96 | def convert_schema_object_to_dict(self, schema_object: dict) -> dict[str, Any]: 97 | properties = schema_object.get("properties", {}) 98 | parsed_schema: dict[str, Any] = {} 99 | for key, value in properties.items(): 100 | parsed_schema[key] = self.convert_schema(value) 101 | return parsed_schema 102 | 103 | def convert_schema_array_to_list(self, schema_array: Any) -> list[Any]: 104 | parsed_items: list[Any] = [] 105 | items = self.convert_schema(schema_array.get("items", {})) 106 | min_items = schema_array.get("minItems", 1) 107 | max_items = schema_array.get("maxItems", 1) 108 | while len(parsed_items) < min_items or len(parsed_items) < max_items: 109 | parsed_items.append(items) 110 | return parsed_items 111 | -------------------------------------------------------------------------------- /tests/schemas/any_of_one_of_test_schema.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | contact: 8 | name: Swagger API Team 9 | email: apiteam@swagger.io 10 | url: http://swagger.io 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | servers: 15 | - url: http://petstore.swagger.io/api 16 | paths: 17 | /one-of-aliens: 18 | get: 19 | operationId: findAliens 20 | responses: 21 | '200': 22 | description: alien response 23 | content: 24 | application/json: 25 | schema: 26 | oneOf: 27 | - $ref: '#/components/schemas/Flower' 28 | - $ref: '#/components/schemas/Pet' 29 | - $ref: '#/components/schemas/Alien' 30 | default: 31 | description: unexpected error 32 | content: 33 | application/json: 34 | schema: 35 | $ref: '#/components/schemas/Error' 36 | /any-of-aliens: 37 | get: 38 | operationId: findAliens 39 | responses: 40 | '200': 41 | description: alien response 42 | content: 43 | application/json: 44 | schema: 45 | anyOf: 46 | - $ref: '#/components/schemas/Flower' 47 | - $ref: '#/components/schemas/Pet' 48 | - $ref: '#/components/schemas/Alien' 49 | default: 50 | description: unexpected error 51 | content: 52 | application/json: 53 | schema: 54 | $ref: '#/components/schemas/Error' 55 | components: 56 | schemas: 57 | Pet: 58 | allOf: 59 | - $ref: '#/components/schemas/NewPet' 60 | - type: object 61 | required: 62 | - id 63 | properties: 64 | id: 65 | type: integer 66 | format: int64 67 | price: 68 | minimum: 0 69 | maximum: 10 70 | type: integer 71 | 72 | NewPet: 73 | type: object 74 | required: 75 | - name 76 | properties: 77 | name: 78 | type: string 79 | tag: 80 | type: string 81 | food: 82 | type: string 83 | sound: 84 | type: string 85 | color: 86 | type: string 87 | format: byte 88 | 89 | Flower: 90 | type: object 91 | required: 92 | - name 93 | properties: 94 | name: 95 | type: string 96 | brand: 97 | type: string 98 | leaves: 99 | minimum: 1 100 | type: integer 101 | tag: 102 | type: string 103 | pigments: 104 | type: string 105 | format: byte 106 | price: 107 | minimum: 0 108 | maximum: 10 109 | type: integer 110 | 111 | Alien: 112 | type: object 113 | required: 114 | - name 115 | properties: 116 | id: 117 | type: integer 118 | name: 119 | type: string 120 | tag: 121 | type: string 122 | color: 123 | type: string 124 | num_of_friends: 125 | type: integer 126 | maximum: 3 127 | song: 128 | type: string 129 | format: byte 130 | weapons: 131 | type: array 132 | items: 133 | type: string 134 | pets: 135 | type: "array" 136 | items: 137 | anyOf: 138 | - $ref: '#/components/schemas/Pet' 139 | - $ref: '#/components/schemas/Alien' 140 | - $ref: '#/components/schemas/Flower' 141 | 142 | Error: 143 | type: object 144 | required: 145 | - code 146 | - message 147 | properties: 148 | code: 149 | type: integer 150 | format: int32 151 | message: 152 | type: string 153 | -------------------------------------------------------------------------------- /tests/schemas/manual_reference_schema.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.2 2 | info: 3 | title: 'api-specs' 4 | version: '1.0.0' 5 | paths: 6 | /api/v1/{pk}/names: 7 | get: 8 | operationId: api_v1_names_retrieve 9 | description: '' 10 | parameters: 11 | - in: path 12 | name: pk 13 | schema: 14 | type: integer 15 | description: A unique value identifying this names. 16 | required: true 17 | tags: 18 | - api 19 | security: 20 | - cookieAuth: [ ] 21 | - { } 22 | responses: 23 | '200': 24 | content: 25 | application/json: 26 | schema: 27 | type: object 28 | properties: 29 | pk: 30 | type: integer 31 | required: 32 | - pk 33 | description: '' 34 | /api/v1/cars/correct: 35 | get: 36 | operationId: listGoodCars 37 | description: '' 38 | parameters: [ ] 39 | responses: 40 | '200': 41 | content: 42 | application/json: 43 | schema: 44 | title: 'Success' 45 | type: array 46 | items: { 47 | title: 'Success', 48 | type: 'object', 49 | properties: { 50 | name: { 51 | description: 'A swedish car?', 52 | type: 'string', 53 | example: 'Saab', 54 | }, 55 | color: { 56 | description: 'The color of the car.', 57 | type: 'string', 58 | example: 'Yellow', 59 | }, 60 | height: { 61 | description: 'How tall the car is.', 62 | type: 'string', 63 | example: 'Medium height', 64 | }, 65 | width: { 66 | description: 'How wide the car is.', 67 | type: 'string', 68 | example: 'Very wide', 69 | }, 70 | length: { 71 | description: 'How long the car is.', 72 | type: 'string', 73 | example: '2 meters', 74 | }, 75 | } 76 | } 77 | description: '' 78 | post: 79 | operationId: CreateGoodCars 80 | description: '' 81 | parameters: [ ] 82 | responses: 83 | '200': 84 | content: 85 | application/json: 86 | schema: { } 87 | description: '' 88 | put: 89 | operationId: UpdateGoodCars 90 | description: '' 91 | parameters: [ ] 92 | responses: 93 | '200': 94 | content: 95 | application/json: 96 | schema: { } 97 | description: '' 98 | delete: 99 | operationId: DestroyGoodCars 100 | description: '' 101 | parameters: [ ] 102 | responses: 103 | '204': 104 | description: '' 105 | /api/v1/cars/incorrect: 106 | get: 107 | operationId: listBadCars 108 | description: '' 109 | parameters: [ ] 110 | responses: 111 | '200': 112 | description: '' 113 | post: 114 | operationId: CreateBadCars 115 | description: '' 116 | parameters: [ ] 117 | responses: 118 | '200': 119 | content: 120 | application/json: 121 | schema: { } 122 | description: '' 123 | put: 124 | operationId: UpdateBadCars 125 | description: '' 126 | parameters: [ ] 127 | responses: 128 | '200': 129 | content: 130 | application/json: 131 | schema: { } 132 | description: '' 133 | delete: 134 | operationId: DestroyBadCars 135 | description: '' 136 | parameters: [ ] 137 | responses: 138 | '204': 139 | description: '' 140 | /api/v1/trucks/correct: 141 | get: 142 | operationId: listGoodTrucks 143 | description: '' 144 | parameters: [ ] 145 | responses: 146 | '200': 147 | content: 148 | application/json: 149 | schema: 150 | title: 'Success' 151 | type: array 152 | items: { 153 | title: 'Success', 154 | type: 'object', 155 | properties: { 156 | name: { 157 | description: 'A swedish truck?', 158 | type: 'string', 159 | example: 'Saab', 160 | }, 161 | color: { 162 | description: 'The color of the truck.', 163 | type: 'string', 164 | example: 'Yellow', 165 | }, 166 | height: { 167 | description: 'How tall the truck is.', 168 | type: 'string', 169 | example: 'Medium height', 170 | }, 171 | width: { 172 | description: 'How wide the truck is.', 173 | type: 'string', 174 | example: 'Very wide', 175 | }, 176 | length: { 177 | description: 'How long the truck is.', 178 | type: 'string', 179 | example: '2 meters', 180 | }, 181 | } 182 | } 183 | description: '' 184 | post: 185 | operationId: CreateGoodTrucks 186 | description: '' 187 | parameters: [ ] 188 | responses: 189 | '200': 190 | content: 191 | application/json: 192 | schema: { } 193 | description: '' 194 | put: 195 | operationId: UpdateGoodTrucks 196 | description: '' 197 | parameters: [ ] 198 | responses: 199 | '200': 200 | content: 201 | application/json: 202 | schema: { } 203 | description: '' 204 | delete: 205 | operationId: DestroyGoodTrucks 206 | description: '' 207 | parameters: [ ] 208 | responses: 209 | '204': 210 | description: '' 211 | /api/v1/trucks/incorrect: 212 | get: 213 | operationId: listBadTrucks 214 | description: '' 215 | parameters: [ ] 216 | responses: 217 | '200': 218 | description: '' 219 | post: 220 | operationId: CreateBadTrucks 221 | description: '' 222 | parameters: [ ] 223 | responses: 224 | '200': 225 | content: 226 | application/json: 227 | schema: { } 228 | description: '' 229 | put: 230 | operationId: UpdateBadTrucks 231 | description: '' 232 | parameters: [ ] 233 | responses: 234 | '200': 235 | content: 236 | application/json: 237 | schema: { } 238 | description: '' 239 | delete: 240 | operationId: DestroyBadTrucks 241 | description: '' 242 | parameters: [ ] 243 | responses: 244 | '204': 245 | description: '' 246 | /api/v1/categories/{category_pk}/subcategories/{subcategory_pk}/: 247 | get: 248 | operationId: getProducts 249 | description: '' 250 | parameters: 251 | - in: path 252 | name: category_pk 253 | schema: 254 | type: integer 255 | description: A unique value identifying this category. 256 | required: true 257 | - in: path 258 | name: subcategory_pk 259 | schema: 260 | type: integer 261 | description: A unique value identifying this subcategory. 262 | required: true 263 | responses: 264 | '200': 265 | description: '' 266 | -------------------------------------------------------------------------------- /tests/schemas/openapi_v2_reference_schema.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Swagger Petstore" 5 | description: "A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification" 6 | termsOfService: "http://swagger.io/terms/" 7 | contact: 8 | name: "Swagger API Team" 9 | email: "apiteam@swagger.io" 10 | url: "http://swagger.io" 11 | license: 12 | name: "Apache 2.0" 13 | url: "https://www.apache.org/licenses/LICENSE-2.0.html" 14 | externalDocs: 15 | description: "find more info here" 16 | url: "https://swagger.io/about" 17 | host: "petstore.swagger.io" 18 | basePath: "/api" 19 | schemes: 20 | - "http" 21 | consumes: 22 | - "application/json" 23 | produces: 24 | - "application/json" 25 | paths: 26 | /pets: 27 | get: 28 | description: "Returns all pets from the system that the user has access to" 29 | operationId: "findPets" 30 | externalDocs: 31 | description: "find more info here" 32 | url: "https://swagger.io/about" 33 | produces: 34 | - "application/json" 35 | - "application/xml" 36 | - "text/xml" 37 | - "text/html" 38 | parameters: 39 | - name: "tags" 40 | in: "query" 41 | description: "tags to filter by" 42 | required: false 43 | type: "array" 44 | items: 45 | type: "string" 46 | collectionFormat: "csv" 47 | - name: "limit" 48 | in: "query" 49 | description: "maximum number of results to return" 50 | required: false 51 | type: "integer" 52 | format: "int32" 53 | responses: 54 | "200": 55 | description: "pet response" 56 | schema: 57 | type: "array" 58 | items: 59 | $ref: "#/definitions/Pet" 60 | default: 61 | description: "unexpected error" 62 | schema: 63 | $ref: "#/definitions/ErrorModel" 64 | post: 65 | description: "Creates a new pet in the store. Duplicates are allowed" 66 | operationId: "addPet" 67 | produces: 68 | - "application/json" 69 | parameters: 70 | - name: "pet" 71 | in: "body" 72 | description: "Pet to add to the store" 73 | required: true 74 | schema: 75 | $ref: "#/definitions/NewPet" 76 | responses: 77 | "200": 78 | description: "pet response" 79 | schema: 80 | $ref: "#/definitions/Pet" 81 | default: 82 | description: "unexpected error" 83 | schema: 84 | $ref: "#/definitions/ErrorModel" 85 | /pets/{id}: 86 | get: 87 | description: "Returns a user based on a single ID, if the user does not have access to the pet" 88 | operationId: "findPetById" 89 | produces: 90 | - "application/json" 91 | - "application/xml" 92 | - "text/xml" 93 | - "text/html" 94 | parameters: 95 | - name: "id" 96 | in: "path" 97 | description: "ID of pet to fetch" 98 | required: true 99 | type: "integer" 100 | format: "int64" 101 | responses: 102 | "200": 103 | description: "pet response" 104 | schema: 105 | $ref: "#/definitions/Pet" 106 | default: 107 | description: "unexpected error" 108 | schema: 109 | $ref: "#/definitions/ErrorModel" 110 | delete: 111 | description: "deletes a single pet based on the ID supplied" 112 | operationId: "deletePet" 113 | parameters: 114 | - name: "id" 115 | in: "path" 116 | description: "ID of pet to delete" 117 | required: true 118 | type: "integer" 119 | format: "int64" 120 | responses: 121 | "204": 122 | description: "pet deleted" 123 | default: 124 | description: "unexpected error" 125 | schema: 126 | $ref: "#/definitions/ErrorModel" 127 | definitions: 128 | Pet: 129 | type: "object" 130 | allOf: 131 | - $ref: "#/definitions/NewPet" 132 | - required: 133 | - "id" 134 | properties: 135 | id: 136 | type: "integer" 137 | format: "int64" 138 | NewPet: 139 | type: "object" 140 | required: 141 | - "name" 142 | properties: 143 | name: 144 | type: "string" 145 | tag: 146 | type: "string" 147 | ErrorModel: 148 | type: "object" 149 | required: 150 | - "code" 151 | - "message" 152 | properties: 153 | code: 154 | type: "integer" 155 | format: "int32" 156 | message: 157 | type: "string" 158 | -------------------------------------------------------------------------------- /tests/schemas/openapi_v3_reference_schema.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | contact: 8 | name: Swagger API Team 9 | email: apiteam@swagger.io 10 | url: http://swagger.io 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | servers: 15 | - url: http://petstore.swagger.io/api 16 | paths: 17 | /pets: 18 | get: 19 | description: | 20 | Returns all pets from the system that the user has access to 21 | Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia. 22 | 23 | Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien. 24 | operationId: findPets 25 | parameters: 26 | - name: tags 27 | in: query 28 | description: tags to filter by 29 | required: false 30 | style: form 31 | schema: 32 | type: array 33 | items: 34 | type: string 35 | - name: limit 36 | in: query 37 | description: maximum number of results to return 38 | required: false 39 | schema: 40 | type: integer 41 | format: int32 42 | responses: 43 | '200': 44 | description: pet response 45 | content: 46 | application/json: 47 | schema: 48 | type: array 49 | items: 50 | $ref: '#/components/schemas/Pet' 51 | default: 52 | description: unexpected error 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '#/components/schemas/Error' 57 | post: 58 | description: Creates a new pet in the store. Duplicates are allowed 59 | operationId: addPet 60 | requestBody: 61 | description: Pet to add to the store 62 | required: true 63 | content: 64 | application/json: 65 | schema: 66 | $ref: '#/components/schemas/NewPet' 67 | responses: 68 | '200': 69 | description: pet response 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/Pet' 74 | default: 75 | description: unexpected error 76 | content: 77 | application/json: 78 | schema: 79 | $ref: '#/components/schemas/Error' 80 | /pets/{id}: 81 | get: 82 | description: Returns a user based on a single ID, if the user does not have access to the pet 83 | operationId: find pet by id 84 | parameters: 85 | - name: id 86 | in: path 87 | description: ID of pet to fetch 88 | required: true 89 | schema: 90 | type: integer 91 | format: int64 92 | responses: 93 | '200': 94 | description: pet response 95 | content: 96 | application/json: 97 | schema: 98 | $ref: '#/components/schemas/Pet' 99 | default: 100 | description: unexpected error 101 | content: 102 | application/json: 103 | schema: 104 | $ref: '#/components/schemas/Error' 105 | delete: 106 | description: deletes a single pet based on the ID supplied 107 | operationId: deletePet 108 | parameters: 109 | - name: id 110 | in: path 111 | description: ID of pet to delete 112 | required: true 113 | schema: 114 | type: integer 115 | format: int64 116 | responses: 117 | '204': 118 | description: pet deleted 119 | default: 120 | description: unexpected error 121 | content: 122 | application/json: 123 | schema: 124 | $ref: '#/components/schemas/Error' 125 | components: 126 | schemas: 127 | Pet: 128 | allOf: 129 | - $ref: '#/components/schemas/NewPet' 130 | - type: object 131 | required: 132 | - id 133 | properties: 134 | id: 135 | type: integer 136 | format: int64 137 | 138 | NewPet: 139 | type: object 140 | required: 141 | - name 142 | properties: 143 | name: 144 | type: string 145 | tag: 146 | type: string 147 | 148 | Error: 149 | type: object 150 | required: 151 | - code 152 | - message 153 | properties: 154 | code: 155 | type: integer 156 | format: int32 157 | message: 158 | type: string 159 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/api-example.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Questo e' un progetto d'esempio in formato OpenAPI 3.0 API Starter Kit. 3 | # 4 | # Stai per sviluppare un API? Segui attentamente questo template e 5 | # potrai integrarla facilmente in Developers Italia. 6 | # 7 | openapi: 3.0.0 8 | info: 9 | version: "1.0.0" 10 | # Il `title` e' il nome del tuo prodotto/servizio! 11 | # E' la prima cosa ad apparire in pagine web e cataloghi. 12 | # Dev'essere chiaro e conciso. 13 | title: |- 14 | Starter Kit API 15 | x-summary: >- 16 | Una linea che descrive la nostra API per il catalogo. 17 | description: | 18 | #### Documentazione 19 | Qui devi inserire la documentazione principale del servizio. 20 | Il testo dev'essere diviso in piu' parti, tutte scritte in Markdown. 21 | 22 | Questa sezione e' informativa: 23 | 24 | * cosa fanno queste API? 25 | * chi puo' beneficiarne? 26 | * perche' sono utili? 27 | 28 | ##### Sottosezioni 29 | E' possibile utilizzare - con criterio - delle sottosezioni. 30 | 31 | #### Note 32 | 33 | Usa questa sezione per annotazioni specifiche, riferimenti normativi e/o 34 | per manleve ed esclusioni di responsabilita' eventualmente non incluse in `termsOfService`. 35 | 36 | #### Informazioni tecniche ed esempi 37 | 38 | Qui e' possibile introdurre brevi informazioni tecniche ed esempi. 39 | Attenzione: la `description` non sostituisce la documentazione di progetto, 40 | deve pero' facilitare l'esecuzione delle prime richieste. 41 | 42 | # I termini del servizio contengono un riferimento 43 | # a tutte le indicazioni e le note legali per l'utilizzo 44 | # del servizio, inclusi gli eventuali riferimenti utili al GDPR. 45 | termsOfService: 'http://swagger.io/terms/' 46 | # Chi posso contattare per informazioni sul servizio e sul suo stato? 47 | contact: 48 | email: robipolli@gmail.com 49 | name: Roberto Polli 50 | url: https://twitter.com/ioggstream 51 | # L'audience delle API. Attualmente e' definito solamente 52 | # la tipologia `public`. 53 | x-audience: 54 | - public 55 | # Ogni API deve avere un UUID, invariante nel tempo e 56 | # rispetto al `title`. 57 | x-api-id: 71afb493-b5a1-44ed-a997-991c217f520c 58 | license: 59 | name: Apache 2.0 60 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 61 | x-lifecycle: 62 | maturity: developing 63 | # Una lista di `tag` utili a raggruppare le varie operazioni 64 | # eseguite dalle API. Ogni `tag` puo' referenziare un link 65 | # alla sua descrizione. 66 | tags: 67 | - name: public 68 | description: Retrieve informations 69 | externalDocs: 70 | url: http://docs.my-api.com/pet-operations.htm 71 | # Uno o piu' server di erogazione. 72 | # Puo' essere utile indicare gli indirizzi di collaudo, 73 | # sviluppo e test. 74 | servers: 75 | - description: Test server 76 | url: https://api.example.com/ipa/v1 77 | x-sandbox: yes 78 | x-healthCheck: 79 | interval: 5 80 | timeout: 10 81 | url: https://foo/status 82 | - description: Development server 83 | url: https://localhost:8443/ipa/v1 84 | # 85 | # Qui vanno tutti i path. 86 | # 87 | paths: 88 | /indicepa/{ipa_code}: 89 | get: 90 | summary: Recupera le informazioni su una PA. 91 | description: | 92 | Si connette ad IndicePA e recupera le informazioni su 93 | un'amministrazione tramite il codice IPA. 94 | operationId: get_ipa 95 | tags: 96 | - public 97 | parameters: 98 | - $ref: "#/components/parameters/ipa_code" 99 | responses: 100 | '200': 101 | description: | 102 | La PA e' stata trovata e le informazioni sono state recuperate 103 | con successo. 104 | # Questi headder di throttling sono obbligatori e definiti 105 | # nel Nuovo modello di interoperabilità. 106 | headers: 107 | X-RateLimit-Limit: 108 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Limit' 109 | X-RateLimit-Remaining: 110 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Remaining' 111 | X-RateLimit-Reset: 112 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Reset' 113 | content: 114 | application/json: 115 | schema: 116 | $ref: '#/components/schemas/PA' 117 | '400': 118 | $ref: '#/components/responses/400BadRequest' 119 | '404': 120 | $ref: '#/components/responses/404NotFound' 121 | '429': 122 | $ref: '#/components/responses/429TooManyRequests' 123 | '503': 124 | $ref: '#/components/responses/503ServiceUnavailable' 125 | default: 126 | $ref: '#/components/responses/default' 127 | /indicepa: 128 | get: 129 | summary: Ricerca una PA per nome. 130 | description: | 131 | Si connette ad IndicePA e ricerca una PA per nome. 132 | operationId: search_ipa 133 | tags: 134 | - public 135 | parameters: 136 | - $ref: "#/components/parameters/name" 137 | - $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/limit' 138 | - $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/offset' 139 | - $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/sort' 140 | responses: 141 | '200': 142 | description: | 143 | La PA e' stata trovata e le informazioni sono state recuperate 144 | con successo. 145 | headers: 146 | X-RateLimit-Limit: 147 | $ref: '#/components/headers/X-RateLimit-Limit' 148 | X-RateLimit-Remaining: 149 | $ref: '#/components/headers/X-RateLimit-Remaining' 150 | X-RateLimit-Reset: 151 | $ref: '#/components/headers/X-RateLimit-Reset' 152 | content: 153 | application/json: 154 | schema: 155 | $ref: '#/components/schemas/PA' 156 | '400': 157 | $ref: '#/components/responses/400BadRequest' 158 | '404': 159 | $ref: '#/components/responses/404NotFound' 160 | '429': 161 | $ref: '#/components/responses/429TooManyRequests' 162 | '503': 163 | $ref: '#/components/responses/503ServiceUnavailable' 164 | default: 165 | $ref: '#/components/responses/default' 166 | 167 | components: 168 | parameters: 169 | ipa_code: 170 | name: ipa_code 171 | in: path 172 | description: Il codice IPA dell'amministrazione. 173 | required: true 174 | example: asl_lt 175 | schema: 176 | type: string 177 | name: 178 | name: name 179 | in: query 180 | description: La stringa da ricercare nel nome dell'amministrazione. 181 | required: true 182 | example: Latina 183 | schema: 184 | type: string 185 | 186 | headers: 187 | X-RateLimit-Limit: 188 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Limit' 189 | X-RateLimit-Remaining: 190 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Remaining' 191 | X-RateLimit-Reset: 192 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Reset' 193 | Retry-After: 194 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/Retry-After' 195 | 196 | responses: 197 | # Predefined error codes for this API 198 | 400BadRequest: 199 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/400BadRequest' 200 | 404NotFound: 201 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/404NotFound' 202 | 429TooManyRequests: 203 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/429TooManyRequests' 204 | 503ServiceUnavailable: 205 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/503ServiceUnavailable' 206 | default: 207 | $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/default' 208 | 209 | schemas: 210 | # Problem: 211 | # $ref: 'https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/schemas/Problem' 212 | PA: 213 | type: object 214 | description: Una Pubblica Amministrazione. 215 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/external-apis/anpr-dashboard.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Questo e' un progetto d'esempio in formato OpenAPI 3.0 API Starter Kit. 3 | # 4 | # Stai per sviluppare un API? Segui attentamente questo template e 5 | # potrai integrarla facilmente in Developers Italia. 6 | # 7 | openapi: 3.0.2 8 | info: 9 | version: "1.0.0" 10 | # Il `title` e' il nome del tuo prodotto/servizio! 11 | # E' la prima cosa ad apparire in pagine web e cataloghi. 12 | # Dev'essere chiaro e conciso. 13 | title: |- 14 | ANPR Dashboard API 15 | x-summary: >- 16 | Informazioni sulllo stato di ANPR. 17 | description: | 18 | #### Documentazione 19 | ANPR è l'Anagrafe Nazionale della Popolazione Residente [anpr]. 20 | 21 | Con ANPR le amministrazioni possono dialogare in maniera efficiente 22 | tra di loro avendo una fonte unica e certa per i dati dei cittadini. 23 | 24 | ANPR consente ai cittadini di ottenere vantaggi immediati 25 | quali la richiesta di certificati anagrafici in tutti i comuni, 26 | cambio di residenza più semplice ed immediato ed 27 | a breve la possibilità di ottenere certificati da un portale unico. 28 | 29 | Queste API permettono di recuperare le informazioni 30 | aggiornate su base periodica relative allo stato della migrazione 31 | dei comuni italiani. 32 | 33 | 34 | #### Note 35 | 36 | Usa questa sezione per annotazioni specifiche, riferimenti normativi e/o 37 | per manleve ed esclusioni di responsabilita' eventualmente non incluse in `termsOfService`. 38 | 39 | #### Informazioni tecniche ed esempi 40 | 41 | Qui e' possibile introdurre brevi informazioni tecniche ed esempi. 42 | Attenzione: la `description` non sostituisce la documentazione di progetto, 43 | deve pero' facilitare l'esecuzione delle prime richieste. 44 | 45 | 46 | [//]: # (Riferimenti) 47 | 48 | [anpr]: https://anpr.interno.it/ 49 | 50 | # I termini del servizio contengono un riferimento 51 | # a tutte le indicazioni e le note legali per l'utilizzo 52 | # del servizio, inclusi gli eventuali riferimenti utili al GDPR. 53 | termsOfService: https://dashboard.anpr.it 54 | # Chi posso contattare per informazioni sul servizio e sul suo stato? 55 | contact: 56 | email: roberto@teamdigitale.governo.it 57 | name: Roberto Polli 58 | url: https://twitter.com/ioggstream 59 | x-project: anpr 60 | # L'audience delle API. Attualmente e' definito solamente 61 | # la tipologia `public`. 62 | x-audience: 63 | - public 64 | # Ogni API deve avere un UUID, invariante nel tempo e 65 | # rispetto al `title`. 66 | x-api-id: b7e4f1be-747a-4378-9d5a-2174975f3e11 67 | license: 68 | name: Apache 2.0 69 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 70 | x-lifecycle: 71 | maturity: developing 72 | # Una lista di `tag` utili a raggruppare le varie operazioni 73 | # eseguite dalle API. Ogni `tag` puo' referenziare un link 74 | # alla sua descrizione. 75 | tags: 76 | - name: public 77 | description: |- 78 | Informazioni pubbliche sulla migrazione in ANPR. 79 | 80 | # Uno o piu' server di erogazione. 81 | # Puo' essere utile indicare gli indirizzi di collaudo, 82 | # sviluppo e test. 83 | servers: 84 | - description: Produzione 85 | url: https://dashboard.anpr.it/api/ 86 | x-healthCheck: 87 | interval: 300 88 | timeout: 10 89 | url: https://dashboard.anpr.it/api/016024 90 | - description: Development server 91 | url: http://localhost:8080/api 92 | x-sandbox: yes 93 | 94 | x-commons: 95 | common-responses: &common-responses 96 | '429': 97 | $ref: '#/components/responses/429TooManyRequests' 98 | '503': 99 | $ref: '#/components/responses/503ServiceUnavailable' 100 | default: 101 | $ref: '#/components/responses/default' 102 | common-headers: &common-headers 103 | X-RateLimit-Limit: 104 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/headers/X-RateLimit-Limit' 105 | X-RateLimit-Remaining: 106 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/headers/X-RateLimit-Remaining' 107 | X-RateLimit-Reset: 108 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/headers/X-RateLimit-Reset' 109 | # 110 | # Qui vanno tutti i path. 111 | # 112 | paths: 113 | /comune/{istat_code}: 114 | get: 115 | summary: Recupera lo stato di migrazione di un comune. 116 | description: | 117 | Si connette ad IndicePA e recupera le informazioni su 118 | un'amministrazione tramite il codice IPA. 119 | operationId: get_comune 120 | tags: 121 | - public 122 | parameters: 123 | - $ref: "#/components/parameters/istat_code" 124 | responses: 125 | <<: *common-responses 126 | '200': 127 | description: | 128 | Il comune e' stato trovato e le informazioni sono state recuperate 129 | con successo. 130 | headers: 131 | <<: *common-headers 132 | content: 133 | application/json: 134 | schema: 135 | $ref: '#/components/schemas/Result' 136 | /dashboard/data.json: 137 | get: 138 | summary: Mostra tutti i dati. 139 | description: | 140 | Recupera i dati di tutti i comuni. 141 | operationId: get_all_data 142 | tags: 143 | - public 144 | responses: 145 | '200': 146 | description: | 147 | I dati di tutti i comuni sono stati ritornati con successo. 148 | headers: 149 | <<: *common-headers 150 | content: 151 | application/json: 152 | schema: 153 | $ref: '#/components/schemas/Dump' 154 | 155 | components: 156 | parameters: 157 | istat_code: 158 | name: istat_code 159 | in: path 160 | description: Il codice ISTAT del comune. 161 | required: true 162 | example: "016024" 163 | schema: 164 | type: string 165 | 166 | responses: 167 | # Predefined error codes for this API 168 | 400BadRequest: 169 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/responses/400BadRequest' 170 | 404NotFound: 171 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/responses/404NotFound' 172 | 429TooManyRequests: 173 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/responses/429TooManyRequests' 174 | 503ServiceUnavailable: 175 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/responses/503ServiceUnavailable' 176 | default: 177 | $ref: 'https://teamdigitale.github.io/openapi/0.0.5/definitions.yaml#/responses/default' 178 | 179 | schemas: 180 | Result: 181 | properties: 182 | result: 183 | type: string 184 | error: 185 | type: string 186 | default: "" 187 | data: 188 | $ref: '#/components/schemas/Comune' 189 | 190 | Dump: 191 | description: |- 192 | Un file con tutte le statistiche della dashboard ANPR che include 193 | i valori di riepilogo e lo stato puntuale dei comuni. 194 | properties: 195 | geojson: 196 | $ref: '#/components/schemas/GeoJson' 197 | summaries: 198 | type: object 199 | properties: 200 | com_sub: 201 | type: integer 202 | pop_sub: 203 | type: integer 204 | com_pre: 205 | type: integer 206 | pop_pre: 207 | type: integer 208 | pop_aire: 209 | type: integer 210 | pop_pre_aire: 211 | type: integer 212 | required: 213 | - com_sub 214 | - pop_sub 215 | - com_pre 216 | - pop_pre 217 | - pop_aire 218 | - pop_pre_aire 219 | fornitori: 220 | type: array 221 | items: 222 | $ref: '#/components/schemas/Fornitori' 223 | charts: 224 | properties: 225 | subentro: 226 | type: array 227 | items: 228 | $ref: '#/components/schemas/Chart' 229 | presubentro: 230 | type: array 231 | items: 232 | $ref: '#/components/schemas/Chart' 233 | Chart: 234 | type: object 235 | properties: 236 | date: 237 | type: string 238 | comuni: 239 | type: integer 240 | popolazione: 241 | type: integer 242 | popolazione_aire: 243 | type: integer 244 | required: 245 | - date 246 | - comuni 247 | - popolazione 248 | - popolazione_aire 249 | GeoJson: 250 | properties: 251 | type: 252 | type: string 253 | example: Feature 254 | features: 255 | type: array 256 | items: 257 | $ref: '#/components/schemas/GeoJsonFeature' 258 | GeoJsonFeature: 259 | description: |- 260 | Informazioni sul comune e sue coordinate geografiche. 261 | properties: 262 | type: 263 | type: string 264 | enum: 265 | - Feature 266 | geometry: 267 | $ref: https://geojson.org/schema/Point.json 268 | properties: 269 | $ref: '#/components/schemas/Properties' 270 | Fornitori: 271 | type: object 272 | properties: 273 | percentuale_comuni_subentrati: 274 | type: integer 275 | percentuale_comuni_in_presubentro: 276 | type: integer 277 | percentuale_comuni_inattivi: 278 | type: integer 279 | nome: 280 | type: string 281 | required: 282 | - percentuale_comuni_subentrati 283 | - percentuale_comuni_in_presubentro 284 | - percentuale_comuni_inattivi 285 | - nome 286 | Properties: 287 | description: | 288 | Informazioni sullo stato di migrazione ANPR del comune. 289 | type: object 290 | properties: 291 | PROVINCIA: 292 | type: string 293 | REGIONE: 294 | type: string 295 | ZONA: 296 | type: string 297 | data_presubentro: 298 | type: string 299 | data_subentro_preferita: 300 | type: string 301 | label: 302 | type: string 303 | popolazione: 304 | type: integer 305 | popolazione_aire: 306 | type: integer 307 | prima_data_subentro: 308 | type: string 309 | ultima_data_subentro: 310 | type: string 311 | required: 312 | - PROVINCIA 313 | - REGIONE 314 | - ZONA 315 | - data_presubentro 316 | - data_subentro_preferita 317 | - label 318 | - popolazione 319 | - popolazione_aire 320 | - prima_data_subentro 321 | - ultima_data_subentro 322 | example: 323 | PROVINCIA: Treviso 324 | REGIONE: Veneto 325 | ZONA: Nord-Est 326 | data_presubentro: 29/11/2018 327 | data_subentro_preferita: 07/10/2019 328 | label: PEDEROBBA 329 | popolazione: 7355 330 | popolazione_aire: 0 331 | prima_data_subentro: 07/10/2019 332 | ultima_data_subentro: 11/10/2019 333 | 334 | Comune: 335 | description: | 336 | Dati puntuali sullo stato di migrazione del comune e dell'eventuale 337 | intervallo di subentro - se pianificato. 338 | properties: 339 | CodiceIstat: 340 | type: string 341 | Name: 342 | type: string 343 | DataSubentro: 344 | type: string 345 | format: date-time 346 | DataAbilitazione: 347 | type: string 348 | format: date-time 349 | DataPresubentro: 350 | type: string 351 | format: date-time 352 | PianificazioneIntervalloSubentro: 353 | $ref: '#/components/schemas/IntervalloSubentro' 354 | IntervalloSubentro: 355 | properties: 356 | From: 357 | type: string 358 | format: date-time 359 | To: 360 | type: string 361 | format: date-time 362 | PreferredDate: 363 | type: string 364 | format: date-time 365 | IP: 366 | type: string 367 | default: null 368 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/external-apis/istat-sdmx-from-wadl.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "servers": [ 4 | { 5 | "url": "http://sdmxb.istat.it/sdmxws/rest/" 6 | } 7 | ], 8 | "paths": { 9 | "/{structure}": { 10 | "get": { 11 | "operationId": "getStructureAll", 12 | "responses": { 13 | "200": { 14 | "description": "Successful Response" 15 | } 16 | }, 17 | "parameters": [ 18 | { 19 | "name": "detail", 20 | "required": false, 21 | "in": "query", 22 | "schema": { 23 | "type": "string", 24 | "default": "full" 25 | } 26 | }, 27 | { 28 | "name": "references", 29 | "required": false, 30 | "in": "query", 31 | "schema": { 32 | "type": "string", 33 | "default": "none" 34 | } 35 | }, 36 | { 37 | "name": "Accept", 38 | "required": false, 39 | "in": "header", 40 | "schema": { 41 | "type": "string" 42 | } 43 | } 44 | ] 45 | }, 46 | "parameters": [ 47 | { 48 | "name": "structure", 49 | "required": true, 50 | "in": "path", 51 | "schema": { 52 | "type": "string" 53 | } 54 | } 55 | ] 56 | }, 57 | "/{structure}/{agencyID}/{resourceID}/{version}": { 58 | "get": { 59 | "operationId": "getStructure", 60 | "responses": { 61 | "200": { 62 | "description": "Successful Response" 63 | } 64 | }, 65 | "parameters": [ 66 | { 67 | "name": "detail", 68 | "required": false, 69 | "in": "query", 70 | "schema": { 71 | "type": "string", 72 | "default": "full" 73 | } 74 | }, 75 | { 76 | "name": "references", 77 | "required": false, 78 | "in": "query", 79 | "schema": { 80 | "type": "string", 81 | "default": "none" 82 | } 83 | }, 84 | { 85 | "name": "Accept", 86 | "required": false, 87 | "in": "header", 88 | "schema": { 89 | "type": "string" 90 | } 91 | } 92 | ] 93 | }, 94 | "delete": { 95 | "operationId": "Delete", 96 | "responses": { 97 | "200": { 98 | "description": "Successful Response" 99 | } 100 | } 101 | }, 102 | "parameters": [ 103 | { 104 | "name": "structure", 105 | "required": true, 106 | "in": "path", 107 | "schema": { 108 | "type": "string" 109 | } 110 | }, 111 | { 112 | "name": "structure", 113 | "required": true, 114 | "in": "path", 115 | "schema": { 116 | "type": "string" 117 | } 118 | }, 119 | { 120 | "name": "resourceID", 121 | "required": true, 122 | "in": "path", 123 | "schema": { 124 | "type": "string" 125 | } 126 | }, 127 | { 128 | "name": "agencyID", 129 | "required": true, 130 | "in": "path", 131 | "schema": { 132 | "type": "string" 133 | } 134 | }, 135 | { 136 | "name": "version", 137 | "required": true, 138 | "in": "path", 139 | "schema": { 140 | "type": "string" 141 | } 142 | } 143 | ] 144 | }, 145 | "/{structure}/{agencyID}/{resourceID}": { 146 | "get": { 147 | "operationId": "getStructureLatest", 148 | "responses": { 149 | "200": { 150 | "description": "Successful Response" 151 | } 152 | }, 153 | "parameters": [ 154 | { 155 | "name": "detail", 156 | "required": false, 157 | "in": "query", 158 | "schema": { 159 | "type": "string", 160 | "default": "full" 161 | } 162 | }, 163 | { 164 | "name": "references", 165 | "required": false, 166 | "in": "query", 167 | "schema": { 168 | "type": "string", 169 | "default": "none" 170 | } 171 | }, 172 | { 173 | "name": "Accept", 174 | "required": false, 175 | "in": "header", 176 | "schema": { 177 | "type": "string" 178 | } 179 | } 180 | ] 181 | }, 182 | "parameters": [ 183 | { 184 | "name": "structure", 185 | "required": true, 186 | "in": "path", 187 | "schema": { 188 | "type": "string" 189 | } 190 | }, 191 | { 192 | "name": "structure", 193 | "required": true, 194 | "in": "path", 195 | "schema": { 196 | "type": "string" 197 | } 198 | }, 199 | { 200 | "name": "resourceID", 201 | "required": true, 202 | "in": "path", 203 | "schema": { 204 | "type": "string" 205 | } 206 | }, 207 | { 208 | "name": "agencyID", 209 | "required": true, 210 | "in": "path", 211 | "schema": { 212 | "type": "string" 213 | } 214 | } 215 | ] 216 | }, 217 | "/{structure}/{agencyID}": { 218 | "get": { 219 | "operationId": "getStructureAllIdsLatest", 220 | "responses": { 221 | "200": { 222 | "description": "Successful Response" 223 | } 224 | }, 225 | "parameters": [ 226 | { 227 | "name": "detail", 228 | "required": false, 229 | "in": "query", 230 | "schema": { 231 | "type": "string", 232 | "default": "full" 233 | } 234 | }, 235 | { 236 | "name": "references", 237 | "required": false, 238 | "in": "query", 239 | "schema": { 240 | "type": "string", 241 | "default": "none" 242 | } 243 | }, 244 | { 245 | "name": "Accept", 246 | "required": false, 247 | "in": "header", 248 | "schema": { 249 | "type": "string" 250 | } 251 | } 252 | ] 253 | }, 254 | "parameters": [ 255 | { 256 | "name": "structure", 257 | "required": true, 258 | "in": "path", 259 | "schema": { 260 | "type": "string" 261 | } 262 | }, 263 | { 264 | "name": "structure", 265 | "required": true, 266 | "in": "path", 267 | "schema": { 268 | "type": "string" 269 | } 270 | }, 271 | { 272 | "name": "agencyID", 273 | "required": true, 274 | "in": "path", 275 | "schema": { 276 | "type": "string" 277 | } 278 | } 279 | ] 280 | }, 281 | "/data/{flowRef}/{key}/{providerRef}": { 282 | "get": { 283 | "operationId": "getGenericData", 284 | "responses": { 285 | "200": { 286 | "description": "Successful Response" 287 | } 288 | }, 289 | "parameters": [ 290 | { 291 | "name": "Accept", 292 | "required": false, 293 | "in": "header", 294 | "schema": { 295 | "type": "string" 296 | } 297 | } 298 | ] 299 | }, 300 | "parameters": [ 301 | { 302 | "name": "providerRef", 303 | "required": true, 304 | "in": "path", 305 | "schema": { 306 | "type": "string" 307 | } 308 | }, 309 | { 310 | "name": "flowRef", 311 | "required": true, 312 | "in": "path", 313 | "schema": { 314 | "type": "string" 315 | } 316 | }, 317 | { 318 | "name": "key", 319 | "required": true, 320 | "in": "path", 321 | "schema": { 322 | "type": "string" 323 | } 324 | } 325 | ] 326 | }, 327 | "/data/{flowRef}/{key}": { 328 | "get": { 329 | "operationId": "getGenericDataAllProviders", 330 | "responses": { 331 | "200": { 332 | "description": "Successful Response" 333 | } 334 | }, 335 | "parameters": [ 336 | { 337 | "name": "Accept", 338 | "required": false, 339 | "in": "header", 340 | "schema": { 341 | "type": "string" 342 | } 343 | } 344 | ] 345 | }, 346 | "parameters": [ 347 | { 348 | "name": "flowRef", 349 | "required": true, 350 | "in": "path", 351 | "schema": { 352 | "type": "string" 353 | } 354 | }, 355 | { 356 | "name": "key", 357 | "required": true, 358 | "in": "path", 359 | "schema": { 360 | "type": "string" 361 | } 362 | } 363 | ] 364 | }, 365 | "/data/{flowRef}": { 366 | "get": { 367 | "operationId": "getGenericDataAllKeys", 368 | "responses": { 369 | "200": { 370 | "description": "Successful Response" 371 | } 372 | }, 373 | "parameters": [ 374 | { 375 | "name": "Accept", 376 | "required": false, 377 | "in": "header", 378 | "schema": { 379 | "type": "string" 380 | } 381 | } 382 | ] 383 | }, 384 | "parameters": [ 385 | { 386 | "name": "flowRef", 387 | "required": true, 388 | "in": "path", 389 | "schema": { 390 | "type": "string" 391 | } 392 | } 393 | ] 394 | }, 395 | "/": { 396 | "post": { 397 | "responses": { 398 | "200": { 399 | "description": "Successful Response" 400 | } 401 | } 402 | }, 403 | "patch": { 404 | "responses": { 405 | "200": { 406 | "description": "Successful Response" 407 | } 408 | } 409 | }, 410 | "parameters": [] 411 | } 412 | }, 413 | "info": { 414 | "version": "", 415 | "title": "" 416 | } 417 | } 418 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/external-apis/ows01-agenzia-entrate.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Cartografia Catastale WMS 4 | version: 1.3.0 5 | termsOfService: >- 6 | https://www.agenziaentrate.gov.it/portale/documents/20143/260417/Manuale+consultazione+cartografia_Documentazione+descrittiva+del+servizio+di+consultazione+della+cartografia+catastale+20180611.pdf/35e955f7-2344-56c8-1157-8f7567531660 7 | contact: 8 | name: >- 9 | Agenzia delle Entrate – Direzione Centrale Servizi Catastali, Cartografici e di Pubblicità 10 | Immobiliare 11 | url: >- 12 | https://www.agenziaentrate.gov.it/portale/it/web/guest/schede/fabbricatiterreni/consultazione-cartografia-catastale/servizio-consultazione-cartografia 13 | email: assistenzaweb@agenziaentrate.it 14 | license: 15 | name: CC-BY 4.0 16 | url: 'https://creativecommons.org/licenses/by/4.0/' 17 | x-summary: >- 18 | Agenzia delle Entrate - Servizio di consultazione della cartografia catastale WMS. Le informazioni 19 | sono allineate con la banca dati cartografica del Catasto, costantemente aggiornata in modalità 20 | automatica mediante gli atti tecnici predisposti dai professionisti abilitati. Licenza CC BY 4.0. 21 | L'Agenzia è l'amministrazione titolare dei dati. La citazione della titolarità è sempre obbligatoria 22 | in caso d'uso. 23 | x-audience: 24 | - public 25 | x-api-id: 00000000-0000-0000-0000-000000000000 26 | 27 | description: |- 28 | ## Che cos'è 29 | 30 | La consultazione libera della cartografia catastale consiste nella navigazione di molti contenuti 31 | delle mappe catastali in maniera dinamica. 32 | 33 | 34 | Le informazioni sono allineate con la banca dati cartografica del Catasto, costantemente aggiornata in 35 | modalità automatica mediante gli atti tecnici predisposti dai professionisti abilitati. Non sono 36 | esposte le mappe sottoposte a vincoli di riservatezza o quelle sulle quali sono in corso interventi di 37 | manutenzione. 38 | 39 | 40 | L’Agenzia delle Entrate, in attuazione della Direttiva europea INSPIRE - INfrastructure for SPatial 41 | InfoRmation in Europe, mette a disposizione due servizi per la consultazione della cartografia catastale: 42 | 43 | * Consultazione cartografia catastale WMS: basata sullo standard Web Map Service 1.3.0, è fruibile 44 | utilizzando un software GIS (Geographic Information System) o specifiche applicazioni a disposizione 45 | dell’utente 46 | 47 | * Geoportale cartografico catastale: è una piattaforma che consente la ricerca e la visualizzazione 48 | delle particelle presenti sulla mappa del Catasto dei Terreni. 49 | 50 | 51 | Il servizio copre l’intero territorio nazionale, ad eccezione dei territori nei quali il Catasto è 52 | gestito, per delega dello Stato, dalle Province Autonome di Trento e di Bolzano. 53 | 54 | ## URL delle funzionalità (capabilities) 55 | 56 | ``` 57 | https://wms.cartografia.agenziaentrate.gov.it/inspire/wms/ows01.php?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities 58 | ``` 59 | 60 | 61 | 62 | ## Sistemi di riferimento 63 | 64 | Il servizio rende consultabili i dati nel Sistema di riferimento geodetico 65 | nazionale (Decreto 10 novembre 2011) costituito dalla realizzazione ETRF2000 - all'epoca 2008.0 - del 66 | Sistema di riferimento geodetico europeo ETRS89, identificativo EPSG. : 6706. Ai soli fini di una 67 | migliore fruibilità del servizio in ambito INSPIRE, sono disponibili i Sistemi di riferimento relativi 68 | alla realizzazione ETRF89 (codici EPSG 4258, 25832, 25833, 25834, 3044, 3045, 3046). 69 | 70 | ## Statistiche 71 | 72 | In un'ottica di continuo miglioramento, l’Agenzia monitora costantemente l’utilizzo e le prestazioni 73 | dei servizi online di consultazione della cartografia catastale per garantirne la qualità e 74 | l'efficienza. 75 | I dati e le statistiche relative all’uso del servizio sono aggiornati quotidianamente. 76 | [Consulta le Statistiche sull'uso del servizio WMS](https://geoportale.cartografia.agenziaentrate.gov.it/age-inspire/srv/ita/catalog.search#/statistiche?scrollTo=#statistiche-WMS) 77 | 78 | 79 | 80 | ## Livelli di servizio 81 | 82 | Il Servizio di consultazione è stato progettato, secondo la direttiva INSPIRE sui servizi WMS, per 83 | rispondere senza interruzioni orarie. L’Agenzia a garanzia del servizio ha previsto un limite massimo 84 | di richieste contemporanee di consultazione, raggiunto il quale l’interrogazione deve essere ripetuta. 85 | Il servizio non espone le mappe sottoposte a vincoli di riservatezza o quelle sulle quali sono in 86 | corso interventi di manutenzione. 87 | 88 | ## Utilizzo dei dati 89 | 90 | Il servizio di consultazione della cartografia catastale è disponibile con licenza CC-BY 4.0. 91 | L’Agenzia delle Entrate non è responsabile per qualunque tipo di danno diretto, indiretto o 92 | accidentale derivante dall’impiego delle informazioni raccolte tramite questo servizio. 93 | 94 | L’Agenzia, inoltre, è l’amministrazione titolare dei dati; in caso di uso dei dati è obbligatorio 95 | citarne la titolarità. 96 | 97 | ## Contatti e assistenza 98 | 99 | Per maggiori informazioni si può consultare il manuale: [Servizio di Consultazione della cartografia 100 | catastale - Web Map Service (WMS) - pdf](https://www.agenziaentrate.gov.it/portale/documents/20143/260417/Manuale+consultazione+cartografia_Documentazione+descrittiva+del+servizio+di+consultazione+della+cartografia+catastale+20180611.pdf/35e955f7-2344-56c8-1157-8f7567531660) 101 | 102 | Per quesiti relativi al servizio consultare [le risposte alle domande più frequenti(FAQ)](https://www.agenziaentrate.gov.it/portale/web/guest/schede/fabbricatiterreni/consultazione-cartografia-catastale/servizio-consultazione-cartografia/risposte-alle-domande-frequenti) 103 | Per altri 104 | quesiti o segnalazioni scrivere all’indirizzo: assistenzaweb@agenziaentrate.it 105 | 106 | Per informazioni o segnalazioni relative ai dati della cartografia catastale, 107 | rivolgersi agli [Uffici 108 | provinciali - Territorio](https://wwwt.agenziaentrate.gov.it/servizi/UfficiProvinciali/index.php) 109 | 110 | 111 | servers: 112 | - 113 | url: 'https://wms.cartografia.agenziaentrate.gov.it/inspire/wms' 114 | description: Produzione 115 | paths: 116 | /ows01.php: 117 | get: 118 | parameters: 119 | - name: SERVICE 120 | in: query 121 | schema: 122 | type: string 123 | default: WMS 124 | - name: VERSION 125 | in: query 126 | schema: 127 | $ref: '#/components/schemas/Version' 128 | - name: REQUEST 129 | in: query 130 | schema: 131 | $ref: '#/components/schemas/Request' 132 | 133 | tags: 134 | - public 135 | responses: 136 | '200': 137 | headers: 138 | X-Global-Transaction-ID: 139 | schema: 140 | type: string 141 | example: 73b8ec385f71bae55c30209f 142 | content: 143 | text/xml: {} 144 | description: | 145 | La specifica WMS 1.3 ritorna status code 200 sia in caso di successo 146 | che di errore. 147 | 148 | Gli errori ritornano 149 | operationId: get_echo 150 | summary: Mostra le capabilities del servizio in formato WMS 151 | description: > 152 | Questo path mostra tutte le funzionalità del servizio 153 | 154 | in formato WMS compatibilmente con le specifiche INSPIRE. 155 | 156 | 157 | Per ulteriori informazioni si veda il 158 | [Manuale](https://www.agenziaentrate.gov.it/portale/documents/20143/260417/Manuale+consultazione+cartografia_Documentazione+descrittiva+del+servizio+di+consultazione+della+cartografia+catastale+20180611.pdf/35e955f7-2344-56c8-1157-8f7567531660) 159 | components: 160 | schemas: 161 | Version: 162 | description: |- 163 | La versione dello standard WMS adottato è la 1.3.0. 164 | È possibile utilizzare anche le precedenti 165 | versioni 1.1.x 166 | type: string 167 | default: 1.3.0 168 | Request: 169 | description: |- 170 | Sono ammesse le seguenti operazioni: 171 | - GetMap con dimensione massima 2048 x 2048 172 | - GetCapabilities 173 | - GetFeatureInfo sui layer “Particelle”, “Mappe” 174 | - GetLegendGraphic 175 | enum: 176 | - GetCapabilities 177 | - GetMap 178 | - GetFeatureInfo 179 | - GetLegentGraphic 180 | type: string 181 | default: GetCapabilities 182 | ServiceExceptionReport: 183 | type: string 184 | externalDocs: 185 | description: an error type 186 | url: 'http://cite.opengeospatial.org/OGCTestData/wfs/1.0.0/schemas/OGC-1.0.0/OGC-exception.xsd' 187 | example: |- 188 | 189 | 190 | 191 | 192 | 193 | tags: 194 | - 195 | name: public 196 | description: Consultazione 197 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/marketplace-catalog.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | servers: 3 | - description: SwaggerHub API Auto Mocking 4 | url: >- 5 | https://virtserver.swaggerhub.com/ioggstream_italia/marketplace-italia/0.0.1 6 | info: 7 | description: Test 8 | version: 0.0.1 9 | title: Interfaccia Standard Marketplace API 10 | contact: 11 | email: roberto@teamdigitale.governo.it 12 | license: 13 | name: Apache 2.0 14 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 15 | tags: 16 | - name: catalogo 17 | description: Qualsiasi cosa circa il catalogo API 18 | - name: stats 19 | description: Circa le statistiche di utilizzo 20 | - name: security 21 | description: Per richiedere un'API Key 22 | paths: 23 | /catalogo: 24 | post: 25 | tags: 26 | - catalogo 27 | summary: Aggiungi un nuovo end-point API al catalogo 28 | description: >- 29 | Con questa operazione è possibile aggiungere al catalogo esposto al 30 | marketplace una nuova API di dominio 31 | operationId: addCatalogo 32 | responses: 33 | '405': 34 | description: Invalid input 35 | requestBody: 36 | content: 37 | application/json: 38 | schema: 39 | $ref: '#/components/schemas/APIs' 40 | description: oggetto API che si vuole aggiungere al catalogo 41 | required: true 42 | get: 43 | tags: 44 | - catalogo 45 | summary: Lista di tutte le API pubblicate dal medesimo servizio 46 | description: >- 47 | Con questa operazione è possibile listare tutte le API messe a 48 | disposizione dallo specifico erogatore 49 | operationId: showCatalogo 50 | parameters: 51 | - $ref: '#/components/parameters/limit' 52 | - $ref: '#/components/parameters/offset' 53 | responses: 54 | '200': 55 | headers: &common_headers 56 | X-RateLimit-Limit: 57 | $ref: "#/components/headers/X-RateLimit-Limit" 58 | X-RateLimit-Remaining: 59 | $ref: "#/components/headers/X-RateLimit-Remaining" 60 | X-RateLimit-Reset: 61 | $ref: "#/components/headers/X-RateLimit-Reset" 62 | 63 | description: Lista API 64 | content: 65 | application/json: 66 | schema: 67 | $ref: '#/components/schemas/APIs' 68 | '404': 69 | $ref: '#/components/responses/APIsDoesNotExistResponse' 70 | '500': 71 | $ref: '#/components/responses/Standard500ErrorResponse' 72 | '/catalogo/{id}': 73 | get: 74 | tags: 75 | - catalogo 76 | summary: Lettura dei metadati caratteristici di ogni API esposta 77 | description: >- 78 | Con questa operazione è possibile leggere tutti i meta dati di una 79 | singola API esposta dall'erogatore 80 | operationId: showSingolaAPI 81 | parameters: 82 | - $ref: '#/components/parameters/id' 83 | responses: 84 | '200': 85 | <<: *common_headers 86 | description: Meta data API 87 | content: 88 | application/json: 89 | schema: 90 | $ref: '#/components/schemas/APIs' 91 | '404': 92 | $ref: '#/components/responses/APIsDoesNotExistResponse' 93 | '500': 94 | $ref: '#/components/responses/Standard500ErrorResponse' 95 | /stats: 96 | get: 97 | tags: 98 | - stats 99 | summary: >- 100 | Recupera per tutte le API registrate dall'erogatore le statistiche di 101 | base 102 | description: >- 103 | Statistiche esposte per singola API: Numero di accessi totali, media 104 | giornaliera, numero errori totali, media giornaliera 105 | operationId: statsAPI 106 | responses: 107 | '200': 108 | description: Stats API 109 | <<: *common_headers 110 | content: 111 | application/json: 112 | schema: 113 | $ref: '#/components/schemas/Stats' 114 | '404': 115 | $ref: '#/components/responses/StatsDoesNotExistsResponse' 116 | '500': 117 | $ref: '#/components/responses/Standard500ErrorResponse' 118 | /getKey: 119 | post: 120 | tags: 121 | - security 122 | summary: Richiedi un'API Key per accedere alle API della PA erogatotrice 123 | description: >- 124 | E' possibile chiedere la generazione di un API Key da utilizzare poi per 125 | autenticare le chiamate alle API erogate dalla PA che gestisce questa 126 | serie di API 127 | operationId: apikey 128 | responses: 129 | '200': 130 | description: API key generata con successo 131 | content: 132 | application/json: 133 | schema: 134 | $ref: '#/components/schemas/ApiKey' 135 | '404': 136 | $ref: '#/components/responses/APIsDoesNotExistResponse' 137 | '500': 138 | $ref: '#/components/responses/Standard500ErrorResponse' 139 | '501': 140 | $ref: '#/components/responses/UsernameAlreadyExists' 141 | default: 142 | description: API key generata con successo 143 | content: 144 | application/json: 145 | schema: 146 | $ref: '#/components/schemas/ApiKey' 147 | requestBody: 148 | content: 149 | application/json: 150 | schema: 151 | $ref: '#/components/schemas/GetKey' 152 | description: Creazione di una API Key 153 | required: true 154 | components: 155 | schemas: 156 | API: 157 | type: object 158 | required: 159 | - api_endpoint 160 | - id 161 | - openapi_url 162 | properties: 163 | api_endpoint: 164 | description: URI dell'endpoint della singola API 165 | type: string 166 | format: url 167 | example: 'https://api.server.it/path/api-handler' 168 | id: 169 | description: Id univoco interno della singola API 170 | type: string 171 | format: uuid 172 | example: 15 173 | openapi_url: 174 | type: string 175 | format: url 176 | description: Link al documento swagger/OpenAPI di specifica 177 | example: 'https://api.server.it/swagger/api-handler' 178 | Description: 179 | type: string 180 | description: Overview dello scopo della singola API 181 | example: Questo handler API permetter la creazione di un utente... 182 | Parameters: 183 | type: array 184 | items: 185 | properties: 186 | name: 187 | type: string 188 | type: object 189 | httpVerb: 190 | type: string 191 | example: GET 192 | enum: 193 | - GET 194 | - POST 195 | - PUT 196 | - DELETE 197 | description: Quale HTTP verb è associato alla singola API 198 | responses: 199 | type: string 200 | properties: 201 | parameters: 202 | enum: 203 | - '200' 204 | - '404' 205 | - '500' 206 | - '501' 207 | APIs: 208 | type: array 209 | items: 210 | $ref: '#/components/schemas/API' 211 | SingleAPIStats: 212 | type: array 213 | items: 214 | properties: 215 | id: 216 | type: string 217 | format: uuid 218 | description: API uuid from #/info/x-api-id 219 | api_endpoint: 220 | type: string 221 | format: uri 222 | description: API endpoint 223 | request_count: 224 | type: integer 225 | format: int64 226 | description: cumulative number of requests since the service startup. 227 | http_request_size_bytes_max: 228 | type: integer 229 | format: int64 230 | description: Max request size allowed 231 | availability_per_30d: 232 | type: number 233 | format: float 234 | description: "% of time when the service was available during the last 30 days." 235 | http_response_success_rate_1d: 236 | type: number 237 | format: float 238 | description: "% of successful call during the last 24 hours." 239 | expected_response_time_seconds_85pp: 240 | type: number 241 | format: float 242 | description: Expected response time at 85pp 243 | responsiveness_per_30d: 244 | type: number 245 | format: float 246 | description: |- 247 | "% of time when 85% of the requests was served before the deadline expiration." 248 | type: object 249 | Stats: 250 | type: array 251 | items: 252 | $ref: '#/components/schemas/SingleAPIStats' 253 | GetKey: 254 | type: object 255 | properties: 256 | username: 257 | type: string 258 | description: username a cui associare la nuova appkey 259 | example: grossi 260 | name: 261 | type: string 262 | description: Nome del richiedente 263 | example: Guido 264 | surname: 265 | type: string 266 | description: Cognome del richiedente 267 | example: Rossi 268 | email: 269 | type: string 270 | description: eMail del richidente 271 | example: g.rossi@emailente.it 272 | phone: 273 | type: string 274 | description: Telefono del richiedente 275 | example: '0611111111' 276 | xml: 277 | name: GetKey 278 | ApiKey: 279 | type: object 280 | properties: 281 | apikey: 282 | type: string 283 | example: AIzaSyDHeXm6n1hyGIEO_wgscTYUBDJcMmqgOtA 284 | Problem: 285 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/schemas/Problem" 286 | 287 | responses: 288 | Standard500ErrorResponse: 289 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/default" 290 | APIsDoesNotExistResponse: 291 | description: API non esistenti per questo erogatore. 292 | StatsDoesNotExistsResponse: 293 | description: Stats non esisteti per queste API dell'erogatore. 294 | UsernameAlreadyExists: 295 | description: Username already existing 296 | 429TooManyRequests: 297 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/429TooManyRequests" 298 | 503ServiceUnavailable: 299 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/responses/503ServiceUnavailable" 300 | 301 | headers: 302 | X-RateLimit-Limit: 303 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Limit" 304 | X-RateLimit-Remaining: 305 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Remaining" 306 | X-RateLimit-Reset: 307 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/headers/X-RateLimit-Reset" 308 | parameters: 309 | id: 310 | name: id 311 | in: path 312 | description: l'ID dell'API che si vuole analizzare 313 | required: true 314 | schema: 315 | type: integer 316 | limit: 317 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/limit" 318 | offset: 319 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/offset" 320 | sort: 321 | $ref: "https://raw.githubusercontent.com/teamdigitale/openapi/0.0.3/docs/definitions.yaml#/parameters/sort" 322 | -------------------------------------------------------------------------------- /tests/schemas/sample-schemas/metadata.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Metatada catalog items extending OAS3 schemas 3 | # 4 | 5 | oas-3.0: 6 | info: 7 | type: object 8 | required: 9 | - x-api-id 10 | - x-summary 11 | - title 12 | - version 13 | - description 14 | - termsOfService 15 | - contact 16 | - x-lifecycle 17 | properties: 18 | x-api-id: 19 | $ref: '#/Api_id' 20 | x-summary: 21 | $ref: '#/Summary' 22 | contact: 23 | required: 24 | - email 25 | - name 26 | - url 27 | x-lifecycle: 28 | $ref: '#/Lifecycle' 29 | servers: 30 | type: array 31 | items: 32 | properties: 33 | x-healthCheck: 34 | $ref: '#/HealthCheck' 35 | x-sandbox: 36 | $ref: '#/Sandbox' 37 | 38 | Sandbox: 39 | type: boolean 40 | description: An one-liner summary for the API 41 | 42 | Summary: 43 | type: string 44 | description: An one-liner summary for the API 45 | 46 | Api_id: 47 | type: string 48 | pattern: '[0-9a-f\-]+' 49 | description: A unique id for the API 50 | 51 | Lifecycle: 52 | required: 53 | - maturity 54 | properties: 55 | published: 56 | type: string 57 | format: date 58 | deprecated: 59 | type: string 60 | format: date 61 | retired: 62 | type: string 63 | format: date 64 | maturity: 65 | type: string 66 | enum: [proposal,developing,published,deprecated,retired] 67 | 68 | Catalog: 69 | properties: 70 | tag: 71 | description: >- 72 | A list of tags useful for catalog search purposes. 73 | type: array 74 | items: 75 | type: string 76 | category: 77 | tag: 78 | type: array 79 | items: 80 | type: string 81 | context: 82 | type: array 83 | items: 84 | $ref: '#/Context' 85 | ecosystem: 86 | type: array 87 | items: 88 | $ref: '#/Ecosystem' 89 | 90 | 91 | Context: 92 | description: >- 93 | WRITEME @stefkohub 94 | properties: 95 | name: 96 | type: string 97 | description: 98 | type: string 99 | 100 | Ecosystem: 101 | description: >- 102 | WRITEME @stefkohub 103 | properties: 104 | name: 105 | type: string 106 | description: 107 | type: string 108 | url: 109 | type: string 110 | 111 | HealthCheck: 112 | description: >- 113 | HealthCheck informations for testing API status. 114 | required: 115 | - url 116 | - interval 117 | - timeout 118 | properties: 119 | url: 120 | type: string 121 | format: url 122 | description: absolute or relative url to the healthcheck path 123 | interval: 124 | type: number 125 | description: expected seconds between two checks 126 | timeout: 127 | type: number 128 | description: expected timeout interval after which a request should timeout 129 | -------------------------------------------------------------------------------- /tests/schemas/spectactular_reference_schema.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: '' 4 | version: 0.0.0 5 | paths: 6 | /api/v1/{custom_id_field}/names: 7 | get: 8 | operationId: api_v1_names_retrieve 9 | description: '' 10 | parameters: 11 | - in: path 12 | name: custom_id_field 13 | schema: 14 | type: integer 15 | description: A unique value identifying this names. 16 | required: true 17 | tags: 18 | - api 19 | security: 20 | - cookieAuth: [] 21 | - {} 22 | responses: 23 | '200': 24 | content: 25 | application/json: 26 | schema: 27 | $ref: '#/components/schemas/Names' 28 | description: '' 29 | /api/v1/animals: 30 | get: 31 | operationId: api_v1_animals_retrieve 32 | description: '' 33 | tags: 34 | - api 35 | security: 36 | - cookieAuth: [] 37 | - {} 38 | responses: 39 | '200': 40 | description: No response body 41 | delete: 42 | operationId: api_v1_animals_destroy 43 | description: '' 44 | tags: 45 | - api 46 | security: 47 | - cookieAuth: [] 48 | - {} 49 | responses: 50 | '204': 51 | description: No response body 52 | /api/v1/cars/correct: 53 | get: 54 | operationId: api_v1_cars_correct_list 55 | description: '' 56 | tags: 57 | - api 58 | security: 59 | - cookieAuth: [] 60 | - {} 61 | responses: 62 | '200': 63 | content: 64 | application/json: 65 | schema: 66 | type: array 67 | items: 68 | $ref: '#/components/schemas/Car' 69 | description: '' 70 | post: 71 | operationId: api_v1_cars_correct_create 72 | description: '' 73 | tags: 74 | - api 75 | security: 76 | - cookieAuth: [] 77 | - {} 78 | responses: 79 | '200': 80 | description: No response body 81 | put: 82 | operationId: api_v1_cars_correct_update 83 | description: '' 84 | tags: 85 | - api 86 | security: 87 | - cookieAuth: [] 88 | - {} 89 | responses: 90 | '200': 91 | description: No response body 92 | delete: 93 | operationId: api_v1_cars_correct_destroy 94 | description: '' 95 | tags: 96 | - api 97 | security: 98 | - cookieAuth: [] 99 | - {} 100 | responses: 101 | '204': 102 | description: No response body 103 | /api/v1/cars/incorrect: 104 | get: 105 | operationId: api_v1_cars_incorrect_list 106 | description: '' 107 | tags: 108 | - api 109 | security: 110 | - cookieAuth: [] 111 | - {} 112 | responses: 113 | '200': 114 | content: 115 | application/json: 116 | schema: 117 | type: array 118 | items: 119 | $ref: '#/components/schemas/Car' 120 | description: '' 121 | post: 122 | operationId: api_v1_cars_incorrect_create 123 | description: '' 124 | tags: 125 | - api 126 | security: 127 | - cookieAuth: [] 128 | - {} 129 | responses: 130 | '200': 131 | description: No response body 132 | put: 133 | operationId: api_v1_cars_incorrect_update 134 | description: '' 135 | tags: 136 | - api 137 | security: 138 | - cookieAuth: [] 139 | - {} 140 | responses: 141 | '200': 142 | description: No response body 143 | delete: 144 | operationId: api_v1_cars_incorrect_destroy 145 | description: '' 146 | tags: 147 | - api 148 | security: 149 | - cookieAuth: [] 150 | - {} 151 | responses: 152 | '204': 153 | description: No response body 154 | /api/v1/exempt-endpoint: 155 | get: 156 | operationId: api_v1_exempt_endpoint_retrieve 157 | description: '' 158 | tags: 159 | - api 160 | security: 161 | - cookieAuth: [] 162 | - {} 163 | responses: 164 | '200': 165 | description: No response body 166 | /api/v1/items: 167 | post: 168 | operationId: api_v1_items_create 169 | description: '' 170 | tags: 171 | - api 172 | security: 173 | - cookieAuth: [] 174 | - {} 175 | responses: 176 | '200': 177 | description: No response body 178 | /api/v1/router_generated/names/: 179 | get: 180 | operationId: api_v1_router_generated_names_list 181 | description: '' 182 | tags: 183 | - api 184 | security: 185 | - cookieAuth: [] 186 | - {} 187 | responses: 188 | '200': 189 | content: 190 | application/json: 191 | schema: 192 | type: array 193 | items: 194 | $ref: '#/components/schemas/Names' 195 | description: '' 196 | /api/v1/router_generated/names/{custom_id_field}/: 197 | get: 198 | operationId: api_v1_router_generated_names_retrieve 199 | description: '' 200 | parameters: 201 | - in: path 202 | name: custom_id_field 203 | schema: 204 | type: integer 205 | description: A unique value identifying this names. 206 | required: true 207 | tags: 208 | - api 209 | security: 210 | - cookieAuth: [] 211 | - {} 212 | responses: 213 | '200': 214 | content: 215 | application/json: 216 | schema: 217 | $ref: '#/components/schemas/Names' 218 | description: '' 219 | /api/v1/snake-case/: 220 | get: 221 | operationId: api_v1_snake_case_list 222 | description: '' 223 | tags: 224 | - api 225 | security: 226 | - cookieAuth: [] 227 | - {} 228 | responses: 229 | '200': 230 | content: 231 | application/json: 232 | schema: 233 | type: array 234 | items: 235 | $ref: '#/components/schemas/SnakeCase' 236 | description: '' 237 | /api/v1/trucks/correct: 238 | get: 239 | operationId: api_v1_trucks_correct_list 240 | description: '' 241 | tags: 242 | - api 243 | security: 244 | - cookieAuth: [] 245 | - {} 246 | responses: 247 | '200': 248 | content: 249 | application/json: 250 | schema: 251 | type: array 252 | items: 253 | $ref: '#/components/schemas/Car' 254 | description: '' 255 | post: 256 | operationId: api_v1_trucks_correct_create 257 | description: '' 258 | tags: 259 | - api 260 | security: 261 | - cookieAuth: [] 262 | - {} 263 | responses: 264 | '200': 265 | description: No response body 266 | put: 267 | operationId: api_v1_trucks_correct_update 268 | description: '' 269 | tags: 270 | - api 271 | security: 272 | - cookieAuth: [] 273 | - {} 274 | responses: 275 | '200': 276 | description: No response body 277 | delete: 278 | operationId: api_v1_trucks_correct_destroy 279 | description: '' 280 | tags: 281 | - api 282 | security: 283 | - cookieAuth: [] 284 | - {} 285 | responses: 286 | '204': 287 | description: No response body 288 | /api/v1/trucks/incorrect: 289 | get: 290 | operationId: api_v1_trucks_incorrect_list 291 | description: '' 292 | tags: 293 | - api 294 | security: 295 | - cookieAuth: [] 296 | - {} 297 | responses: 298 | '200': 299 | content: 300 | application/json: 301 | schema: 302 | type: array 303 | items: 304 | $ref: '#/components/schemas/Car' 305 | description: '' 306 | post: 307 | operationId: api_v1_trucks_incorrect_create 308 | description: '' 309 | tags: 310 | - api 311 | security: 312 | - cookieAuth: [] 313 | - {} 314 | responses: 315 | '200': 316 | description: No response body 317 | put: 318 | operationId: api_v1_trucks_incorrect_update 319 | description: '' 320 | tags: 321 | - api 322 | security: 323 | - cookieAuth: [] 324 | - {} 325 | responses: 326 | '200': 327 | description: No response body 328 | delete: 329 | operationId: api_v1_trucks_incorrect_destroy 330 | description: '' 331 | tags: 332 | - api 333 | security: 334 | - cookieAuth: [] 335 | - {} 336 | responses: 337 | '204': 338 | description: No response body 339 | /api/v1/vehicles: 340 | post: 341 | operationId: api_v1_vehicles_create 342 | description: '' 343 | tags: 344 | - api 345 | security: 346 | - cookieAuth: [] 347 | - {} 348 | responses: 349 | '200': 350 | description: No response body 351 | /en/api/v1/i18n: 352 | get: 353 | operationId: en_api_v1_i18n_retrieve 354 | description: '' 355 | tags: 356 | - en 357 | security: 358 | - cookieAuth: [] 359 | - {} 360 | responses: 361 | '200': 362 | description: No response body 363 | components: 364 | schemas: 365 | BlankEnum: 366 | enum: 367 | - '' 368 | Car: 369 | type: object 370 | properties: 371 | name: 372 | type: string 373 | maxLength: 254 374 | color: 375 | type: string 376 | maxLength: 254 377 | height: 378 | type: string 379 | maxLength: 254 380 | width: 381 | type: string 382 | maxLength: 254 383 | length: 384 | type: string 385 | maxLength: 254 386 | required: 387 | - color 388 | - height 389 | - length 390 | - name 391 | - width 392 | NameEnum: 393 | enum: 394 | - mo 395 | - moi 396 | - mu 397 | type: string 398 | Names: 399 | type: object 400 | properties: 401 | custom_id_field: 402 | type: integer 403 | name: 404 | nullable: true 405 | oneOf: 406 | - $ref: '#/components/schemas/NameEnum' 407 | - $ref: '#/components/schemas/BlankEnum' 408 | - $ref: '#/components/schemas/NullEnum' 409 | required: 410 | - custom_id_field 411 | NullEnum: 412 | enum: 413 | - null 414 | SnakeCase: 415 | type: object 416 | properties: 417 | this_is_snake_case: 418 | type: string 419 | required: 420 | - this_is_snake_case 421 | securitySchemes: 422 | cookieAuth: 423 | type: apiKey 424 | in: cookie 425 | name: Session 426 | -------------------------------------------------------------------------------- /tests/test_case_validators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from openapi_tester.case_testers import is_camel_case, is_kebab_case, is_pascal_case, is_snake_case 4 | from openapi_tester.exceptions import CaseError 5 | 6 | camel_case_test_data = [ 7 | {"incorrect": "snake_case", "correct": "snakeCase"}, 8 | {"incorrect": "PascalCase", "correct": "pascalCase"}, 9 | {"incorrect": "kebab-case", "correct": "kebabCase"}, 10 | {"incorrect": "UPPER", "correct": "upper"}, 11 | ] 12 | 13 | 14 | def test_camel_cased_words(): 15 | """ 16 | Verifies that our camel case verification function actually works as expected. 17 | """ 18 | for item in camel_case_test_data: 19 | is_camel_case(item["correct"]) 20 | with pytest.raises(CaseError): 21 | is_camel_case(item["incorrect"]) 22 | 23 | 24 | def test_camel_case_less_than_two_chars(): 25 | """ 26 | When the length of an input is less than 2, our regex logic breaks down, 27 | """ 28 | is_camel_case("") 29 | is_camel_case("s") 30 | is_camel_case(" ") 31 | is_camel_case("_") 32 | is_camel_case("%") 33 | with pytest.raises(CaseError): 34 | is_camel_case("-") 35 | 36 | 37 | kebab_case_test_data = [ 38 | {"incorrect": "snake_case", "correct": "snake-case"}, 39 | {"incorrect": "PascalCase", "correct": "pascal-case"}, 40 | {"incorrect": "camelCase", "correct": "camel-case"}, 41 | {"incorrect": "UPPER", "correct": "u-p-p-e-r"}, 42 | ] 43 | 44 | 45 | def test_kebab_cased_words(): 46 | """ 47 | Verifies that our kebab case verification function actually works as expected. 48 | """ 49 | for item in kebab_case_test_data: 50 | is_kebab_case(item["correct"]) 51 | with pytest.raises(CaseError): 52 | is_kebab_case(item["incorrect"]) 53 | 54 | 55 | def test_kebab_case_less_than_two_chars(): 56 | """ 57 | When the length of an input is less than 2, our regex logic breaks down, 58 | :return: 59 | """ 60 | is_kebab_case("") 61 | is_kebab_case("s") 62 | is_kebab_case("") 63 | is_kebab_case(" ") 64 | is_kebab_case("-") 65 | is_kebab_case("%") 66 | with pytest.raises(CaseError): 67 | is_kebab_case("R") 68 | 69 | 70 | pascal_case_test_data = [ 71 | {"incorrect": "snake_case", "correct": "SnakeCase"}, 72 | {"incorrect": "camelCase", "correct": "CamelCase"}, 73 | {"incorrect": "kebab-case", "correct": "KebabCase"}, 74 | {"incorrect": "l ower", "correct": "Lower"}, 75 | {"incorrect": "uPPER", "correct": "Upper"}, 76 | ] 77 | 78 | 79 | def test_pascal_cased_words(): 80 | """ 81 | Verifies that our pascal case verification function actually works as expected. 82 | """ 83 | for item in pascal_case_test_data: 84 | is_pascal_case(item["correct"]) 85 | with pytest.raises(CaseError): 86 | is_pascal_case(item["incorrect"]) 87 | 88 | 89 | def test_pascal_case_less_than_two_chars(): 90 | """ 91 | When the length of an input is less than 2, our regex logic breaks down, 92 | :return: 93 | """ 94 | is_pascal_case("") 95 | is_pascal_case("S") 96 | is_pascal_case(" ") 97 | is_pascal_case("_") 98 | is_pascal_case("%") 99 | with pytest.raises(CaseError): 100 | is_pascal_case("-") 101 | with pytest.raises(CaseError): 102 | is_pascal_case("s") 103 | 104 | 105 | snake_case_test_data = [ 106 | {"incorrect": "camelCase", "correct": "camel_case"}, 107 | {"incorrect": "PascalCase", "correct": "pascal_case"}, 108 | {"incorrect": "kebab-case", "correct": "kebab_case"}, 109 | {"incorrect": "UPPER", "correct": "u_p_p_e_r"}, 110 | ] 111 | 112 | 113 | def test_snake_cased_words(): 114 | """ 115 | Verifies that our snake case verification function actually works as expected. 116 | """ 117 | for item in snake_case_test_data: 118 | is_snake_case(item["correct"]) 119 | with pytest.raises(CaseError): 120 | is_snake_case(item["incorrect"]) 121 | 122 | 123 | def test_snake_case_less_than_two_chars(): 124 | """ 125 | When the length of an input is less than 2, our regex logic breaks down, 126 | :return: 127 | """ 128 | is_snake_case("") 129 | is_snake_case("s") 130 | is_snake_case(" ") 131 | is_snake_case("_") 132 | is_snake_case("%") 133 | with pytest.raises(CaseError): 134 | is_snake_case("-") 135 | with pytest.raises(CaseError): 136 | is_snake_case("R") 137 | -------------------------------------------------------------------------------- /tests/test_clients.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | 4 | import pytest 5 | from django.test.testcases import SimpleTestCase 6 | from rest_framework import status 7 | 8 | from openapi_tester.clients import OpenAPIClient 9 | from openapi_tester.exceptions import UndocumentedSchemaSectionError 10 | from openapi_tester.schema_tester import SchemaTester 11 | 12 | 13 | @pytest.fixture() 14 | def openapi_client(settings) -> OpenAPIClient: 15 | """Sample ``OpenAPIClient`` instance to use in tests.""" 16 | # use `drf-yasg` schema loader in tests 17 | settings.INSTALLED_APPS = [app for app in settings.INSTALLED_APPS if app != "drf_spectacular"] 18 | return OpenAPIClient() 19 | 20 | 21 | def test_init_schema_tester_passed(): 22 | """Ensure passed ``SchemaTester`` instance is used.""" 23 | schema_tester = SchemaTester() 24 | 25 | client = OpenAPIClient(schema_tester=schema_tester) 26 | 27 | assert client.schema_tester is schema_tester 28 | 29 | 30 | @pytest.mark.parametrize( 31 | ("generic_kwargs", "expected_status_code"), 32 | [ 33 | ( 34 | {"method": "GET", "path": "/api/v1/cars/correct"}, 35 | status.HTTP_200_OK, 36 | ), 37 | ( 38 | { 39 | "method": "POST", 40 | "path": "/api/v1/vehicles", 41 | "data": json.dumps({"vehicle_type": "suv"}), 42 | "content_type": "application/json", 43 | }, 44 | status.HTTP_201_CREATED, 45 | ), 46 | ], 47 | ) 48 | def test_request(openapi_client, generic_kwargs, expected_status_code): 49 | """Ensure ``SchemaTester`` doesn't raise exception when response valid.""" 50 | response = openapi_client.generic(**generic_kwargs) 51 | 52 | assert response.status_code == expected_status_code 53 | 54 | 55 | def test_request_on_empty_list(openapi_client): 56 | """Ensure ``SchemaTester`` doesn't raise exception when response is empty list.""" 57 | response = openapi_client.generic( 58 | method="GET", 59 | path="/api/v1/empty-names", 60 | content_type="application/json", 61 | ) 62 | assert response.status_code == status.HTTP_200_OK, response.data 63 | 64 | 65 | @pytest.mark.parametrize( 66 | ("generic_kwargs", "raises_kwargs"), 67 | [ 68 | ( 69 | { 70 | "method": "POST", 71 | "path": "/api/v1/vehicles", 72 | "data": json.dumps({"vehicle_type": "1" * 50}), 73 | "content_type": "application/json", 74 | }, 75 | { 76 | "expected_exception": UndocumentedSchemaSectionError, 77 | "match": "Undocumented status code: 400", 78 | }, 79 | ), 80 | ( 81 | {"method": "PUT", "path": "/api/v1/animals"}, 82 | { 83 | "expected_exception": UndocumentedSchemaSectionError, 84 | "match": "Undocumented method: put", 85 | }, 86 | ), 87 | ], 88 | ) 89 | def test_request_invalid_response( 90 | openapi_client, 91 | generic_kwargs, 92 | raises_kwargs, 93 | ): 94 | """Ensure ``SchemaTester`` raises an exception when response is invalid.""" 95 | with pytest.raises(**raises_kwargs): # noqa: PT010 96 | openapi_client.generic(**generic_kwargs) 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "openapi_client_class", 101 | [ 102 | OpenAPIClient, 103 | functools.partial(OpenAPIClient, schema_tester=SchemaTester()), 104 | ], 105 | ) 106 | def test_django_testcase_client_class(openapi_client_class): 107 | """Ensure example from README.md about Django test case works fine.""" 108 | 109 | class DummyTestCase(SimpleTestCase): 110 | """Django ``TestCase`` with ``OpenAPIClient`` client.""" 111 | 112 | client_class = openapi_client_class 113 | 114 | test_case = DummyTestCase() 115 | test_case._pre_setup() 116 | 117 | assert isinstance(test_case.client, OpenAPIClient) 118 | -------------------------------------------------------------------------------- /tests/test_django_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.urls import reverse 6 | from rest_framework.test import APITestCase 7 | 8 | from openapi_tester import SchemaTester 9 | from tests.utils import TEST_ROOT 10 | 11 | if TYPE_CHECKING: 12 | from rest_framework.response import Response 13 | 14 | schema_tester = SchemaTester(schema_file_path=str(TEST_ROOT) + "/schemas/sample-schemas/content_types.yaml") 15 | 16 | 17 | class BaseAPITestCase(APITestCase): 18 | """Base test class for api views including schema validation""" 19 | 20 | @staticmethod 21 | def assertResponse(response: Response, **kwargs) -> None: 22 | """helper to run validate_response and pass kwargs to it""" 23 | schema_tester.validate_response(response=response, **kwargs) 24 | 25 | 26 | class PetsAPITests(BaseAPITestCase): 27 | def test_get_pet_by_id(self): 28 | response = self.client.get( 29 | reverse( 30 | "get-pet", 31 | kwargs={ 32 | "petId": 1, 33 | }, 34 | ), 35 | content_type="application/vnd.api+json", 36 | ) 37 | assert response.status_code == 200 38 | self.assertResponse(response) 39 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from openapi_tester import SchemaTester 4 | from openapi_tester.exceptions import CaseError, DocumentationError 5 | from openapi_tester.validators import ( 6 | validate_enum, 7 | validate_format, 8 | validate_max_items, 9 | validate_max_length, 10 | validate_max_properties, 11 | validate_maximum, 12 | validate_min_items, 13 | validate_min_length, 14 | validate_min_properties, 15 | validate_minimum, 16 | validate_multiple_of, 17 | validate_pattern, 18 | validate_type, 19 | validate_unique_items, 20 | ) 21 | 22 | 23 | def test_case_error_message(): 24 | error = CaseError(key="test-key", case="camelCase", expected="testKey") 25 | assert error.args[0].strip() == "The response key `test-key` is not properly camelCase. Expected value: testKey" 26 | 27 | 28 | class TestValidatorErrors: 29 | # Message only-format exceptions 30 | 31 | def test_validate_min_properties_error(self): 32 | message = validate_min_properties({"minProperties": 2}, {}) 33 | assert message == "The number of properties in {} is fewer than the specified minimum number of properties of 2" 34 | 35 | def test_validate_max_properties_error(self): 36 | message = validate_max_properties({"maxProperties": 1}, {"one": 1, "two": 2}) 37 | assert ( 38 | message 39 | == "The number of properties in {'one': 1, 'two': 2} exceeds the" 40 | " specified maximum number of properties of 1" 41 | ) 42 | 43 | def test_validate_max_items_error(self): 44 | message = validate_max_items({"maxItems": 1}, [1, 2]) 45 | assert message == "The length of the array [1, 2] exceeds the specified maximum length of 1" 46 | 47 | def test_validate_min_items_error(self): 48 | message = validate_min_items({"minItems": 1}, []) 49 | assert message == "The length of the array [] is shorter than the specified minimum length of 1" 50 | 51 | def test_validate_max_length_error(self): 52 | message = validate_max_length({"maxLength": 1}, "test") 53 | assert message == 'The length of "test" exceeds the specified maximum length of 1' 54 | 55 | def test_validate_min_length_error(self): 56 | message = validate_min_length({"minLength": 5}, "test") 57 | assert message == 'The length of "test" is shorter than the specified minimum length of 5' 58 | 59 | def test_validate_unique_items_error(self): 60 | message = validate_unique_items({"uniqueItems": True}, [1, 2, 1]) 61 | assert message == "The array [1, 2, 1] must contain unique items only" 62 | 63 | def test_validate_minimum_error(self): 64 | message = validate_minimum({"minimum": 2}, 0) 65 | assert message == "The response value 0 is lower than the specified minimum of 2" 66 | 67 | def test_validate_exclusive_minimum_error(self): 68 | message = validate_minimum({"minimum": 2, "exclusiveMinimum": True}, 2) 69 | assert message == "The response value 2 is lower than the specified minimum of 3" 70 | 71 | message = validate_minimum({"minimum": 2, "exclusiveMinimum": False}, 2) 72 | assert message is None 73 | 74 | def test_validate_maximum_error(self): 75 | message = validate_maximum({"maximum": 2}, 3) 76 | assert message == "The response value 3 exceeds the maximum allowed value of 2" 77 | 78 | def test_validate_exclusive_maximum_error(self): 79 | message = validate_maximum({"maximum": 2, "exclusiveMaximum": True}, 2) 80 | assert message == "The response value 2 exceeds the maximum allowed value of 1" 81 | 82 | message = validate_maximum({"maximum": 2, "exclusiveMaximum": False}, 2) 83 | assert message is None 84 | 85 | def test_validate_multiple_of_error(self): 86 | message = validate_multiple_of({"multipleOf": 2}, 3) 87 | assert message == "The response value 3 should be a multiple of 2" 88 | 89 | def test_validate_pattern_error(self): 90 | message = validate_pattern({"pattern": "^[a-z]$"}, "3") 91 | assert message == 'The string "3" does not match the specified pattern: ^[a-z]$' 92 | 93 | # Formatted errors 94 | 95 | def test_validate_enum_error(self): 96 | message = validate_enum({"enum": ["Cat"]}, "Turtle") 97 | assert message == "Expected: a member of the enum ['Cat']\n\nReceived: \"Turtle\"" 98 | 99 | def test_validate_format_error(self): 100 | d = [ 101 | ({"format": "byte"}, "not byte"), 102 | ({"format": "base64"}, "not byte"), 103 | ({"format": "date"}, "not date"), 104 | ({"format": "date-time"}, "not date-time"), 105 | ({"format": "double"}, "not double"), 106 | ({"format": "email"}, "not email"), 107 | ({"format": "float"}, "not float"), 108 | ({"format": "ipv4"}, "not ipv4"), 109 | ({"format": "ipv6"}, "not ipv6"), 110 | ({"format": "time"}, "not time"), 111 | ({"format": "uri"}, "not uri"), 112 | ({"format": "url"}, "not url"), 113 | ] 114 | for schema, data in d: 115 | message = validate_format(schema, data) 116 | assert message == f'''Expected: a "{schema['format']}" formatted value\n\nReceived: "{data}"''' 117 | 118 | def test_validate_type_error(self): 119 | # string 120 | message = validate_type({"type": "string"}, 1) 121 | assert message == 'Expected: a "string" type value\n\nReceived: 1' 122 | 123 | # file 124 | message = validate_type({"type": "file"}, 1) 125 | assert message == 'Expected: a "file" type value\n\nReceived: 1' 126 | 127 | # boolean 128 | message = validate_type({"type": "boolean"}, 1) 129 | assert message == 'Expected: a "boolean" type value\n\nReceived: 1' 130 | 131 | # integer 132 | message = validate_type({"type": "integer"}, True) 133 | assert message == 'Expected: an "integer" type value\n\nReceived: True' 134 | 135 | # number 136 | message = validate_type({"type": "number"}, "string") 137 | assert message == 'Expected: a "number" type value\n\nReceived: "string"' 138 | 139 | # number 140 | message = validate_type({"type": "number"}, "string") 141 | assert message == 'Expected: a "number" type value\n\nReceived: "string"' 142 | 143 | # object 144 | message = validate_type({"type": "object"}, "string") 145 | assert message == 'Expected: an "object" type value\n\nReceived: "string"' 146 | 147 | # array 148 | message = validate_type({"type": "array"}, "string") 149 | assert message == 'Expected: an "array" type value\n\nReceived: "string"' 150 | 151 | 152 | class TestTestOpenAPIObjectErrors: 153 | def test_missing_response_key_error(self): 154 | expected_error_message = ( 155 | 'The following property is missing in the response data: "one"\n\n' 156 | "Reference: init.object:key:one\n\n" 157 | "Hint: Remove the key from your OpenAPI docs, or include it in your API response" 158 | ) 159 | tester = SchemaTester() 160 | with pytest.raises(DocumentationError, match=expected_error_message): 161 | tester.test_openapi_object( 162 | {"required": ["one"], "properties": {"one": {"type": "int"}}}, {"two": 2}, reference="init" 163 | ) 164 | 165 | def test_missing_schema_key_error(self): 166 | expected_error_message = ( 167 | 'The following property was found in the response, but is missing from the schema definition: "two"\n\n' 168 | "Reference: init.object:key:two\n\n" 169 | "Hint: Remove the key from your API response, or include it in your OpenAPI docs" 170 | ) 171 | tester = SchemaTester() 172 | with pytest.raises(DocumentationError, match=expected_error_message): 173 | tester.test_openapi_object( 174 | {"required": ["one"], "properties": {"one": {"type": "int"}}}, {"one": 1, "two": 2}, reference="init" 175 | ) 176 | 177 | def test_key_in_write_only_properties_error(self): 178 | expected_error_message = ( 179 | 'The following property was found in the response, but is documented as being "writeOnly": "one"\n\n' 180 | "Reference: init.object:key:one\n\n" 181 | 'Hint: Remove the key from your API response, or remove the "WriteOnly" restriction' 182 | ) 183 | tester = SchemaTester() 184 | with pytest.raises(DocumentationError, match=expected_error_message): 185 | tester.test_openapi_object( 186 | {"properties": {"one": {"type": "int", "writeOnly": True}}}, 187 | {"one": 1}, 188 | reference="init", 189 | ) 190 | 191 | 192 | def test_null_error(): 193 | expected_error_message = ( 194 | "Received a null value for a non-nullable schema object\n\n" 195 | "Reference: init\n\n" 196 | "Hint: Return a valid type, or document the value as nullable" 197 | ) 198 | tester = SchemaTester() 199 | with pytest.raises(DocumentationError, match=expected_error_message): 200 | tester.test_schema_section({"type": "object"}, None, reference="init") 201 | 202 | 203 | def test_any_of_error(): 204 | expected_error_message = ( 205 | "Expected data to match one or more of the documented anyOf schema types, but found no matches\n\n" 206 | "Reference: init.anyOf" 207 | ) 208 | tester = SchemaTester() 209 | with pytest.raises(DocumentationError, match=expected_error_message): 210 | tester.test_schema_section({"anyOf": []}, {}, reference="init") 211 | 212 | 213 | def test_one_of_error(): 214 | expected_error_message = ( 215 | "Expected data to match one and only one of the oneOf schema types; found 0 matches\n\nReference: init.oneOf" 216 | ) 217 | tester = SchemaTester() 218 | with pytest.raises(DocumentationError, match=expected_error_message): 219 | tester.test_schema_section({"oneOf": []}, {}, reference="init") 220 | -------------------------------------------------------------------------------- /tests/test_loaders.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import Mock, patch 4 | 5 | import pytest 6 | 7 | from openapi_tester.loaders import ( 8 | DrfSpectacularSchemaLoader, 9 | DrfYasgSchemaLoader, 10 | StaticSchemaLoader, 11 | UrlStaticSchemaLoader, 12 | ) 13 | from tests.utils import TEST_ROOT, get_schema_content 14 | 15 | yaml_schema_path = str(TEST_ROOT) + "/schemas/manual_reference_schema.yaml" 16 | json_schema_path = str(TEST_ROOT) + "/schemas/manual_reference_schema.json" 17 | 18 | loaders = [ 19 | StaticSchemaLoader(yaml_schema_path, field_key_map={"language": "en"}), 20 | StaticSchemaLoader(json_schema_path, field_key_map={"language": "en"}), 21 | DrfYasgSchemaLoader(field_key_map={"language": "en"}), 22 | DrfSpectacularSchemaLoader(field_key_map={"language": "en"}), 23 | ] 24 | static_schema_loaders = [ 25 | StaticSchemaLoader(yaml_schema_path, field_key_map={"language": "en"}), 26 | StaticSchemaLoader(json_schema_path, field_key_map={"language": "en"}), 27 | ] 28 | 29 | 30 | @pytest.mark.parametrize("loader", loaders) 31 | def test_loader_get_schema(loader): 32 | loader.get_schema() # runs internal validation 33 | 34 | 35 | def test_url_schema_loader(): 36 | test_schema_url = "http://schemas:8080/test/schema.yaml" 37 | schema_loader = UrlStaticSchemaLoader(test_schema_url) 38 | schema_content = get_schema_content(TEST_ROOT / "schemas" / "any_of_one_of_test_schema.yaml") 39 | 40 | with patch("openapi_tester.loaders.requests.get") as mocked_get_request: 41 | mocked_get_request.return_value = Mock(content=schema_content) 42 | loaded_schema = schema_loader.load_schema() 43 | 44 | assert type(loaded_schema) == dict 45 | assert loaded_schema["openapi"] == "3.0.0" 46 | assert loaded_schema["info"]["title"] == "Swagger Petstore" 47 | 48 | 49 | @pytest.mark.parametrize("loader", loaders) 50 | def test_loader_get_route(loader): 51 | assert loader.resolve_path("/api/v1/items/", "get")[0] == "/api/{version}/items" 52 | assert loader.resolve_path("api/v1/items/", "get")[0] == "/api/{version}/items" 53 | assert loader.resolve_path("api/v1/items", "get")[0] == "/api/{version}/items" 54 | assert loader.resolve_path("/api/v1/snake-case/", "get")[0] == "/api/{version}/snake-case/" 55 | assert loader.resolve_path("api/v1/snake-case/", "get")[0] == "/api/{version}/snake-case/" 56 | with pytest.raises(ValueError, match="Could not resolve path `test`"): 57 | assert loader.resolve_path("test", "get") 58 | 59 | 60 | @pytest.mark.parametrize("loader", loaders) 61 | def test_loader_resolve_path(loader): 62 | assert loader.resolve_path("/api/v1/cars/correct", "get") is not None 63 | 64 | with pytest.raises( 65 | ValueError, match="Could not resolve path `/api/v1/blars/correct`.\n\nDid you mean one of these?" 66 | ): 67 | loader.resolve_path("/api/v1/blars/correct", "get") 68 | 69 | 70 | @pytest.mark.parametrize("loader", static_schema_loaders) 71 | def test_static_loader_resolve_nested_route(loader): 72 | assert ( 73 | loader.resolve_path("/api/v1/categories/1/subcategories/1/", "get")[0] 74 | == "/api/{version}/categories/{category_pk}/subcategories/{subcategory_pk}/" 75 | ) 76 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from openapi_tester.utils import merge_objects 2 | from tests.utils import sort_object 3 | 4 | object_1 = {"type": "object", "required": ["key1"], "properties": {"key1": {"type": "string"}}} 5 | object_2 = {"type": "object", "required": ["key2"], "properties": {"key2": {"type": "string"}}} 6 | merged_object = { 7 | "type": "object", 8 | "required": ["key1", "key2"], 9 | "properties": {"key1": {"type": "string"}, "key2": {"type": "string"}}, 10 | } 11 | 12 | 13 | def test_documentation_error_sort_data_type(): 14 | assert sort_object([1, 3, 2]) == [1, 2, 3] # list 15 | assert sort_object({"1", "3", "2"}) == {"1", "2", "3"} # set 16 | assert sort_object({"1": "a", "3": "a", "2": "a"}) == {"1": "a", "2": "a", "3": "a"} # dict 17 | 18 | # Test sort failure scenario - expect the method to succeed and default to no reordering 19 | assert sort_object(["1", {}, []]) == ["1", {}, []] 20 | 21 | 22 | def test_merge_objects(): 23 | test_schemas = [ 24 | object_1, 25 | object_2, 26 | ] 27 | expected = { 28 | "type": "object", 29 | "required": ["key1", "key2"], 30 | "properties": {"key1": {"type": "string"}, "key2": {"type": "string"}}, 31 | } 32 | assert sort_object(merge_objects(test_schemas)) == sort_object(expected) 33 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from copy import deepcopy 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from rest_framework.response import Response 9 | 10 | from tests.schema_converter import SchemaToPythonConverter 11 | 12 | if TYPE_CHECKING: 13 | from typing import Any, Callable, Generator 14 | 15 | TEST_ROOT = Path(__file__).resolve(strict=True).parent 16 | 17 | 18 | def response_factory(schema: dict | None, url_fragment: str, method: str, status_code: int | str = 200) -> Response: 19 | converted_schema = None 20 | if schema: 21 | converted_schema = SchemaToPythonConverter(deepcopy(schema)).result 22 | response = Response(status=int(status_code), data=converted_schema) 23 | response.request = {"REQUEST_METHOD": method, "PATH_INFO": url_fragment} # type: ignore 24 | if schema: 25 | response.json = lambda: converted_schema # type: ignore 26 | return response 27 | 28 | 29 | def iterate_schema(schema: dict) -> Generator[tuple[dict | None, Response | None, str], None, None]: 30 | for url_fragment, path_object in schema["paths"].items(): 31 | for method, method_object in path_object.items(): 32 | if method.lower() != "parameters": 33 | for status_code, responses_object in method_object["responses"].items(): 34 | if status_code == "default": 35 | continue 36 | schema_section = None 37 | response = None 38 | with suppress(KeyError): 39 | if "content" in responses_object: 40 | schema_section = responses_object["content"]["application/json"]["schema"] 41 | elif "schema" in responses_object: # noqa: SIM908 42 | schema_section = responses_object["schema"] 43 | if schema_section: 44 | response = response_factory( 45 | schema=schema_section, url_fragment=url_fragment, method=method, status_code=status_code 46 | ) 47 | yield schema_section, response, url_fragment 48 | 49 | 50 | def mock_schema(schema) -> Callable: 51 | def _mocked(): 52 | return schema 53 | 54 | return _mocked 55 | 56 | 57 | def sort_object(data_object: Any) -> Any: 58 | """helper function to sort objects""" 59 | if isinstance(data_object, dict): 60 | for key, value in data_object.items(): 61 | if isinstance(value, (dict, list)): 62 | data_object[key] = sort_object(value) 63 | return dict(sorted(data_object.items())) 64 | if isinstance(data_object, list) and data_object: 65 | if not all(isinstance(entry, type(data_object[0])) for entry in data_object): 66 | return data_object 67 | if isinstance(data_object[0], (dict, list)): # pragma: no cover 68 | return [sort_object(entry) for entry in data_object] 69 | return sorted(data_object) 70 | return data_object 71 | 72 | 73 | def get_schema_content(schema: Path) -> bytes: 74 | with open(schema, "rb") as schema_file: 75 | return schema_file.read() 76 | --------------------------------------------------------------------------------